Testing Eloquent Query Scopes without Code Duplication

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.
Bonus
You can find the Laravel package i created that applies the above approach, from the link below: