Testing Eloquent Query Scopes without Code Duplication

Turan Karatuğ
5 min readApr 29, 2022

--

Photo by Ferenc Almasi on Unsplash

One of Laravel’s blessings, Eloquent ORM, provides a mechanism called Query Scope for creating reusable queries on a table. In this way, we get cleaner and simpler controllers as the repetitive parts of the queries are transferred to the model.

For detailed information on query scopes, you can check Laravel’s official documentation here, as in this post, I will focus on how query scopes can be tested without violating the DRY (Don’t Repeat Yourself) principle.

Problem

First, let’s state the problem. For example, in an e-commerce project, we have a complicated conditional query for Orders that is important to be tested. With this query, orders will be filtered according to different criteria as follows,

  • paid orders
  • delivered orders
  • orders sorted by created date as descending

For the above query constraints, we can create the following query scopes in the Order model,

  • scopePaid()
  • scopeDelivered()
  • scopeCreatedAtDesc()

Considering that we use all of these query scopes in the same method in an OrderController, we will need to write a feature test for the following cases;

  • user_can_get_orders
  • user_can_get_only_paid_orders
  • user_can_get_only_delivered_orders
  • user_can_get_orders_by_created_at_desc

This approach has several disadvantages;

  • We may need to use these query scopes in more than one place. We may have created two different controllers for API and WEB environments and even a separate controller for CSV export. In this case, we would have to rewrite the above four test cases for all three controllers. Thus, we violate the DRY (Don’t Repeat Yourself) principle.
  • When we change any of the scopes, we need to make the exact change in all the feature tests where they are used.
  • The more places they are used, the harder it is to remember them, and we lose effort and time because of overlooked tests.

At this point, the first thing that comes to mind would be to write a unit test for each of the query scopes. Since the query is happening in the model scope, it would be nice to test the query in the model’s unit test and only write the test coverage once. However, in our feature tests, it’s hard to be sure that the model scope with test coverage is actually used in our controller, so we’ll likely duplicate that test coverage in our Feature tests somehow.

So, how can we test the places where we use these query scopes without code duplication? Otherwise, the unit tests we have written will not be sufficient for test coverage.

Now that we have clearly stated the problem, we can move on to the solution phase.

If we can find a way to detect which query scope was called in a model, we can easily assert that in our feature tests. To achieve this, we will use event, another nice feature of Laravel.

The method we will follow is to trigger an event when a query scope is called, mock the Event facade in the feature tests, and check whether the event we created is triggered for the scope.

1- Creating the ModelScopeCalled Event

We start by creating the event. For this, we create a new event named ModelScopeCalled in the app/Events directory.

We can also easily create an event using an artisan command:

$ php artisan make:event ModelScopeCalled

After running the above command, we enter the relevant file and write our event;

The parameters in the __construct() method represent the name of the scope and the model.

2- Triggering the ModelScopeCalled Event When a Query Scope is Called

Now that we’ve created the event, it’s time to find where to trigger it.

In Laravel, the Illuminate\Database\Eloquent\Model class which models are extended has a method called callNamedScope(). This method is where the local scopes in the model are executed. Therefore, we can trigger the event we created above at this very point.

We will use PHP’s trait mechanism for this. First, we create a trait named HasScopeWatcher under the app\Traits directory of our project and override the callNamedScope() method I mentioned above:

We created a method with the same name in the trait that overrides the callNamedScope() method in the Model class. Only while the tests are running, when any query scope is called, we trigger the ModelScopeCalled event we created earlier by giving the scope and model names.

Now, this trait can be applied to models we want to be able to assert that query scope was called in our tests.

3- Scope Assertion

What we need to do at this stage is to fake Laravel’s Event facade in our feature tests and check whether the ModelScopeCalled event is triggered for the scope and the model.

We can create another trait for this which will be used in the Tests\TestCase class.

In the trait above, we created assertScopeCalled()method to check the query scopes called in feature tests easily. Then we defined an array and pushed the scope and model names that triggered the event. Finally, we checked whether the scope and model we passed as parameters to the method are included in this array.

Of course, to use the assertScopeCalled() method in all our tests, we need to add this trait to the Tests\TestCase class and fake the ModelScopeCalled event:

4- Testing Scopes in Feature Tests

After setting up the necessary infrastructure for testing, we can write a feature test for the OrderController@index method, which we mentioned at the beginning of the article.

I assume that the index() method in the example above is run through the route named orders.index and the query scopes used are available in the Order model as scopePaid(), scopeDelivered(), and scopeCreatedAtDesc().

All we have to do is use the assertScopeCalled() method of the HasScopeAssertion trait and check whether the query scopes we use are called.

Conclusion

With the above approach, it will be sufficient to write unit tests of all query scopes in the model classes and check whether they are called in feature tests with the help of the assertScopeCalled() method.

Thus, we do not fall into code duplication and therefore do not violate the DRY (Don’t Repeat Yourself) principle.

--

--