AR Query Matchers
These RSpec matchers allows guarding against N+1 queries by specifying exactly how many queries we expect each of our models to perform.
They also help us reason about the type of record interactions happening in a block of code.
This pattern is a based on how Rails itself tests queries: https://github.com/rails/rails/blob/ac2bc00482c1cf47a57477edb6ab1426a3ba593c/activerecord/test/cases/test_case.rb#L104-L141
This module defines a few categories of matchers:
- Create: Which models are created during a block
- Load: Which models are fetched during a block
- Update: Which models are updated during a block
Each matcher category includes 3 assertions, for example, for the Load category, you could use the following assertions:
- only_load_models: Strict assertion, not other query is allowed.
- not_load_models: No models are allowed to be loaded.
- load_models: Inclusion, other models are allowed to be loaded if not specified in the assertion.
For example:
The following spec will pass only if there are exactly 4 SQL SELECTs that load User records (and 1 for Address, 1 for Payroll) and no other models perform any SELECT queries.
expect { some_code() }.to only_load_models(
'User' => 4,
'Address' => 1,
'Payroll' => 1,
)
The following spec will pass only if there are exactly no select queries.
expect { some_code() }.to not_load_models
The following spec will pass only if there are exactly 4 SQL SELECTs that load User records (and 1 for Address, 1 for Payroll).
expect { some_code() }.to load_models(
'User' => 4,
'Address' => 1,
'Payroll' => 1,
)
This will show some helpful output on failure:
Expected to run queries to load models exactly {"Address"=>1, "Payroll"=>1, "User"=>4} queries, got {"Address"=>1, "Payroll"=>1, "User"=>5}
Expectations that differed:
User - expected: 4, got: 5 (+1)
Source lines:
User loaded from:
4 call: /spec/controllers/employees_controller_spec.rb:128:in 'block (4 levels) in <top (required)>'
1 call: /app/models/user.rb:299
High Level Design:
The RSpec matcher delegates to the query counters, asserts expectations and formats error messages to provide meaningful failures.
The matchers are pretty simple, and delegate instrumentation into specialized QueryCounter classes. The QueryCounters are different classes instruments a ruby block by listening on all sql, parsing the queries and returning structured data describing the interactions.
┌────────────────────────────────────────────────────────────────────────────────────────┐
┌─┤expect { Employee.create!() }.to only_create_models('Employee' => 1) │
│ └────────────────────────────────────────────────────────────────────────────────────────┘
└▶┌────────────────────────────────────────────────────────────────────────────────────────┐
┌─┤Queries::CreateCounter.instrument { Employee.create!() } => QueryStats │
│ └────────────────────────────────────────────────────────────────────────────────────────┘
└▶┌────────────────────────────────────────────────────────────────────────────────────────┐
│QueryCounter.new(CreateQueryFilter.new).instrument { Employee.create!() } => QueryStats │
└────────────────────────────────────────────────────────────────────────────────────────┘
For more information, see:
ArQueryMatchers::Queries::QueryCounter
ArQueryMatchers::Queries::CreateCounter
ArQueryMatchers::Queries::LoadCounter
ArQueryMatchers::Queries::UpdateCounter
Known problems
- The Rails 4
ActiveRecord::Base#pluck
method doesn't issue aLoad
orExists
named query and therefore we don't capture the counts with this tool. This may be fixed in Rails 5/6.