マトリョーシカ的日常

ワクワクばらまく明日のブログ。

Railsの通らないテストとキャッシュ制御

 フィヨルドブートキャンプというプログラミングスクールの課題について、けっこう悩んでいたところがあった。質問したら、メンターの伊藤 (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? メソッドを使う。関連付けがすでに読み込まれているかを出力させる。テストに組み込んでみる。

rails/activerecord/lib/active_record/associations/collection_proxy.rb at 984c3ef2775781d47efa9f541ce570daa2434a80 · rails/rails · GitHub

  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を作成した段階ですでにデータベースへのアクセスは行われており、これ以降はこの値のキャッシュを使っていたに過ぎないのだ。

終わり

 年末までずっとコードと向き合っていた。来年もこうでありたい。

UnsplashMarek Piwnicki