Testingmania #1: Unit Testing Laravel Models

Geni Jaho

13 min read

Some might consider Laravel model testing to be a gruesome task, especially when there's plenty of them in the codebase, and most of them are covered by other unit or feature tests. However, to get to that sweet 100% (or so) coverage, these tests need to be carried out. Plus, you can be much more specific when testing them in isolation.

The model we're testing goes like this, stripped of comments and similar relationships and attributes since we need to show only one of each thing we're testing.

<?php
namespace App\Models;
use ...

class Photo extends Model
{
    use HasFactory;

    protected $fillable = [
        'filename',
        'model',
        'datetime',
        ...
    ];

    protected $appends = ['selected'];

    public function getSelectedAttribute()
    {
        return false;
    }

    public static function categories()
    {
        return [
            'smoking',
            'food',
            'brands',
            ...
        ];
    }

    public function tags()
    {
        foreach ($this->categories() as $category) {
            if ($this->$category) {
                foreach ($this->$category->types() as $tag) {
                    if (is_null($this->$category[$tag])) {
                        unset ($this->$category[$tag]);
                    }
                }
            }
        }
    }

    public function total()
    {
        $total = 0;

        foreach ($this->categories() as $category) {
            if ($this->$category) {
                // We dont want to include brands in total_litter
                if ($category !== 'brands') {
                    $total += $this->$category->total();
                }
            }
        }

        $this->total_litter = $total;
        $this->save();
    }

    public function boxes()
    {
        return $this->hasMany(Annotation::class);
    }

    public function owner()
    {
        return $this->belongsTo(User::class, 'user_id');
    }

    /**
     * Litter categories
     */
    public function smoking()
    {
        return $this->hasOne(
            'App\Models\Litter\Categories\Smoking',
            'id',
            'smoking_id'
        );
    }


    public function brands()
    {
        return $this->hasOne(
            'App\Models\Litter\Categories\Brand',
            'id',
            'brands_id'
        );
    }

    /**
     * More Litter categories
     */
}

Testing that the model has the expected columns

The first assertion that we want to do is to check if the model has all the important columns in the database. This will ensure that whatever happens, the models' schema will be accounted for.

use Illuminate\Support\Facades\Schema;
use Tests\TestCase;

class PhotoTest extends TestCase
{
    public function test_photos_database_has_expected_columns()
    {
        $this->assertTrue(
            Schema::hasColumns('photos', [
                'id', 'user_id', 'filename', 'model',
                'datetime', 'lat', 'lon', 'verification',
                ...
            ])
        );
    }
}

Testing that the model has the expected attributes

The Photo model has a selected attribute, and we can't know for sure that it will always be there unless we write a test for it. The attribute simply returns false at the time of writing, which is the only thing we need to test.

...
public function test_a_photo_has_selected_attribute()
{
    $photo = Photo::factory()->create();
    $this->assertFalse($photo->selected);
}
...

Testing the relationships on the model

When testing model relationships there are generally two things I want to test. The first one is the relationship type or the class of the object being returned, and the second is checking whether the object returned is the correct one. This has proven to me to work well on many occasions, it's neither too strict nor too relaxed.

public function test_a_photo_has_many_boxes()
{
    $photo = Photo::factory()->create();
    $annotation = Annotation::factory()->create([
        'photo_id' => $photo->id
    ]);

    $this->assertInstanceOf(Collection::class, $photo->boxes);
    $this->assertCount(1, $photo->boxes);
    $this->assertTrue($annotation->is($photo->boxes->first()));
}

public function test_a_photo_has_an_owner()
{
    $owner = User::factory()->create();
    $photo = Photo::factory()->create([
        'user_id' => $owner->id
    ]);

    $this->assertInstanceOf(User::class, $photo->owner);
    $this->assertTrue($owner->is($photo->owner));
}

public function test_a_photo_has_a_smoking_relationship()
{
    $smoking = Smoking::factory()->create();
    $photo = Photo::factory()->create([
        'smoking_id' => $smoking->id
    ]);

    $this->assertInstanceOf(Smoking::class, $photo->smoking);
    $this->assertTrue($smoking->is($photo->smoking));
}

Testing the methods on the model

Notice the Photo has a categories method. It simply returns an array of strings, so we make a simple assertion for that.

...
public function test_a_photo_has_categories()
{
    $photo = Photo::factory()->create();
    $this->assertIsArray($photo->categories());
    $this->assertNotEmpty($photo->categories());
}
...

The next stop is the tags method. What it does is basically strips the empty tags off of every category relationship of the model. Since we're unit testing, we don't need to know why, we just need to know that it works. So:

public function test_a_photo_removes_empty_tags_from_categories()
{
    $smoking = Smoking::factory([
        'butts' => 1, 'lighters' => null
    ])->create();
    $brands = Brand::factory([
        'walkers' => 1, 'amazon' => null
    ])->create();
    $photo = Photo::factory()->create([
        'smoking_id' => $smoking->id,
        'brands_id' => $brands->id
    ]);
    // As a sanity check, we first test that
    // the current state is as we expect it to be
    $this->assertEquals(1, $photo->smoking->butts);
    $this->assertEquals(1, $photo->brands->walkers);
    $this->assertArrayHasKey(
        'lighters', $photo->smoking->getAttributes()
    );
    $this->assertArrayHasKey(
        'amazon', $photo->brands->getAttributes()
    );

    $photo->tags();

    $this->assertEquals(1, $photo->smoking->butts);
    $this->assertEquals(1, $photo->brands->walkers);
    $this->assertArrayNotHasKey(
        'lighters', $photo->smoking->getAttributes()
    );
    $this->assertArrayNotHasKey(
        'amazon', $photo->brands->getAttributes()
    );
}

The total method calculates the total number of litter present in a Photo by summing the totals of each category. It should filter out the litter for brands and for that reason we include an item for it. However, we test that it does not get added to the total number of items:

public function test_a_photo_has_a_count_of_total_litter_in_it()
{
    $smoking = Smoking::factory(['butts' => 1])->create();
    $brands = Brand::factory(['walkers' => 1])->create();
    $photo = Photo::factory()->create([
        'smoking_id' => $smoking->id,
        'brands_id' => $brands->id
    ]);

    $photo->total();

    // Brands are not calculated
    $this->assertEquals($smoking->total(), $photo->total_litter);
}
The code used for illustration is taken from the OpenLitterMap project. They're doing a great job creating the world's most advanced open database on litter, brands & plastic pollution. The project is open-sourced and would love your contributions, both as users and developers.