Two ways for testing preloading/eager-loading of ActiveRecord associations in Rails
… and check why 5600+ Rails engineers read also this
Two ways for testing preloading/eager-loading of ActiveRecord associations in Rails
As a developer who cares about performance you know to avoid N+1 queries by using #includes
, #preload
or #eager_load
methods . But is there a way of checking out that you are doing your job correctly and making sure that the associations you expect to be preloaded are indeed? How can you test it? There are two ways.
Imagine we have two of these classes in our Rails application. An order
can have many order_lines
.
class Order < ActiveRecord::Base
has_many :order_lines
def self.last_ten
limit(10).preload(:order_lines)
end
end
class OrderLine < ActiveRecord::Base
belongs_to :order
end
We implemented Order.last_ten
method which returns last 10 orders with one eager loaded association. Let’s see how can make sure that the lines are preloaded after calling it.
association(:name).loaded?
require 'test_helper'
class OrderTest < ActiveSupport::TestCase
test "#last_ten eager loading" do
o = Order.new()
o.order_lines.build
o.order_lines.build
o.save!
orders = Order.last_ten
assert orders[0].association(:order_lines).loaded?
end
end
Because we preload(:order_lines)
we are interested whether order_lines
is loaded. To check that we need to get one Order
object such as orders[0]
verify on it. There is nothing to check on orders
collection that could tell us if the association is loaded or not.
The test in Rspec would look quite similar
require 'rails_helper'
RSpec.describe Order, type: :model do
specify "#last_ten eager loading" do
o = Order.new()
o.order_lines.build
o.order_lines.build
o.save!
orders = Order.last_ten
expect(orders[0].association(:order_lines).loaded?).to eq(true)
# or alternatively
expect(orders[0].association(:order_lines)).to be_loaded
end
end
count queries with ActiveSupport::Notifications
ActiveRecord library has a nice helper method called assert_queries
which is part of ActiveRecord::TestCase
. Unfortunately, ActiveRecord::TestCase
is not shipped as part of ActiveRecord. It is only available in rails internal tests to verify its behavior. We can however quite easily emulate it for our needs.
Imagine a scenario in which you operate on a graph of Active Record objects but you don’t return them. You just return a computed values. How can your verify it in such case that you don’t have the N+1 problem? There are no observable side-effects, no returned records to check if they are loaded?
. But… aren’t they really?
class Order < ActiveRecord::Base
has_many :order_lines
def self.average_line_gross_price_today
lines = where("created_at > ?", Time.current.beginning_of_day).
preload(:order_lines).
flat_map do |order|
order.order_lines.map(&:gross_price)
end
lines.sum / lines.size
end
end
class OrderLine < ActiveRecord::Base
belongs_to :order
def gross_price
# ...
end
end
In this situation. How can you test that Order.average_line_gross_price_today
does not suffer from N+1 queries? Is there a way to make sure order.order_lines.map(&:gross_price)
is not triggering a SQL query when reading order_lines
? It turns out there is.
We can use ActiveSupport::Notifications
and get notified about every executed SQL statement.
require 'rails_helper'
RSpec.describe Order, type: :model do
specify "#average_line_gross_price_today eager loading" do
o = Order.new()
o.order_lines.build
o.order_lines.build
o.save!
count = count_queries{ Order.average_line_gross_price_today }
expect(count).to eq(2)
end
private
def count_queries &block
count = 0
counter_f = ->(name, started, finished, unique_id, payload) {
unless %w[ CACHE SCHEMA ].include?(payload[:name])
count += 1
end
}
ActiveSupport::Notifications.subscribed(
counter_f,
"sql.active_record",
&block
)
count
end
end
If you go that way make sure to create enough records to detect potential issues with eager loading. One order with one line is not enough because with and without the eager loading the number of queries would be the same. In this case only when you have 2 order lines you can see the difference in a number of queries with preloading (2, one for all orders and one for all lines) vs without preloading (3, one for all orders and one for every line separately). Always make sure your test is failing before fixing it :)
While using this approach is possible, it tells me that it could be nice to split the responsibilities into two smaller methods. One responsible for extracting the right records from a database (IO-related) and one for transforming the data and doing the computations (no IO, side-effect free).
You can check out db-query-matchers gem for RSpec matchers to help you with that kind of testing.
Would you like to continue learning more?
If you enjoyed the article, subscribe to our newsletter so that you are always the first one to get the knowledge that you might find useful in your everyday Rails programmer job. Content is mostly focused on (but not limited to) Ruby, Rails, Web-development and refactoring.