新卒エンジニア体験:RSpecで遭遇した罠と対策

目次

はじめに

こんにちは、2025年4月新卒入社の劉です。ドワンゴ教育事業部サービス開発部のバックエンドセクションに所属し、普段からRailsのプロジェクトに携わっています。

最近ではサービス移行(APIのバージョンアップ)の業務に関与する機会が増えました。そこでは、RSpecを用いた既存のテストケースのリファクタリングや新規テストの作成を数多く行うことになりました。エンジニアとしてRSpecと向き合う中で、RuboCopのワーニングの意味が分からなかったり、単体実行では通るのに全体実行では落ちるといった問題に直面することがありました。

本記事では、私が実際に遭遇した問題とその対策を中心に、RSpecテストコードを書く際のプラクティスをまとめています。同じようにテストコードで悩んでいる方や、将来の自分自身が忘れた頃に読み返すための備忘録として、少しでもお役に立てれば幸いです。

1. RuboCopのワーニングと対策

1.1 定義数が多すぎる問題(RSpec/MultipleMemoizedHelpers)

私がメインに扱っているサービスは、複数のミドルウェアサービスからのレスポンスを統合し、フロントエンドに返す API Gateway です。そのため、テストコードでは複数のAPIからのレスポンスをモックする必要があり、 letlet! の定義が増えがちです。

RSpec/MultipleMemoizedHelpers は、一定数以上の let / let! を定義するとワーニングを出します(デフォルトでは5つ以上)。特に、結合テストを書く際に、このワーニングに遭遇することが多くなります。

以下は、コース進捗を更新するAPIのテストコードです(N高グループの生徒向けの必修授業コースを想定したサンプルコードです)。

Bad: 多数の let! が並んでいる

describe "POST /course/:course_id/progress" do
  context "N高グループ生の必修授業(type_n_school)の場合" do
    # ---- ここから let! が8つ(デフォルト上限の5つを超えている) ----
    let!(:user) { create(:user) }
    let!(:course_id) { 1 }
    let!(:v3_get_course_progress_response) do  # コース進捗APIのモックレスポンス
      create(
        :v3_get_course_progress_response,
        course_id:,
        chapter_ids: [v3_chapter.chapter_id],
        short_test_ids: [v3_short_test.short_test_id],
      )
    end
    let!(:v3_chapter) { create(:v3_chapter) }       # 進捗に紐付くチャプター
    let!(:v3_short_test) { create(:v3_short_test) } # 進捗に紐付く小テスト
    let!(:v3_course) { create(:v3_course, :type_n_school, course_id:) }
    let!(:v3_get_course_response) do               # コース情報APIのモックレスポンス
      create(
        :v3_get_course_response,
        :n_school_course,
        course: v3_course,
        children: [
          build(:v3_get_course_response_child, :type_advanced_course_chapter, chapter: v3_chapter),
        ],
      )
    end
    let!(:answer_params) { { content: "answer text", user_id: user.id } }
    # ---- ここまで ----

    before do
      allow(user_api_mock).to receive(:get_course_progress)
        .with(user.id, course_id)
        .and_return(v3_get_course_progress_response)
      allow(contents_api_mock).to receive(:get_course)
        .with(course_id)
        .and_return(v3_get_course_response)
    end

    it "returns correct result" do
      post "/course/#{course_id}/progress", params: answer_params
      expect(response).to have_http_status(:ok)
    end
  end
end

この例では、 let! が多すぎてテストの見通しが悪くなっています。以下、具体的な対策を紹介します。

対策1: 共通セットアップを切り出す、shared_context を活用する

複数のテストで共通する準備処理を shared_context に切り出すことで、各テストファイルの let! の数を減らせます。以下の例では、context に応じて v3_coursev3_get_course_response は変わりますが、それ以外の共通するコース進捗部分を course progress setup に切り出しました。

# context によって変わらない「コース進捗」関連の定義のみをまとめる
shared_context "course progress setup" do
  let!(:course_id) { 1 }
  let!(:v3_get_course_progress_response) do
    create(
      :v3_get_course_progress_response,
      course_id:,
      chapter_ids: [v3_chapter.chapter_id],
      short_test_ids: [v3_short_test.short_test_id],
    )
  end
  let!(:v3_chapter) { create(:v3_chapter) }
  let!(:v3_short_test) { create(:v3_short_test) }

  before do
    allow(user_api_mock).to receive(:get_course_progress)
      .with(user.id, course_id)
      .and_return(v3_get_course_progress_response)
  end
end

describe "POST /course/:course_id/progress" do
  context "N高グループ生の必修授業(type_n_school)の場合" do
    include_context "course progress setup"  # 共通部分を読み込む

    # context 固有の定義のみをここで行う(let! は4つに削減)
    let!(:user) { create(:user) }
    let!(:v3_course) { create(:v3_course, :type_n_school, course_id:) }
    let!(:v3_get_course_response) do
      create(
        :v3_get_course_response,
        :n_school_course,
        course: v3_course,
        children: [
          build(:v3_get_course_response_child, :type_advanced_course_chapter, chapter: v3_chapter),
        ],
      )
    end
    let!(:answer_params) { { content: "answer text", user_id: user.id } }

    before do
      allow(contents_api_mock).to receive(:get_course)
        .with(course_id)
        .and_return(v3_get_course_response)
    end

    it "returns correct result" do
      post "/course/#{course_id}/progress", params: answer_params
      expect(response).to have_http_status(:ok)
    end
  end
end

対策2: before ブロックと変数を使う

it ブロックで参照しない変数を before ブロック内で定義することで、let! の数を減らせます。

注意: before ブロック内で定義した変数は、そのブロック内でのみ有効です。テストケース( it ブロック)で参照する必要がある場合は、 let! を使います。

describe "POST /course/:course_id/progress" do
  context "N高グループ生の必修授業(type_n_school)の場合" do
    # it ブロックで参照するので let! で定義する
    let!(:user) { create(:user) }
    let!(:course_id) { 1 }
    let!(:answer_params) { { content: "answer text", user_id: user.id } }

    before do
      # it ブロックで直接参照しない変数はローカル変数として定義する
      v3_chapter = create(:v3_chapter)
      v3_short_test = create(:v3_short_test)
      v3_get_course_progress_response = create(
        :v3_get_course_progress_response,
        course_id:,
        chapter_ids: [v3_chapter.chapter_id],
        short_test_ids: [v3_short_test.short_test_id],
      )
      v3_course = create(:v3_course, :type_n_school, course_id:)
      v3_get_course_response = create(
        :v3_get_course_response,
        :n_school_course,
        course: v3_course,
        children: [
          build(:v3_get_course_response_child, :type_advanced_course_chapter, chapter: v3_chapter),
        ],
      )
      allow(user_api_mock).to receive(:get_course_progress)
        .with(user.id, course_id)
        .and_return(v3_get_course_progress_response)
      allow(contents_api_mock).to receive(:get_course)
        .with(course_id)
        .and_return(v3_get_course_response)
    end

    it "returns correct result" do
      post "/course/#{course_id}/progress", params: answer_params
      expect(response).to have_http_status(:ok)
    end
  end
end

対策3: 関連するパラメータをハッシュにまとめる

リクエストパラメータなど、関連するテストデータを1つのハッシュにまとめることで、 let! の数を減らせます。

describe Material::Contents::V1 do
  describe "POST /v1/material/exercises/:id/answers" do
    let!(:user)     { create(:user) }
    let!(:exercise) { create(:exercise) }
    let!(:answer_params) do
      {
        content:   "answer text",
        user_id:   user.id,
        locale:    "ja",
        client_id: "web"
      }
    end

    it "returns correct result" do
      post "/v1/material/exercises/#{exercise.id}/answers", params: answer_params
      expect(response).to have_http_status(:ok)
    end
  end
end

対策4: FactoryBot の trait を活用する

同じモデルを「状態違い」で複数のテストに使い回す場合、let! を使ってテストごとに個別のファクトリを定義すると、似た定義が散在してしまいます。FactoryBot の trait を使うと、デフォルト属性はファクトリ側に集約しつつ、状態の差分だけを trait として切り出せるため、let! の数を減らせます。

実例: コースファクトリでの trait 活用

FactoryBot.define do
  factory :v3_course do
    # デフォルト: 一般向け advanced コースの属性
    sequence(:id)
    title { "test title" }
    outline { "test outline" }
    sequence(:subject_category_id)
    released { true }
    updated_at { "2021-08-30 10:44:21" }
    tags { nil }
    course_type { "advanced" }
    permissions { { "view" => ["free_member"], "partial_select" => ["free_member"], "select" => ["free_member"] } }
    released_at { true }

    # trait: N高グループ生向け必修授業コースとの差分のみを定義
    trait :n_school do
      tags { Array.new(rand(4)) { Faker::Lorem.characters(number: rand(1..3)) } }
      course_type { "n_school" }
      permissions { { "view" => ["basic_n_student"], "partial_select" => ["basic_n_student"], "select" => ["basic_n_student"] } }
      subject_category { association(:subject_category, :without_courses) }
    end
  end
end

この定義により、テストコードでは以下のように簡潔に使い分けることができます。

# course_typeがadvancedのテスト(デフォルト)
let!(:v3_course_advanced) { create(:v3_course) }

# course_typeがn_schoolのテスト
let!(:v3_course_n_school) { create(:v3_course, :n_school) }

対策の使い分け

状況 推奨する対策
複数の context やテストファイルで同じセットアップが必要 対策1(shared_context)
it ブロックで参照しない初期化処理が多い 対策2(before ブロック)
リクエストパラメータなど、意味的にまとまるデータがある 対策3(ハッシュ)
同じモデルを異なる状態で使い回す箇所が多い 対策4(trait)

実際には、これらを組み合わせて使うことが多いです。まず対策4で Factory 側を整理し、それでも let! が多い場合は対策1〜3を検討するのが自然な流れです。

1.2 expect(receive) と have_received の違い(RSpec/MessageSpies)

RSpec/MessageSpies は、expect(object).to receive(:method) のように「これからこのメソッドが呼ばれるはずだ」という事前設定スタイルにワーニングを出します。代わりに allow + have_received を使った Message Spy パターンが推奨されます。

receive と have_received の根本的な違い

receivehave_received は、検証のタイミングが異なります。

  • expect(obj).to receive(:method)実行前に設定する「事前期待」。この行より後でメソッドが呼ばれることを期待する
  • expect(obj).to have_received(:method)実行後に確認する「事後検証」。この行より前にメソッドが呼ばれたことを確認する

そのため、receivehave_received に置き換えるだけでは不十分で、コードの実行順も合わせて変える必要があります。また、expect(receive) スタイルはテストが失敗したとき、「期待が間違っているのか、実装が間違っているのか」が分かりにくいという問題もあります。

Bad: 事前期待スタイル、落ちないがRuboCopのワーニングが出る

it "logs the message" do
  expect(logger).to receive(:info).with('test message')  # ① 事前期待
  described_class.new.log                                       # ② 実行
end

Good: allow でスタブを設定し、実行後に have_received で検証する

it "logs the message" do
  allow(logger).to receive(:info)                               # ① スタブ設定
  described_class.new.log                                       # ② 実行
  expect(logger).to have_received(:info).with("test message")  # ③ 事後検証
end

「準備 → 実行 → 検証」という順序が、テストの意図を明確に伝えます。

double / instance_double を使う場合の注意

doubleinstance_double を使っている場合、allow でスタブを設定していないと、予期しないメッセージを受け取った時点でエラーになります。instance_spy を使えば全メソッドが自動的にスタブ化されるため、allow を省略できます。ただし、instance_spy はすべてのメソッド呼び出しを暗黙的に許可するため、意図しないメソッドが呼ばれていても気づきにくく、基本的に使わないです。

2. RuboCopのワーニングは出ないがエラーになる場合

2.1 FactoryBot 利用時の注意点

2.1.1 デフォルト値への依存による「意図せぬ関連」の発生

データ構造が複雑になるにつれ、Factory のデフォルト定義に依存しすぎると、テストの前提条件と異なるデータが作られ、不整合や予期せぬエラーの原因となります。

具体例

たとえば、n_school(N高グループ向け必修授業)が紐付いていないコースをテストしたいとします。しかし Factory のデフォルト定義で n_school の association が設定されていると、create(:v3_course) を呼ぶだけで意図せず n_school が作成・紐付けされてしまいます。

対策

  • 関連を明示する: デフォルトに頼らず、テストケースごとに必要な関連(association)を明示的に指定する
  • Trait での分離: デフォルトですべて定義せず、trait を活用して責務や状態ごとに定義を切り出す
  • 必要最小限の構築: Spec 側では、そのテストに必要なデータと関連のみを構築(build / create)するようにする
# Factory のデフォルト定義(問題のある例)
FactoryBot.define do
  factory :v3_course do
    title { "test title" }
    n_school { association(:n_school) }  # デフォルトで必ず n_school が紐付く
  end
end

# Bad: n_school が紐付かないケースをテストしたいのに、
#      Factory のデフォルトで n_school が作られてしまう
it "returns course without n_school" do
  course = create(:v3_course)  # 暗黙的に n_school も作成される
  expect(course.n_school).to be_nil  # => 失敗:n_school が存在してしまう
end
# Good: nil にしたい場合は明示的に上書きする
it "returns course without n_school" do
  course = create(:v3_course, n_school: nil)  # 明示的に nil を指定
  expect(course.n_school).to be_nil  # => 通る
end

2.1.2 単体実行と全体実行での結果の不整合(Flaky Spec)

「単体で実行すると通るが、全体(CI や全テスト)で実行すると落ちる」という現象は、変数の定義不足や、データベースの状態依存(sequence ID の採番など)が原因で発生しがちです。

具体例:ID のインクリメントによる不整合

FactoryBot でデータを生成すると、通常 ID はインクリメント(1, 2, 3...)されていきます。

  • 単体実行時: ID が初期値(例: 1)から始まるため、ID 固定のモックや検証としたロジックが偶然通ってしまう
  • 全体実行時: 他のテストが先に走り ID が進んでいる(例: 105)ため、 ID=1 を期待している箇所で不整合が起き、テストが失敗する

対策

  • 作成時IDを明示する: モックや検証の参照IDをハードコードせず、各 context で ID を定義し、それを参照するようにする
# Bad: ID をハードコードしているため、全体実行時に失敗する可能性がある
it "returns the correct exercise" do
  exercise = create(:exercise)
  expect(response.body).to include('"id":1')  # 他のテストが先に走ると ID が 1 にならない
end

# Good: 作成したオブジェクトの ID を参照する
it "returns the correct exercise" do
  exercise = create(:exercise)
  expect(response.body).to include("\"id\":#{exercise.id}")
end

2.2 shared_context 間の干渉に注意する

shared_context の内容を把握していない状態で安易に使うと、自分のテストの let!shared_context 内の定義で意図せず上書きされ、予想外の挙動を引き起こすことがあります。

対策

  • shared_context の内容は使用目的に合わせる部分のみを定義する
  • shared_context の内容を把握し、定義が被らないことを確認
# Bad: 汎用的すぎる shared_context が user を定義しており、
#      テスト側の let!(:user) を意図せず上書きしてしまう
RSpec.shared_context "common setup" do
  let!(:user) { create(:user, role: :admin) }  # admin ユーザーが作られる
  let!(:course) { create(:course) }
end

describe "general user endpoint" do
  include_context "common setup"
  # user は admin になってしまう(意図と異なる)
  it "returns 200 for general user" do ...end
end

# Good: shared_context は目的に応じた最小限の定義にとどめる
RSpec.shared_context "course setup" do
  let!(:course) { create(:course) }   # course の定義のみ
end

describe "general user endpoint" do
  include_context "course setup"
  let!(:user) { create(:user, role: :general) }  # ここで明示的に定義
  it "returns 200 for general user" do ...end
end

参考にしたブログ記事等

最後に

株式会社ドワンゴの教育事業では、一緒に未来の当たり前の教育をつくるメンバーを募集しています。 教育サービス「ZEN Study」をはじめとするサービスのサーバーサイド開発に興味がある方は、ぜひ下記の募集要項をご覧ください。

【教育事業】サーバーサイドエンジニア