Unit Testing Laravel Models
Tags: laravel, testing, php, webdev
Published: 2021-06-19 12:38:32

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
<?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.
```php
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.
```php
...
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.
```php
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.
```php
...
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:
```php
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:
```php
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](https://github.com/OpenLitterMap/openlittermap-web) 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.