Unit Testing ActiveRecord eager-loading

January 27, 2016

If you’ve worked with relational databases and any ORMs like Java’s Hibernate, .NET’s NHibernate or Rails’ ActiveRecord in the past, you might be familiar with SELECT N+1 issues. It is a common performance problem in database-dependent applications and, because of this, these ORMs provide a built-in solution to this problem.

In ActiveRecord, includes, preload and eager_load come to the rescue. Therefore, it is not unusual to find these keywords scattered in different places where your application accesses the database. Hopefully this isn’t a lot of places though - you are using Query Objects, right?

An example application

Let’s imagine for a second that we have an application where you can browse restaurants, which in turn have many reviews, each of which belongs to an author:

class Restaurant < ActiveRecord::Base
  has_many :reviews
end

class Review < ActiveRecord::Base
  belongs_to :restaurant
  belongs_to :author
end

class Author < ActiveRecord::Base
  has_many :reviews
end

Now, let’s say that we need to return a list of restaurants with all of its reviews, including the review’s rating, description and author’s name. You could achieve this with:

# I know, I know, I'm not using query objects here...
class RestaurantsController < ApplicationController
  def index
    @restaurants = Restaurant
      .includes(reviews: :author)
      .where(restaurant_id: params[:restaurant_id])
  end
end

Now, when we use those @restaurants, an iteration like the one below would cause a SELECT N+1 issue if we didn’t have eager-loading:

@restaurants.map do |restaurant|
  {
    'name' => restaurant.name,
    'reviews' => restaurant.reviews.map do |review|
      {
        'rating' => review.rating,
        'author_name' => review.author.name
      }
    end
  }
end

Ok, so that’s enough background. Let’s get into the meat of it: How do you make sure that the correct associations are getting eager-loaded in your unit tests?

A first approach: using includes_values, preload_values and eager_load_values

Testing the eager-loaded values of the resulting Restaurant collection (which would be an ActiveRecord::Relation) in the RestaurantsController above can be achieved with the #includes_values, #preload_values and #eager_load_values methods. It would go something like this:

require 'rails_helper'

describe RestaurantsController, type: :controller do
  describe '#index' do
    it 'eager loads' do
      get :index

      restaurants = assigns(:restaurants)
      expect(restaurants.includes_values).to eq(reviews: :author)
    end
  end
end

Now, by using this method you are only testing that you passed the values that you expected to includes, preload or eager_load, respectively, and a few people out there might tell you that this isn’t a very valuable test.

A better approach: using association

We can get a more useful test by using the #association method on an ActiveRecord::Base instance. The same test would now look like this:

require 'rails_helper'

describe RestaurantsController, type: :controller do
  describe '#index' do
    it 'eager loads' do
      get :index

      restaurants = assigns(:restaurants)

      restaurant = restaurants.first
      expect(restaurant.association(:reviews)).to be_loaded, 'Reviews are not eager loaded'

      review = restaurant.reviews.first
      expect(review.association(:author)).to be_loaded, 'Author are not eager loaded'
    end
  end
end

I tend to prefer this way of testing eager-loading because you get to treat the eager-loading code as a black box, instead of reaching into the internals. Additionally, you get the benefit of having the ability to change the way the records are being eager-loaded (by switching from includes to preload, for example) without having to change the tests.

I’ve been using this approach lately and I’ve been quite happy with it so far. Enjoy testing those active records!


Notes

  • This post’s examples are based on Rails 4.2.5, RSpec 3.4.0 (and rspec-rails).
  • Usually, one of my biggest concerns with SELECT N+1 issues is that you have to be constantly thinking about them, and I tend to be very forgetful with these things. Take a look at the bullet gem and rack-mini-profiler to start monitoring.
  • There used to be a #loaded_#{association}? method, but its usage was discouraged a long time ago.