🐾

Railsのテストで「メソッドスパイ」で行ったら安定化したよという話

に公開

要約

  • 結論: Rails のコントローラスペックでログ出力を検証する場合、receive ではなく メソッドスパイ(have_received) を使うと安定する。
  • 理由: Rack/ActionController などの周辺ログやロガーフォーマッタの差分に影響されにくく、検証対象のログが「実際に呼ばれたこと」だけを確実に確認できるから。

背景

コントローラの副作用としてログ出力を検証したい場面は多い。例えばアカウント削除処理の完了を示すログ:

Rails.logger.info "User account deleted: user_id=#{user.id}"

これをテストで素直に expect(...).to receive(:info).with(...) と書くと、実行タイミングや他ログの影響で不安定になりがち。

実際、以下のような失敗に遭遇することがある:

  • expected: ("User account deleted: user_id=15")
  • got: ("Processing by Api::UsersController#destroy as JSON")

解決策: メソッドスパイに切り替える

「先に期待して待つ」のではなく、「呼び出し後に実際に呼ばれたかを確認する」テクニック。

Before(不安定になりやすい)

expect(Rails.logger).to receive(:info).with("User account deleted: user_id=#{user.id}")

delete :destroy, params: { id: user.id }

After(安定する)

allow(Rails.logger).to receive(:info).and_call_original

delete :destroy, params: { id: user.id }

expect(Rails.logger).to have_received(:info)
  .with(/User account deleted: user_id=\d+/)

ポイント:

  • 事前に allow でスパイ化しておき、対象アクション実行後に have_received で検証。
  • 余計なログや順序の差異があっても壊れにくい。

なぜスパイ方式が周辺ログに強いのか

  • 呼び出し順序への依存を断てる: receive は「このあとこう呼ばれる」ことを前提に時系列を縛るが、have_received は「呼び出し履歴に含まれていたか」を後から検査するため、フレームワークやミドルウェアの先行ログで崩れにくい。
  • フォーマッタやミドルウェアの差分に影響されない: 最終的な出力行はロガーフォーマッタやタグ付けで変形されうるが、logger.info に渡される引数(メッセージ)は同じ。スパイはメソッド呼び出しとその引数を直接検査するため、出力テキストの前後に付与される装飾の影響を受けない。
  • 余計なログ混入に強い: コントローラ実行中に他の info 呼び出しが挟まっても、目的の1回が履歴に含まれていれば満たせる。
  • 厳密さの調整が容易: 必要に応じて .once.at_least(:once)、正規表現や部分一致(include)を併用して、検証の強度を調整できる。

注意点:

  • allow はデフォルトでスタブ化するため、テスト中は実際のログ出力が抑制される。実ログも出したい場合は and_call_original を併用する。
  • スパイ化は対象アクション実行「前」に行う。前段で発生したログは記録されない点に注意。
allow(logger).to receive(:info).and_call_original

subject.call

expect(logger).to have_received(:info)
  .with(/event completed: id=\d+/).once

実務での安定化テクニック

ログ以外にも、テストが揺れやすい箇所を合わせて安定化できる。

  • デフォルト生成/関連削除による件数のブレ

    • ユーザー作成時のコールバックや初期データ投入によって、設定項目やプロフィール情報が自動生成されることがある。
    • ユーザー削除では関連の投稿・コメント・設定の一括削除が発生し、期待する件数差分が固定しづらい。
    • 件数検証は「最低限減っている」「上限以内に収まる」といった範囲ベースに緩めると安定する。
  • ファクトリ/フィクスチャの自動生成によるブレ

    • ファクトリのコールバック(例: after build/create)やトレイト(trait)が、意図せずサンプル投稿やコメントを追加する場合がある。
    • 余計な自動生成を抑えるか、テストごとに最小構成のデータを用意する。件数期待は上限/下限の緩和や部分一致を用いる。

トレイト(trait)について

  • FactoryBot で定義する、再利用可能な属性・コールバックのまとまり。必要に応じて付与でき、複数を組み合わせ可能。
FactoryBot.define do
  factory :user do
    trait :with_posts do
      after(:create) { |user| create_list(:post, 3, author: user) }
    end
  end
end

create(:user, :with_posts)  # 投稿3件付きユーザーを作成
  • 削除確認は存在ベースで行う
    • カウント差分だけに依存せず、対象IDのレコードが取得できないことを直接確認する方が意図が明確で壊れにくい。

サンプル

# ログ検証(スパイ方式)
allow(Rails.logger).to receive(:info).and_call_original

delete :destroy, params: { id: user.id }

expect(Rails.logger).to have_received(:info)
  .with(/User account deleted: user_id=\d+/)
# ユーザー削除の検証(存在確認)
delete :destroy, params: { id: user.id }

expect(response).to have_http_status(:ok)
expect(User.find_by(id: user.id)).to be_nil
# 関連削除の検証(存在確認に寄せる)
delete :destroy, params: { id: user.id }

expect(User.exists?(id: user.id)).to be_falsey
expect(Post.exists?(author: user)).to be_falsey
expect(Comment.exists?(author: user)).to be_falsey
expect(UserProfile.find_by(user: user)).to be_nil
expect(UserSetting.where(user: user)).to be_empty

ガイドライン

  • ログ検証はスパイ(have_received)で行う
    • 周辺ログや順序のノイズに強い。
  • 件数検証はモデルの自動生成/副作用を考慮
    • デフォルト生成(例: 複数件の初期データ)、ファクトリの after(:build) などがある場合、固定期待は壊れやすい。
  • 存在確認は find_by(...).nil? を利用
    • count 差分だけに依存しないことで、テストの意図が明確で安定する。

まとめ

  • ログ検証は メソッドスパイ に寄せると安定化する。
  • 副作用(自動生成・関連削除)を踏まえて、件数期待は必要十分に緩める。
  • 具体策の積み合わせで、コントローラスペックは大幅に壊れにくくなる。

Discussion