/rspec-activerecord-expectations

A gem to test how many activerecord queries your code executes

Primary LanguageRubyMIT LicenseMIT

RSpec Activerecord Expectations

Rspec::ActiveRecord::Expectations is a library for testing that your code doesn't execute too many queries during execution. In development mode, it's common to use gems like bullet, but no similar functionality exists in RSpec.

Tests of this sort are useful when trying to write regression tests for code that frequently has N+1s introduced accidentally by new feature development.

While it's possible to use Benchmark.measure to assert that runtime hasn't changed, tests typically run with a very small number of records. This can make it difficult to ascertain whether new problems have been introduced. That's because the natural variance in execution time for queries takes over the majority of runtime, so benchmarked times are no longer valid.

This gem introduces a number of new expectation types for your tests that should make it easier to express the kinds of verification you're looking for.

Setup

Setup is fairly straightforward. Add this gem to your Gemfile in the test group.

group 'test' do
  gem 'rspec-activerecord-expectations', '~> 3.0'
end

Then, include the functionality within your rails_helper.rb (or in a support file).

RSpec.configure do |config|
  include RSpec::ActiveRecord::Expectations
end

That's it! Easy peasy.

Basic Usage

At its core, Rspec::ActiveRecord::Expectations is just a series of new matchers that you can apply to your tests. Let's take an example piece of app functionality.

class WastefulnessReport
  def perform
    (1..100).each do |n|
      ResourceType.find(n).summarize
    end
  end
end

This report obviously needs some refactoring. Let's add a test.

# spec/reports/wastefulness_report_spec.rb

RSpec.describe WastefulnessReport do

  it "is reasonably efficient" do
    expect {
      WastefulnessReport.new.perform
    }.to execute.fewer_than(10).queries
  end

end

Running this example, we'll see a usefully failing test!

Failures:

  1) WastefulnessReport is reasonably efficient
     Failure/Error:
       expect {
         WastefulnessReport.new.perform
       }.to execute.fewer_than(10).queries

       expected block to execute fewer than 10 queries, but it executed 100
     # ./spec/reports/wastefulness_report_spec.rb:30:in `block (2 levels) in <top (required)>'

That's it! Refactor your report to be more efficient and then leave the test in place to make sure that future developers don't accidentally cause a performance regression.

Preventing Repeated Load (N+1) Queries

Reloading, whether by e.g. Album.find or album.tracks are both antipatterns within your code. They will load from the database for every iteration in a loop, unless you load records outside the loop, cache responses, or use an eager loading mechanism like includes. These sorts of queries are often referred to as N+1 queries.

This sort of query can be prevented using the repeatedly_load expectation.

expect {}.not_to repeatedly_load('SomeActiveRecordClass')

This matcher will track ActiveRecord's built in load methods to prevent those N+1 situations. Using eager loading (e.g. Track.all.includes(:album)) will allow these expectations to pass as expected!

Testing Batch Queries

If your code loads records in batches, it may be more difficult to create expectations for repeated loading. After all, each batch will execute its own queries, which may look like repeated loading.

If your test has a small enough number of records that only one batch is loaded, your tests may work just fine. But otherwise, you may want to allow your code to specify a batch size in order to guarantee only a single batch is loaded.

tracks = Track.all

expect {
  TrackSerializer.perform(tracks, batch_size: tracks.count)
}.not_to repeatedly_load('Track')

Counting Queries

Some services won't necessarily have N+1 issues with records loading, but might still have problems with executing too many queries. In this case, the repeatedly_load matcher might be insufficient.

In this case, consider creating an expectation on the total number of queries executed by your code. Several comparison types are available, along with some aliases to allow for easier to read tests.

expect {}.to execute.less_than(20).queries
expect {}.to execute.fewer_than(20).queries

expect {}.to execute.less_than_or_equal_to(20).queries
expect {}.to execute.at_most(20).queries

expect {}.to execute.greater_than(20).queries
expect {}.to execute.more_than(20).queries

expect {}.to execute.greater_than_or_equal_to(20).queries
expect {}.to execute.at_least(20).queries

expect {}.to execute.exactly(20).queries

expect {}.to execute.at_least(2).queries
expect {}.to execute.at_least(1).query # singular form also accepted

Specific Query Types

You can make assertions for the total number of queries executed, but sometimes it's more valuable to assert that a particular type of query was executed. For example, a particular number of queries to destroy records. There are matchers available for that purpose as well!

expect {}.to execute.exactly(20).queries

expect {}.to execute.exactly(20).insert_queries
expect {}.to execute.exactly(20).load_queries
expect {}.to execute.exactly(20).destroy_queries
expect {}.to execute.exactly(20).exists_queries

expect {}.to execute.exactly(20).schema_queries
expect {}.to execute.exactly(20).transaction_queries

Note: Transaction (for example, ROLLBACK) queries are not counted in any of these categories, nor are queries that load the DB schema.

Note: Destroy and delete queries are both condensed into the matcher for the errorthe error destroy_queries.

Transaction Management

Sometimes, it makes sense to monitor whether database transactions were successful or not. This is very similar to using expect{}.to change(SomeModel, :count) in a spec, but nonetheless it can be useful to assert transactions themselves. Some assertions are available for this purpose.

expect {}.to execute_a_transaction
expect {}.to rollback_a_transaction
expect {}.to roll_back_a_transaction
expect {}.to commit_a_transaction

A complication to this scheme is that Rails tries not to make unnecessary database calls, which means that attempting to save a model that has failing validations won't actually attempt to save to the database.

expect {
  MyClass.create!(required_field: nil)
}.to rollback_a_transaction

This assertion will fail, as create! will never make it as far as the database. That said, if you manually create a transaction, and you select data within that transaction, you may assert a rollback.

expect {
  MyClass.first # triggers the transaction
  MyClass.create!(required_field: nil)
}.to rollback_a_transaction

It you need to make transaction-related assertions of this sort, your best bet may be to assert that a commit statement was not issued.

expect do
  MyClass.create!(required_field: nil)
rescue
  # NOOP
end.not_to rollback_a_transaction

Note that ActiveRecord will not only roll back the transaction, but also re-raise errors. As such, it's necessary in this example to rescue that error, otherwise the test would fail simply because the code caused a raise.

Counting Transactions

Similar to counting queries, you can quantify the number of transactions you expect to succeed / fail. This is probably of limited value in all but some very specific cases.

expect {}.to commit_a_transaction.once
expect {}.to rollback_a_transaction.exactly(5).times
expect {}.to commit_a_transaction.at_least(5).times

Future Planned Functionality

This gem still has lots of future functionality. See below.

expect {}.to execute.at_least(2).queries_of_type("Audited::Audit Load")
expect {}.to execute.at_least(2).load_queries("Audited::Audit")

expect {}.to execute.at_least(2).activerecord_queries
expect {}.to execute.at_least(2).hand_rolled_queries

expect {}.to create.exactly(5).of_type(User)
expect {}.to insert.exactly(5).subscription_changes
expect {}.to update.exactly(2).of_any_type
expect {}.to delete.exactly(2).of_any_type
  • warn if we smite any built in methods (or methods from other libs)
  • support Rails 6 bulk insert (still one query)

Development

After checking out the repo, run bundle install to install dependencies. Then, run rake spec to run the tests.

Bug reports and PRs are welcome at https://github.com/jmmastey/rspec-activerecord-expectations.

Code of Conduct

Everyone interacting in the Rspec::Activerecord::Expectations project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.

Thanks

This gem was heavily patterned after shoulda-matchers, a project by Thoughtbot that has some seriously complex matchers. Thanks y'all.