Rspec Query Limit Matcher

Jakub Cieślar

rspec-query-limit

Table of Contents

Have you ever wondered how many simultaneous queries your application sends to the database? A hundred, a thousand, or perhaps a million? Whatever your answer, we should be aware of the influence that this number has on the performance of our programme. I would like to introduce to you the rspec-query-limit gem (you can find the source code on our github in the monterail/rspec-query-limit repository): this gem is a rspec matcher that measures the numbers of queries being sent to the database in Ruby on Rails applications. The main goal of this gem is to prevent the so-called N+1 problem from occurring, and to make us realize how many different queries are subsequently being sent to the database. Rspec query limit measures queries using Active Support Instrumentation which allows us to measure certain actions in Rails.

You can use this gem wherever you wish in your tests - you can measure the number of queries in your controllers, services, use cases or models, or anywhere in your applications where you communicate with the database. I would like to show you an example use case that will hopefully be to your liking.

Example of usage

Let’s assume that we have a very simple blog application with a DB structure that looks like this:

class Author < ActiveRecord::Base
  has_many :posts
end

class Post < ActiveRecord::Base
  belongs_to :author
  has_many :comments
end

class Comment < ActiveRecord::Base
  belongs_to :post
end

Taking this example into consideration, let’s create a PostService where we implement the last_posts method which will return an array of last posts with additional information like: number of comments, number of author posts, current post title and author email address. This method will be implemented incorrectly (with an N+1 problem) and because of that we’ll name it last_posts_wrong. Let’s see what the implementation looks like:

class PostService

  def last_posts_wrong(limit)
    Post.where(active: true).last(limit).map do |post|
      {
        post_name: post.title,
        comments_count: post.comments.count,
        last_comment: post.comments.last.content,
        author_email: post.author.email,
        author_articles: post.author.posts.count,
      }
    end
  end

end

A correct implementation of the method last_post in PostService should send 4 queries to the DB:

  • Select proper set of posts
  • Select post comments
  • Select post author information
  • Select number of author posts

Let's check it! To do this we can use the matcher query_limit_eq(number), which is provided by the rspec-query-limit gem to measure the number of queries. We will create two test examples with different post limits:

require "spec_helper"

describe PostService do
  let(:number_of_comments) { 30 }
  let!(:comments) { create_list(:comment, number_of_comments) }

  context 'method with N+1 problem' do
    it 'should have 4 queries' do
      expect { PostService.new.last_posts_wrong(10) }.to query_limit_eq(4)
    end

    it 'should have 4 queries' do
      expect { PostService.new.last_posts_wrong(20) }.to query_limit_eq(4)
    end
  end
end

And then we can run tests and the results are:

Failures:

  1) PostService method with N+1 problem should has 4 queries
     Failure/Error: expect { PostService.new.last_posts_wrong(10) }.to query_limit_eq(4)
       Expected to run exactly 4 queries, got 41
     # ./spec/services/post_service_spec.rb:9:in `block (3 levels) in <top (required)>'
     # ./spec/support/database_cleaner.rb:10:in `block (3 levels) in <top (required)>'
     # ./spec/support/database_cleaner.rb:9:in `block (2 levels) in <top (required)>'

  2) PostService method with N+1 problem should has 4 queries
     Failure/Error: expect { PostService.new.last_posts_wrong(20) }.to query_limit_eq(4)
       Expected to run exactly 4 queries, got 81
     # ./spec/services/post_service_spec.rb:13:in `block (3 levels) in <top (required)>'
     # ./spec/support/database_cleaner.rb:10:in `block (3 levels) in <top (required)>'
     # ./spec/support/database_cleaner.rb:9:in `block (2 levels) in <top (required)>'

Both tests fail, just as we expected, and we can see that the received values depend on the post limit number. Behold the N+1 problem! Let’s try to fix it by writing a new method in PostService and naming it last_posts_correct. To achieve our goal, we will include all related associations, and we will change count to size (count sends a new query to the DB). The final results of our refactoring looks like this:

class PostService

...

  def last_posts_correct(limit)
    Post.where(active: true).includes(:comments, author: :posts).last(limit).map do |post|
      {
        post_name: post.title,
        comments_count: post.comments.size,
        last_comment: post.comments.last.content,
        author_email: post.author.email,
        author_articles: post.author.posts.size,
      }
    end
  end

end

Let’s write another set of specs to test our new approach. To make sure that the N+1 problem doesn’t appear, we’ll prepare specs with a different post limit:


  context 'correct method with 30 comments' do
    it 'should have 4 queries' do
      expect { PostService.new.last_posts_correct(10) }.to query_limit_eq(4)
    end

    it 'should have 4 queries' do
      expect { PostService.new.last_posts_correct(20) }.to query_limit_eq(4)
    end
  end

  context 'correct method with 50 comments' do
    let(:number_of_comments) { 50 }
    it 'should have 4 queries' do
      expect { PostService.new.last_posts_correct(10) }.to query_limit_eq(4)
    end

    it 'should have 4 queries' do
      expect { PostService.new.last_posts_correct(20) }.to query_limit_eq(4)
    end
  end

The results of all the specs:

Finished in 3.89 seconds (files took 2.64 seconds to load)
6 examples, 2 failures

Failed examples:

rspec ./spec/services/post_service_spec.rb:8 # PostService method with N+1 problem should has 4 queries
rspec ./spec/services/post_service_spec.rb:12 # PostService method with N+1 problem should has 4 queries

We can see that our new implementation doesn’t depend on the number of records and, as a result, we can always send 4 queries to the DB. In other words, N+1 problem solved!

Summary

I hope that now you can clearly see the importance of having the number of queries under control. When details are changed or improvements are made to your codebase, you can be sure that they won’t cause any N+1 problem and your application performance will still be good and sound. You can avoid many difficulties down the road simply by paying attention to the number of queries being made and by adding proper tests that measure their impact. This gem is not only about the N+1 problem but it’s also about being aware of how many queries we send to the DB in our applications. In my opinion, prevention is better than a cure, therefore I would like to encourage you to write dedicated tests to keep the number of queries no higher than necessary. Hopefully, this gem helps you achieve this goal.

Jakub Cieślar avatar
Jakub Cieślar