フィヨルドブートキャンプというプログラミングスクールの課題について、けっこう悩んでいたところがあった。質問したら、メンターの伊藤 (id:JunichiIto)さんに動画で解説していただいた。文章でまとめてみようと思う。
前提
Railsでつくられたアプリケーションがあり、そのテストを作成するという課題があった。アプリは日報の作成ができるものであり、日報本文に他の日報のURLを入れて言及することも可能である。データベースは以下のような構成になっている。
User has_many :reports Report belongs_to :user has_many :active_mentions, class_name: 'ReportMention', foreign_key: :mention_to_id, dependent: :destroy, inverse_of: :mention_to has_many :mentioning_reports, through: :active_mentions, source: :mentioned_by has_many :passive_mentions, class_name: 'ReportMention', foreign_key: :mentioned_by_id, dependent: :destroy, inverse_of: :mentioned_by has_many :mentioned_reports, through: :passive_mentions, source: :mention_to ReportMention belongs_to :mention_to, class_name: 'Report' belongs_to :mentioned_by, class_name: 'Report'
日報同士は中間テーブルを用いてN:Nの関係を持っている。
Reportモデルのメソッドのひとつ、save_mention
をテストすることにした。このメソッドはafter_save
コールバックを用いており、Reportモデルが保存・更新された後に実行される。中身としては、登録される日報の中に他の日報のURLがないかを確認し、存在していればそれらの関連づけを行う、というものだ。
問題
以下のようにテストを書いたがこれが通らない。まず日報1とそれを言及した日報2を作成する。その後、日報2の中で日報1の言及をなくす。そうしてから再度「日報2が日報1を言及していないこと」をテストすると、これが通らないのだ。
まずはコードを載せる。
test/models/report_test.rb
test '#save_mention' do user1 = users(:alice) user2 = users(:bob) report1 = user1.reports.create!(title: '朝', content: 'good morning') report2 = user2.reports.create!(title: '言及', content: "http://localhost:3000/reports/#{report1.id}") # これは通る assert_includes(report2.mentioning_reports, report1) assert_includes(report1.mentioned_reports, report2) assert_not_includes(report2.mentioned_reports, report1) update_content = 'good bye' report2.update(title: '言及の修正', content: update_content) # これは通らない assert_not_includes(report2.mentioning_reports, report1)
app/models/report.rb
class Report < ApplicationRecord # 略 after_save :save_mentions # 略 private MENTION_REGEXP = %r{http://localhost:3000/reports/(\d+)} def save_mentions active_mentions.destroy_all ids = content.to_s.scan(MENTION_REGEXP).flatten.uniq reports = Report.where(id: ids).where.not(id:) self.mentioning_reports += reports end end
test/fixtures/users.yml
alice: email: alice@example.com encrypted_password: <%= Devise::Encryptor.digest(User, 'password') %> bob: email: bob@example.com
調査
その後、いろいろと調査を行った。まず、テストではなくアプリを実行してデータの流れを追っていった。デバックを挟みながら確認するも、アプリを実行しているときは問題なかった。日報2を更新して日報1の言及をなくすと、データ上でも関連付けは消去されていた。テストでインスタンス変数(@
)を 使っていないからか、と思ったがそこは関係なかった。
アサーションを変更すると、テストは通るようになった。
# 略 report2.update(title: '言及の修正', content: update_content) # これは通る assert_not_includes(report1.mentioned_reports, report2) # これも通る assert_not report2.mentioning_reports.find_by(id: report1.id)
find_by
を使っているほうはなんとなくテストが通る理由がわかる。アサーションの段階でもう一度データベースにアクセスしているからだ。ただ、mentioned_reports
を使うとなぜ通るのかわからない。
原因と解決策
フィヨルドブートキャンプのQ&Aに投稿して、伊藤さんから回答をいただいた。動画つきでありがたかった。どうやらキャッシュが原因のようだった。以下のようにreload
を付け加えるとテストは通る。
assert_not_includes(report2.reload.mentioning_reports, report1)
もうちょっと詳しく
RailsではActiveRecordで関連づけを行う際に、キャッシュを用いている。
Active Record の関連付け - Railsガイド
関連付けのメソッドは、すべてキャッシュを中心に構築されています。最後に実行したクエリの結果はキャッシュに保持され、次回以降の操作で利用できます。このキャッシュは、以下のようにメソッド間でも共有される点にご注意ください。
save_mention
メソッドの中で self.mentioning_reports
という箇所がある。このコードによって、データベースにアクセスしようとしている。このメソッドはafter_save
コールバックで呼び出されているので、単純な日報の作成・更新のたびに実行される。つまりは日報1と日報2の作成時にすでに self.mentioning_reports
は呼ばれており、データベースにアクセスされていたのだ。
その後のアサーション内の記述 assert_not_includes(report2.mentioning_reports, report1)
では以前呼び出されたキャッシュが使われている。そのため、日報2の中に言及の関連付けは残ったままだったのだ。
実際にそれを確認する方法がある。 loaded?
メソッドを使う。関連付けがすでに読み込まれているかを出力させる。テストに組み込んでみる。
test '#save_mention' do user1 = users(:alice) user2 = users(:bob) report1 = user1.reports.create!(title: '朝', content: 'good morning') report2 = user2.reports.create!(title: '言及', content: "http://localhost:3000/reports/#{report1.id}") assert report2.mentioning_reports.loaded? # 略
これは通る。つまりは日報2を作成した段階ですでにデータベースへのアクセスは行われており、これ以降はこの値のキャッシュを使っていたに過ぎないのだ。
終わり
年末までずっとコードと向き合っていた。来年もこうでありたい。