4

DRYing up test coverage using model scope events in Laravel

 2 years ago
source link: https://devonmather.dev/posts/drying-up-test-coverage-using-model-scope-events
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

DRYing up test coverage using model scope events in Laravel

Published: 20.02.22 Last Updated: 21.02.22

DRYing (Don't Repeat Yourself) up code can be a useful step in maintaining a codebase over time. As a rule of thumb, I generally try to only dry up code once the code is repeated three times as per the ever insightful BaseCode Field Guide. In the case of test coverage the amount of code being duplicated can add up and I may even dry up the code if only duplicating it twice.

Let's take a look at an example problem;

The Real World Problem

  • You have a complicated conditional query for Leads that is important to be tested rigorously
    • Get the leads that have not been contacted
    • Sorted by creation date descending
    • They should be in a valid status state
    • They should be a certain region, certain age etc.
    • The list could go on.
  • The above query constraints should be tested at the feature level, hopefully, isolated so that you have tests like so;
    • user_can_get_leads
    • user_can_only_get_uncontacted_leads
    • user_can_only_get_qualified_leads
    • users_can_get_leads_by_created_at_desc
    • Similarly the list could go on here too.
  • The query is used in multiple places i.e. an API route, Web Route, and a CSV Export
  • Thus a scope on the Lead model could be a good option something like scopeQualifiedLeadsReadyForContact() that way if further constraints are added all parts of the app using this query are updated.
  • If the scope was updated that would also involve adding further feature tests to all the files using this scope.
  • The above situation occurring is uncommon but in cases that it happens, this can be a burden to remember to manage these duplicate tests where ideally one should suffice.
  • Since the query is happening in the model scope it would be nice to unit test the query in the models unit test and therefore only write the test coverage once.
  • However it's hard to ensure in your feature tests that model scope with test coverage was actually used in your controller thus you will likely duplicate that test coverage somewhat in your Feature tests. Thus making the unit you've written rather pointless.

So with the above in mind let's explore the option of firing events on model scopes to add some glue between our Feature tests and our Unit tests.

Taking the above problem and simplifying it a bit let's look at our initial test coverage at the Feature level.

Setting up the initial test coverage for our controller

<?php

// Located in App\Http\Controllers\LeadIndexController.php

namespace App\Http\Controllers;

use App\Models\Lead;
use Inertia\Inertia;

class LeadIndexController extends Controller
{
    public function __invoke() 
    {
        return Inertia::render('Leads/Index', [
            'title' => 'Leads',
            'leads' => Lead::qualified()->createdAtDesc()->get(),
        ]);
    }
}
<?php

// Located in test/Feature/Http/Controllers/LeadIndexControllerTest.php

namespace Tests\Feature\Http\Controllers;

use App\Models\Lead;
use Tests\TestCase;

class LeadIndexControllerTest extends TestCase
{
    /** @test */
    public function user_can_get_leads()
    {
        $leads = Lead::factory()->qualified()->createMany([
            ['created_at' => Carbon::parse('7 days ago')],
            ['created_at' => Carbon::parse('14 days ago')],
            ['created_at' => Carbon::parse('21 days ago')],
        ]);

        $response = $this->get(route('leads.index'));

        $response->assertOk()
            ->assertInertia(fn ($page) => $page
                ->component('Leads/Index')
                ->has('leads', 3, fn ($page) => $page
                    ->where('id', $leads->first()->id)
                    ->where('name', $leads->first()->title)
                    ->where('email', $leads->first()->excerpt)
                    ->where('lead_status', $leads->first()->excerpt)
                    ->where('created_at', $leads->first()->created_at->format('Y-m-d'))
                )
            );
    }

    /** @test */
    public function user_can_get_leads_desc_created_at()
    {
        $leads = Lead::factory()->qualified()->createMany([
            ['created_at' => Carbon::parse('7 days ago')],
            ['created_at' => Carbon::parse('3 days ago')],
            ['created_at' => Carbon::parse('14 days ago')],
        ]);

        $response = $this->get(route('leads.index'));

        $response->assertOk()
            ->assertInertia(fn ($page) => $page
                ->component('Leads/Index')
                ->has('leads.0', fn ($page) => $page
                    ->where('id', $leads->skip(1)->first()->id)
                    ->etc()
                )
                ->has('leads.1', fn ($page) => $page
                    ->where('id', $leads->first()->id)
                    ->etc()
                )
                ->has('leads.2', fn ($page) => $page
                    ->where('id', $leads->skip(2)->first()->id)
                    ->etc()
                )
            );
    }

    /** @test */
    public function user_can_only_get_leads_that_are_qualified()
    {
        Lead::factory()->churned()->create();
        Lead::factory()->unqualified()->create();

        $response = $this->get(route('leads.index'));

        $response->assertOk()
            ->assertInertia(fn ($page) => $page
                ->component('Leads/Index')
                ->has('leads', 0)
            );
    }
}

Let's take this a step further and assume our app has a Command that runs at midnight to export leads created in the last 24 hrs in a CSV, this export has similar constraints on the query i.e. only fetches qualified leads and exports them by created_at desc. I'll leave the tests empty but as you can see the test coverage for them would be similar.

<?php

// Located in test/Feature/Console/Commands/LeadExportCommandTest.php

namespace Tests\Feature\Console\Commands;

use Tests\TestCase;

class LeadExportCommandTest extends TestCase
{
    /** @test */
    public function command_can_export_leads(){}

    /** @test */
    public function command_can_export_leads_desc_created_at(){}

    /** @test */
    public function command_can_only_export_leads_that_are_qualified(){}
}

As you can see we are developing some similar test coverage across a few tests files. While in the above example this is not much of a problem as it's a simple isolated example. With real-world business cases with a lot more edge cases, this can become difficult to maintain with time and developer turnover.

Enter the model scope event trait

<?php 

// Located in App\Models\Traits\ModelScopedCalledTrait.php

namespace App\Models\Traits;

use App\Events\ModelScopedCalledEvent;

trait ModelScopedCalledTrait
{
    /**
     * Apply the given named scope if possible.
     *
     * Overriding this functionality so as to fire events that can be asserted in tests.
     *
     * @param  string  $scope
     * @param  array  $parameters
     * @return mixed
     */
    public function callNamedScope($scope, array $parameters = [])
    {
        // Start custom override
        if (app()->runningUnitTests()) {
            event(new ModelScopedCalledEvent($scope));
        }
        // End custom override

        return $this->{'scope' . ucfirst($scope)}(...$parameters);
    }
}

This trait can now be applied to models you want to be able to assert that a model scope was called on in your tests.

<?php

// Located in App\Models\Lead.php

namespace App\Models;

use App\Enums\LeadStatus;
use App\Models\Traits\ModelScopedCalledTrait;
use Illuminate\Database\Eloquent\Factories\HasFactory;

class Lead extends Model
{
    use HasFactory, ModelScopedCalledTrait;

    public function scopeCreatedAtDesc($query)
    {
        $query->latest('created_at');
    }

    public function scopeQualfied($query)
    {
        $query->where('status', '!=', LeadStatus::DISQUALIFIED);
    }
}

Let's add some unit tests to our Lead model, that our feature tests can later leverage so as to remove duplicate test coverage.

<?php

// Located in Tests\Unit\Models\LeadTest.php

namespace Tests\Unit\Models;

use App\Models\Lead;
use PHPUnit\Framework\TestCase;

class LeadTest extends TestCase
{
    /** @test */
    public function scope_created_at_desc()
    {
        Lead::factory()->createMany([
            [
                'id' => 1,
                'publish_date' => Carbon::parse('14 days ago')
            ],
            [
                'id' => 2,
                'publish_date' => Carbon::parse('7 days ago')
            ],
            [
                'id' => 3,
                'publish_date' => Carbon::parse('21 days ago')
            ],
        ]);

        $leads = Lead::createdAtDesc()->get();

        $this->assertCount(3, $leads);
        $this->assertEquals([2, 1, 3], $leads->pluck('id')->toArray());
    }

    /** @test */
    public function scope_qualified()
    {
        $qualifiedLead = Lead::factory()->qualified()->create();
        Lead::factory()->unqualified()->create();
        Lead::factory()->churned()->create();

        $leads = Lead::qualified()->get();

        $this->assertCount(1, $leads);
        $this->assertEquals($qualifiedLead->id, $response->first()->id);
    }
}

Now that we have unit tests on the model scopes and we have our ModelScopeCalledEvent firing we can simplify our test coverage in LeadIndexControllerTest.php like so;

<?php

namespace Tests\Feature\Http\Controllers;

use App\Models\Lead;
use Tests\TestCase;

class LeadIndexControllerTest extends TestCase
{
    /** @test */
    public function user_can_get_leads()
    {
        $leads = Lead::factory()->qualified()->createMany([
            ['created_at' => Carbon::parse('7 days ago')],
            ['created_at' => Carbon::parse('14 days ago')],
            ['created_at' => Carbon::parse('21 days ago')],
        ]);

        $response = $this->get(route('leads.index'));

        $response->assertOk()
            ->assertInertia(fn ($page) => $page
                ->component('Leads/Index')
                ->has('leads', 3, fn ($page) => $page
                    ->where('id', $leads->first()->id)
                    ->where('name', $leads->first()->title)
                    ->where('email', $leads->first()->excerpt)
                    ->where('lead_status', $leads->first()->excerpt)
                    ->where('created_at', $leads->first()->created_at->format('Y-m-d'))
                )
            );
    }

    // Replaced with user_queries_leads_with_desired_model_scopes
    // /** @test */
    // public function user_can_get_leads_desc_created_at()
    // {
    //     $leads = Lead::factory()->qualified()->createMany([
    //         ['created_at' => Carbon::parse('7 days ago')],
    //         ['created_at' => Carbon::parse('3 days ago')],
    //         ['created_at' => Carbon::parse('14 days ago')],
    //     ]);

    //     $response = $this->get(route('leads.index'));

    //     $response->assertOk()
    //         ->assertInertia(fn ($page) => $page
    //             ->component('Leads/Index')
    //             ->has('leads.0', fn ($page) => $page
    //                 ->where('id', $leads->skip(1)->first()->id)
    //                 ->etc()
    //             )
    //             ->has('leads.1', fn ($page) => $page
    //                 ->where('id', $leads->first()->id)
    //                 ->etc()
    //             )
    //             ->has('leads.2', fn ($page) => $page
    //                 ->where('id', $leads->skip(2)->first()->id)
    //                 ->etc()
    //             )
    //         );
    // }

    // /** @test */
    // public function user_can_only_get_leads_that_are_qualified()
    // {
    //     Lead::factory()->churned()->create();
    //     Lead::factory()->unqualified()->create();

    //     $response = $this->get(route('leads.index'));

    //     $response->assertOk()
    //         ->assertInertia(fn ($page) => $page
    //             ->component('Leads/Index')
    //             ->has('leads', 0)
    //         );
    // }

    /** @test */
    public function user_queries_leads_with_desired_model_scopes()
    {
        Event::fake([ModelScopeCalledEvent::class]);
        
        $response = $this->get(route('leads.index'));

        $triggeredScopes = [];
        Event::assertDispatched(ModelScopeCalledEvent::class, 2);
        Event::assertDispatched(function (ModelScopeCalledEvent $event) use (&$triggeredScopes) {
            $triggeredScopes[] = $event->scope;
            return true;
        });
        $this->assertTrue(in_array('qualified', $triggeredScopes));
        $this->assertTrue(in_array('createdAtDesc', $triggeredScopes));
    }
}

The same would apply to LeadExportCommandTest

<?php

// Located in test/Feature/Console/Commands/LeadExportCommandTest.php

namespace Tests\Feature\Console\Commands;

use Tests\TestCase;

class LeadExportCommandTest extends TestCase
{
    /** @test */
    public function command_can_export_leads(){}

    // Replaced with command_queries_leads_with_desired_model_scopes
    // /** @test */
    // public function command_can_export_leads_desc_created_at(){}

    // /** @test */
    // public function command_can_only_export_leads_that_are_qualified(){}

    /** @test */
    public function command_queries_leads_with_desired_model_scopes(){}
}

Hopefully, this can serve as a potential approach to piecing together test coverage at the unit level into your feature tests. And therefore simplifying the feature tests and removing duplicate test coverage.

The above solution has some caveats;

  • You could have the scoped called by another query but not the one that returned the data
  • In the trait you are overriding callNamedScope on the base Model

Due to the above, it should be used sparingly only when the need arises.

If you have thoughts or better approaches reach out to me on Twitter.


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK