Testing Eloquent Models with Faktory

by Adam Wathan on September 26, 2014

Eloquent is an ActiveRecord implementation, which means a lot of the time the behavior you’re adding to your models needs to hit the database to work correctly.

Imagine you a Customer model who has many Orders, and you need a way to get the orders for that customer that haven’t been shipped yet.

The implementation could be as simple as this:

<?php

// Customer.php
public function getOpenOrders()
{
    return $this->orders()->whereNull('date_shipped')->get();
}

But how would you write a test to make sure you are getting the correct orders back?

Unit Testing

If you try to test this in isolation, you might try to stub the orders relationship:

<?php

// CustomerTest.php
public function test_it_can_retrieve_open_orders()
{
    $open_orders = M::mock('OrderCollection');
    $customer = M::mock('Customer[orders]');
    $customer->shouldReceive('orders->whereNull->get')->andReturn($open_orders);

    $this->assertEquals($open_orders, $customer->getOpenOrders());
}

This test passes, but if you think about it, you’re really not testing that anything actually works. All you’re doing is taking the implementation that you expect to work, and duplicating it in your test!

In fact, this test passes even if orders() hasn’t been defined on Customer. It also passes if the logic is incorrect, as long as you just put the same incorrect logic in both the Customer and the test.

We need to know that when we ask for open orders, we get back the orders that really are still open.

Functional Testing

If we want to make sure we’re actually getting back the right orders, we need to hit the database.

Laravel makes it really easy to setup an in-memory SQLite database for tests, here’s an example.

The approach we like to use goes like this:

  1. Setup some shipped orders and some unshipped orders
  2. Save those orders to a customer
  3. Ask that customer for their open orders
  4. Verify that the orders that come back are the ones we expect

So you might end up with a test that looks something like this:

<?php

// CustomerTest.php
public function setUp()
{
    parent::setUp();
    Artisan::call('migrate');
}

public function test_it_can_retrieve_open_orders()
{
    Eloquent::unguard();

    $shipped_order_1 = new Order([
        'shipping_address' => '123 Fake St.',
        'shipping_city' => 'Fakeville',
        'shipping_province' => 'Ontario',
        'shipping_country' => 'Canada',
        'shipping_postal_code' => 'ABC 123',
        'date_shipped' => new DateTime('5 days ago'),
    ]);

    $shipped_order_2 = new Order([
        'shipping_address' => '123 Fake St.',
        'shipping_city' => 'Fakeville',
        'shipping_province' => 'Ontario',
        'shipping_country' => 'Canada',
        'shipping_postal_code' => 'ABC 123',
        'date_shipped' => new DateTime('3 days ago'),
    ]);

    $unshipped_order = new Order([
        'shipping_address' => '123 Fake St.',
        'shipping_city' => 'Fakeville',
        'shipping_province' => 'Ontario',
        'shipping_country' => 'Canada',
        'shipping_postal_code' => 'ABC 123',
        'date_shipped' => null,
    ]);

    $customer = Customer::create([
        'first_name' => 'John',
        'last_name' => 'Doe',
        'email' => 'example@example.com',
        'phone' => '555 555 5555',
    ]);

    $customer->orders()->saveMany([
        $shipped_order_1,
        $shipped_order_2,
        $unshipped_order
    ]);

    $open_orders = $customer->getOpenOrders();

    $this->assertTrue($open_orders->contains($unshipped_order));
    $this->assertFalse($open_orders->contains($shipped_order_1));
    $this->assertFalse($open_orders->contains($shipped_order_2));
}

Well that felt excessive. 40+ lines of setup for 3 assertions? There must be a better way…

Faktory

The thing that sucks about all that setup is that we really only care about the date_shipped field on the orders. But since we need to save these orders to our test database, we need to make sure we’re providing valid values for every field or we’re going to hit an error when we try to save the records.

The solution to this problem is to use factories to generate our objects for us.

Factories?

Think of factories as little helpers that can spit out your Eloquent models in their minimally valid state.

They also let you easily specify the attributes that are actually relevant to your test. This has a big advantage over just using seed data or fixtures, as it keeps all of the details important to your test together in one place. This makes it really easy for someone reading your test to see the whole picture and understand what you’re trying to test.

Defining our factories

In our test above, there’s a lot of details about orders and customers that aren’t relevant to what we’re testing. We can trim a lot of the cruft by defining factories that fill in the irrelevant details for us.

Our factories are going to look like this:

<?php

Faktory::define(['order', 'Order'], function ($f) {
    $f->shipping_address = '123 Fake St.';
    $f->shipping_city = 'Fakeville';
    $f->shipping_province = 'Ontario';
    $f->shipping_country = 'Canada';
    $f->shipping_postal_code = 'ABC 123';
});

Faktory::define(['customer', 'Customer'], function ($f) {
    $f->first_name = 'John';
    $f->last_name = 'Doe';
    $f->email = 'example@example.com';
    $f->phone = '555 555 5555';
});

Updating our test

Using Faktory, our test ends up looking like this:

<?php

// CustomerTest.php
public function setUp()
{
    parent::setUp();
    Artisan::call('migrate');
    Eloquent::unguard();
}

public function test_it_can_retrieve_open_orders()
{
    $customer = Faktory::create('customer');
    $shipped_order1 = Faktory::create('order', ['date_shipped' => new DateTime('5 days ago')]);
    $shipped_order2 = Faktory::create('order', ['date_shipped' => new DateTime('3 days ago')]);
    $unshipped_order = Faktory::create('order', ['date_shipped' => null]);

    $customer->orders()->save([$shipped_order1, $shipped_order2, $unshipped_order]);

    $open_orders = $customer->getOpenOrders();

    $this->assertTrue($open_orders->contains($unshipped_order));
    $this->assertFalse($open_orders->contains($shipped_order1));
    $this->assertFalse($open_orders->contains($shipped_order2));
}

Using Faktory, we were able to cut out any unnecessary details in our test, while also making it clear and explicit what details actually matter to what we’re testing. We also now have all of the information about minimally valid orders and customers encapsulated into one place, so if that ever changes, updating our tests is going to be trivial.

To find out more about Faktory, check out the documentation on GitHub.

The following two tabs change content below.

Adam Wathan

Developer, powerlifter and resident Slayer fan.

Latest posts by Adam Wathan (see all)