概要
原著者の許諾を得て翻訳・公開いたします。
- 英語記事: Two ways for testing preloading/eager-loading of ActiveRecord associations in Rails
- 原文公開日:
- 著者: Robert Pankowecki
- サイト: Arkency -- RailsやReact.jsの開発・教育・書籍を手がけています。
Rails: ActiveRecord関連付けのpreload/eager-loadをテストする2つの方法(翻訳)
パフォーマンスに気を配っている開発者なら、#includes
、#preload
、#eager_load
などの読み込みメソッドでN+1クエリを回避する方法をご存知でしょう。しかし自分の仕事が正しかったかどうか、期待する関連付けが本当にpreloadされているかどうかをチェックする方法はあるのでしょうか。そもそもどうやってテストすればよいのでしょうか。方法は2つあります。
Railsアプリに次の2つのクラスがあるとします。1つのorder
は複数のorder line(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
ここでOrder.last_ten
というメソッドを実装しました。これはeager-loadingする関連付けを1つ使って、最新のorderを10件返します。このコードを呼び出した後でちゃんとpreloadされるかどうかを確認してみましょう。
1. 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
preload(:order_lines)
を行ったので、order_lines
が読み込まれているのかどうかを知りたいと思います。orders[0]
などのOrder
オブジェクトを1つ取得する必要があることをチェックするには、オブジェクトの照合を行います。orders
コレクションをチェックしても関連付けが読み込まれているかどうかはわからないため、コレクションのチェックは不要です。
RSpecでのテストは以下のような感じになります。
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)
# 次でもよい
expect(orders[0].association(:order_lines)).to be_loaded
end
end
2. ActiveSupport::Notifications
でクエリをカウントする
ActiveRecordライブラリにはassert_queries
という便利なヘルパーメソッドがあり、ActiveRecord::TestCase
に含まれているのですが、惜しいことに、ActiveRecord::TestCase
はActiveRecordに含まれていません。これはRailsの内部テストで振舞いをチェックする目的にのみ利用できます。しかし今回の目的に合わせてassert_queries
をエミュレートするのは意外に簡単です。
いくつかのActiveRecordオブジェクトのグラフを操作するが、オブジェクトを返さずに計算値だけを返すという状況を考えてみましょう。このときにN+1問題が発生していないことをどうやって確認すればよいでしょうか。副作用は見当たらず、loaded?
かどうかをチェックできるレコードも返されません。何か方法はないものでしょうか。
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
上の状況で、Order.average_line_gross_price_today
がN+1クエリ問題を抱えていないかどうかをどのように確認すればよいでしょうか。order_lines?
を読み取るときにorder.order_lines.map(&:gross_price)
がSQLクエリをトリガしないことをどのように確認すればよいでしょうか(実はN+1問題が起きています)。
ActiveSupport::Notifications
を使えば、SQL文が実行されるたびに通知を受け取ることができます。
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
上のようにする場合、eager loadingの問題を検出するのに十分な数のレコードを作成しておいてください。order 1件とline 1件だけでは、eager loadingが発生するかどうかにかかわらずクエリの数が同じになってしまうので不十分です。今回はorderのlineが2の場合にのみ、preloadingでのクエリ数(2件、1つはすべてのorder、もう1つはすべてのline)とpreloadingされない場合のクエリ数(3件、1つはすべてのorder、残りは個別のline)に違いが生じることがわかります。修正する前にはテストが失敗することを必ず確認しましょう :)
この方法はもちろん有効ですが、責務を2つの小さなメソッドに分割できたらなおよいでしょう。責務の1つはデータベースから正しいレコードを抽出すること(IOが発生する)、もう1つはデータを変換して計算することです(IOも副作用もなし)。
この種のテストで役に立つRSpecマッチャーとして、db-query-matchers gemをチェックしてみてください。
もっと知りたい方に
本記事を気に入っていただけた方は、日々のRailsプログラミングに役立つ知識をいつも最初に知ることができる弊社のニュースレターをぜひご購読ください。コンテンツはRuby、Rails、Web開発、リファクタリングが中心ですが、その他の話題も扱っています。
大規模で複雑なRailsアプリを手がけている方は、弊社の最新刊『Domain-Driven Rails』もぜひチェックしてみてください。