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.
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,
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;
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
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
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
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
All we have to do is use the
assertScopeCalled() method of the
HasScopeAssertion trait and check whether the query scopes we use are called.
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
Thus, we do not fall into code duplication and therefore do not violate the DRY (Don’t Repeat Yourself) principle.
You can find the Laravel package i created that applies the above approach, from the link below: