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.