rubyconvict / n_plus_one_control

RSpec and Minitest matchers to prevent N+1 queries problem

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Gem Version Build Status

N + 1 Control

RSpec and Minitest matchers to prevent the N+1 queries problem.

Example output

Why yet another gem to assert DB queries?

Unlike other libraries (such as db-query-matchers, rspec-sqlimit, etc), with n_plus_one_control you don't have to specify exact expectations to control your code behaviour (e.g. expect { subject }.to query(2).times).

Such expectations are rather hard to maintain, 'cause there is a big chance of adding more queries, not related to the system under test.

NPlusOneControl works differently. It evaluates the code under consideration several times with different scale factors to make sure that the number of DB queries behaves as expected (i.e. O(1) instead of O(N)).

So, it's for performance testing and not feature testing.

Why not just use bullet?

Of course, it's possible to use Bullet in tests (see more here), but it's not a silver bullet: there can be both false positives and true negatives.

This gem was born after I've found myself not able to verify with a test yet another N+1 problem.

Sponsored by Evil Martians

Installation

Add this line to your application's Gemfile:

group :test do
  gem 'n_plus_one_control'
end

And then execute:

$ bundle

Usage

RSpec

First, add NPlusOneControl to your spec_helper.rb:

# spec_helper.rb
...

require "n_plus_one_control/rspec"

Then:

# Wrap example into a context with :n_plus_one tag
context "N+1", :n_plus_one do
  # Define `populate` callbacks which is responsible for data
  # generation (and whatever else).
  #
  # It accepts one argument – the scale factor (read below)
  populate { |n| create_list(:post, n) }

  specify do
    expect { get :index }.to perform_constant_number_of_queries
  end
end

NOTE: do not use memoized values within the expectation block!

# BAD – won't work!

subject { get :index }

specify do
  expect { subject }.to perform_constant_number_of_queries
end

Availables modifiers:

# You can specify the RegExp to filter queries.
# By default, it only considers SELECT queries.
expect { ... }.to perform_constant_number_of_queries.matching(/INSERT/)

# You can also provide custom scale factors
expect { ... }.to perform_constant_number_of_queries.with_scale_factors(10, 100)

Minitest

First, add NPlusOneControl to your test_helper.rb:

# test_helper.rb
...

require "n_plus_one_control/minitest"

Then use assert_perform_constant_number_of_queries assertion method:

def test_no_n_plus_one_error
  populate = ->(n) { create_list(:post, n) }

  assert_perform_constant_number_of_queries(populate: populate) do
    get :index
  end
end

You can also specify custom scale factors or filter patterns:

assert_perform_constant_number_of_queries(
  populate: populate,
  scale_factors: [2, 5, 10]
) do
  get :index
end

assert_perform_constant_number_of_queries(
  populate: populate,
  matching: /INSERT/
) do
  do_some_havey_stuff
end

You can also specify populate as a test class instance method:

def populate(n)
  create_list(:post, n)
end

def test_no_n_plus_one_error
  assert_perform_constant_number_of_queries do
    get :index
  end
end

With caching

If you use caching you can face the problem when first request performs more DB queries than others. The solution is:

# RSpec

context "N + 1", :n_plus_one do
  populate { |n| create_list :post, n }
  
  warmup { get :index } # cache something must be cached
  
  specify do
    expect { get :index }.to perform_constant_number_of_queries
  end
end

# Minitest

def populate(n)
  create_list(:post, n)
end

def warmup
  get :index
end

def test_no_n_plus_one_error
  assert_perform_constant_number_of_queries do
    get :index
  end
end

# or with params

def test_no_n_plus_one
  populate = ->(n) { create_list(:post, n) }
  warmup = -> { get :index }
  
  assert_perform_constant_number_of_queries population: populate, warmup: warmup do
    get :index
  end
end

If your warmup and testing procs are identical, you can use:

expext { get :index }.to perform_constant_number_of_queries.with_warming_up # RSpec only

Configuration

There are some global configuration parameters (and their corresponding defaults):

# Default scale factors to use.
# We use the smallest possible but representative scale factors by default.
NPlusOneControl.default_scale_factors = [2, 3]

# Print performed queries if true in the case of failure
# You can activate verbosity through env variable NPLUSONE_VERBOSE=1
NPlusOneControl.verbose = false

# Ignore matching queries
NPlusOneControl.ignore = /^(BEGIN|COMMIT|SAVEPOINT|RELEASE)/

# ActiveSupport notifications event to track queries.
# We track ActiveRecord event by default,
# but can also track rom-rb events ('sql.rom') as well.
NPlusOneControl.event = 'sql.active_record'

# configure transactional behavour for populate method
# in case of use multiple database connections
NPlusOneControl::Executor.tap do |executor|
  connections = ActiveRecord::Base.connection_handler.connection_pool_list.map(&:connection)

  executor.transaction_begin = -> do
    connections.each { |connection| connection.begin_transaction(joinable: false) }
  end
  executor.transaction_rollback = -> do
    connections.each(&:rollback_transaction)
  end
end

How does it work?

Take a look at our Executor to figure out what's under the hood.

What's next?

It may be useful to provide more matchers/assertions, for example:

# Actually, that means that it is N+1))
assert_linear_number_of_queries { ... }

# But we can tune it with `coef` and handle such cases as selecting in batches
assert_linear_number_of_queries(coef: 0.1) do
  Post.find_in_batches { ... }
end

# probably, also make sense to add another curve types
assert_logarithmic_number_of_queries { ... }

If you want to discuss or implement any of these, feel free to open an issue or propose a pull request.

Development

# install deps
bundle install

# run tests
bundle exec rake

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/palkan/n_plus_one_control.

License

The gem is available as open source under the terms of the MIT License.

About

RSpec and Minitest matchers to prevent N+1 queries problem

License:MIT License


Languages

Language:Ruby 99.2%Language:Shell 0.8%