0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Request spec追加】リファクタリングやバグ改修の信頼性が爆上がりする理由

Posted at

はじめに

Rails 5を境に、コントローラのテスト方針が大きく変わりました。従来のcontroller specで使われていたassignsassert_templateは非推奨となり、request specへの移行(追加)が推奨されています。

rails-controller-testing gemを導入すれば旧来の書き方も可能ですが、RSpecコアチームとRailsチームは共にrequest specを推奨しています。

本記事では、controller specからrequest specへの移行(追加)方法を、具体的なコード例とともに解説します。

なぜrequest specでリファクタリングの信頼性が上がるのか

結論から言うと、request specは「実装の詳細」ではなく「振る舞い」をテストするからです。

controller specの問題点

controller specでは以下のようなテストを書いていました。

# コントローラの内部実装に依存したテスト
it '@usersにユーザー一覧が格納される' do
  get :index
  expect(assigns(:users)).to eq users
end

it 'indexテンプレートを描画する' do
  get :index
  expect(response).to render_template :index
end

これらのテストはコントローラの内部実装に強く依存しています。例えば:

  • インスタンス変数名を@usersから@membersに変更 → テスト失敗
  • ビューファイルをindex.html.erbからlist.html.erbに変更 → テスト失敗
  • Service Objectにロジックを切り出して変数の受け渡し方を変更 → テスト失敗

機能としては正しく動作しているのに、テストが壊れるという状況が頻発します。

これがリファクタリングを躊躇させる原因です。「テストが壊れるかもしれない」という恐怖が、コードの改善を妨げてしまうのです。

request specが解決すること

request specは「ユーザーから見た振る舞い」だけをテストします。

# 振る舞いをテストする
it '登録済みユーザーの名前がすべて出力される' do
  get users_path
  users.each do |user|
    expect(response.body).to include(user.name)
  end
end

このテストが検証しているのは「ユーザー一覧ページにアクセスしたら、登録済みユーザーの名前が表示される」という仕様です。

内部でどのような変数名を使っていようが、どのテンプレートを使っていようが、Service Objectを使っていようが関係ありません。仕様通りに動いていればテストは通るのです。

リファクタリング時の安心感

シナリオ controller spec request spec
変数名を@users@membersに変更 ❌ 失敗 ✅ 成功
Decoratorパターンを導入 ❌ 失敗の可能性 ✅ 成功
Service Objectへのロジック切り出し ❌ 失敗の可能性 ✅ 成功
N+1解消のためのクエリ最適化 ✅ 成功 ✅ 成功
表示内容の変更 ✅ 成功 ❌ 失敗(正しい挙動)

最後の行がポイントです。表示内容を変更したら、request specは失敗します。これは正しい失敗です。仕様が変わったのですから、テストも更新すべきなのです。

バグ改修時の信頼性

バグ改修においても、request specの方が信頼性が高くなります。

controller specの場合:

  • インスタンス変数に正しい値が入っていることは確認できる
  • しかし、実際にユーザーに正しく表示されているかは保証されない
  • ビューのバグは検出できない

request specの場合:

  • 実際のHTTPリクエスト〜レスポンスの流れをテスト
  • ユーザーが目にする最終的な出力を検証
  • ビューを含めた統合的な動作確認が可能

つまり、request specは**「ユーザーが体験する挙動」をそのままテストしている**ため、バグ改修後の確認として信頼できるのです。

テストの考え方の違い

controller spec(従来)

  • コントローラのメソッドを直接呼び出す
  • インスタンス変数(@userなど)の状態を検証
  • 描画されるテンプレートを検証
  • ホワイトボックステスト:内部実装を知っている前提

request spec(現在)

  • HTTPリクエストをエンドポイントに送信
  • レスポンスのステータスコードやボディを検証
  • コントローラ内部には関与しない
  • ブラックボックステスト:内部実装を知らなくても書ける

動作環境

  • Rails 5.1.4
  • rspec-rails 3.7.2

モデルとファクトリの準備

app/models/user.rb
class User < ApplicationRecord
  validates :name, presence: true
end
spec/factories/users.rb
FactoryBot.define do
  factory :user do
    sequence(:name) { |n| "テストユーザー#{n}" }
    sequence(:email) { |n| "test#{n}@sample.org" }

    trait :invalid do
      name { nil }
    end
  end

  factory :admin_user, class: User do
    sequence(:name) { |n| "管理者#{n}" }
    sequence(:email) { |n| "admin#{n}@sample.org" }
  end

  factory :general_user, class: User do
    sequence(:name) { |n| "一般ユーザー#{n}" }
    sequence(:email) { |n| "member#{n}@sample.org" }
  end
end

各アクションの書き換え例

一覧表示(index)

Before: controller spec

describe UsersController, type: :controller do
  describe 'GET #index' do
    let(:users) { FactoryBot.create_list :user, 2 }

    it 'ステータス200を返す' do
      get :index
      expect(response.status).to eq 200
    end

    it 'indexテンプレートを描画する' do
      get :index
      expect(response).to render_template :index
    end

    it '@usersにユーザー一覧が格納される' do
      get :index
      expect(assigns :users).to eq users
    end
  end
end

After: request spec

describe 'ユーザー一覧API' do
  let!(:users) { FactoryBot.create_list(:general_user, 3) }

  describe 'GET /users' do
    subject { get users_path }

    it 'HTTPステータス200が返却される' do
      subject
      expect(response).to have_http_status(:ok)
    end

    it '登録済みユーザーの名前がすべて出力される' do
      subject
      users.each do |user|
        expect(response.body).to include(user.name)
      end
    end
  end
end

ポイント

  • get :indexget users_path(URLヘルパーまたはパス文字列を使用)
  • assigns(:users)response.bodyの検証に置き換え
  • render_template → レスポンスボディの内容で間接的に確認
  • subjectを活用してリクエスト処理を共通化

詳細表示(show)

describe 'ユーザー詳細API' do
  describe 'GET /users/:id' do
    context '存在するユーザーの場合' do
      let(:target_user) { FactoryBot.create(:general_user) }
      subject { get user_path(target_user) }

      it 'HTTPステータス200が返却される' do
        subject
        expect(response).to have_http_status(:ok)
      end

      it 'ユーザー名がレスポンスに含まれる' do
        subject
        expect(response.body).to include(target_user.name)
      end
    end

    context '存在しないユーザーの場合' do
      it 'RecordNotFoundが発生する' do
        expect { get user_path(id: 9999) }.to raise_error(ActiveRecord::RecordNotFound)
      end
    end
  end
end

新規作成フォーム(new)

describe 'ユーザー新規作成フォーム' do
  describe 'GET /users/new' do
    it 'HTTPステータス200が返却される' do
      get new_user_path
      expect(response).to have_http_status(:ok)
    end
  end
end

フォーム自体の動作検証はfeature specに任せるのが適切です。ここではエンドポイントが正常に応答することだけを確認します。

編集フォーム(edit)

describe 'ユーザー編集フォーム' do
  let(:target_user) { FactoryBot.create(:general_user) }

  describe 'GET /users/:id/edit' do
    subject { get edit_user_path(target_user) }

    it 'HTTPステータス200が返却される' do
      subject
      expect(response).to have_http_status(:ok)
    end

    it '既存の値がレスポンスに含まれる' do
      subject
      expect(response.body).to include(target_user.name)
      expect(response.body).to include(target_user.email)
    end
  end
end

登録処理(create)

describe 'ユーザー登録API' do
  describe 'POST /users' do
    context '有効なパラメータの場合' do
      let(:valid_attributes) { FactoryBot.attributes_for(:user) }
      subject { post users_path, params: { user: valid_attributes } }

      it 'HTTPステータス302が返却される' do
        subject
        expect(response).to have_http_status(:found)
      end

      it 'ユーザーが1件増える' do
        expect { subject }.to change(User, :count).by(1)
      end

      it '作成したユーザーの詳細ページへリダイレクトする' do
        subject
        expect(response).to redirect_to(User.last)
      end
    end

    context '無効なパラメータの場合' do
      let(:invalid_attributes) { FactoryBot.attributes_for(:user, :invalid) }
      subject { post users_path, params: { user: invalid_attributes } }

      it 'HTTPステータス200が返却される(フォーム再表示)' do
        subject
        expect(response).to have_http_status(:ok)
      end

      it 'ユーザーが増えない' do
        expect { subject }.not_to change(User, :count)
      end

      it 'エラーメッセージがレスポンスに含まれる' do
        subject
        expect(response.body).to include('prohibited this user from being saved')
      end
    end
  end
end

更新処理(update)

describe 'ユーザー更新API' do
  let(:target_user) { FactoryBot.create(:general_user) }

  describe 'PUT /users/:id' do
    context '有効なパラメータの場合' do
      let(:new_attributes) { { name: '更新後の名前', email: 'updated@sample.org' } }
      subject { put user_path(target_user), params: { user: new_attributes } }

      it 'HTTPステータス302が返却される' do
        subject
        expect(response).to have_http_status(:found)
      end

      it 'ユーザー名が更新される' do
        expect { subject }.to change { target_user.reload.name }.to('更新後の名前')
      end

      it '更新したユーザーの詳細ページへリダイレクトする' do
        subject
        expect(response).to redirect_to(target_user)
      end
    end

    context '無効なパラメータの場合' do
      let(:invalid_attributes) { { name: nil } }
      subject { put user_path(target_user), params: { user: invalid_attributes } }

      it 'HTTPステータス200が返却される' do
        subject
        expect(response).to have_http_status(:ok)
      end

      it 'ユーザー名が変更されない' do
        expect { subject }.not_to change { target_user.reload.name }
      end

      it 'エラーメッセージがレスポンスに含まれる' do
        subject
        expect(response.body).to include('prohibited this user from being saved')
      end
    end
  end
end

request specではuser_path(target_user)のようにURLヘルパーにオブジェクトを渡します。controller specのようにparams: { id: target_user }とする必要はありません。

削除処理(destroy)

describe 'ユーザー削除API' do
  let!(:target_user) { FactoryBot.create(:user) }

  describe 'DELETE /users/:id' do
    subject { delete user_path(target_user) }

    it 'HTTPステータス302が返却される' do
      subject
      expect(response).to have_http_status(:found)
    end

    it 'ユーザーが1件減る' do
      expect { subject }.to change(User, :count).by(-1)
    end

    it '一覧ページへリダイレクトする' do
      subject
      expect(response).to redirect_to(users_path)
    end
  end
end

JSON APIのテスト

JSON APIのテストはrequest specの得意分野です。controller specではrender_viewsを呼ばない限りresponse.bodyが空になりますが、request specでは自然にテストできます。

describe 'ユーザー一覧JSON API' do
  let(:json_headers) do
    {
      'Content-Type' => 'application/json',
      'Accept' => 'application/json'
    }
  end
  let!(:users) { FactoryBot.create_list(:general_user, 3) }

  describe 'GET /users.json' do
    subject { get users_path, headers: json_headers }

    it 'HTTPステータス200が返却される' do
      subject
      expect(response).to have_http_status(:ok)
    end

    it '登録済みユーザー件数分のデータが返却される' do
      subject
      json_response = JSON.parse(response.body)
      expect(json_response.length).to eq(3)
    end
  end
end

request specとfeature specの使い分け

request specは単一のリクエスト/レスポンスに焦点を当てます。以下のようなケースはfeature specで扱うべきです。

  • 複数のコントローラをまたぐ操作フロー
  • ユーザーの視点からのシナリオテスト(「ログインしてユーザーを作成する」など)
  • JavaScriptを含むインタラクション

まとめ

controller specからrequest specへの移行(追加)は、見た目ほど大きな変更ではありません。主な違いは以下の点です。

観点 controller spec request spec
リクエスト get :index get users_path
パラメータ params: { id: user } URLヘルパーに含める
検証対象 assigns, render_template response.body, response.status
性質 ホワイトボックス ブラックボックス

request specに移行することで得られるメリットをまとめます:

  1. リファクタリングへの耐性 - 内部実装を変更してもテストが壊れにくい
  2. バグ改修の信頼性向上 - ユーザー体験に近い形で動作確認できる
  3. テストの意図が明確 - 「何をテストしているか」が仕様ベースで理解しやすい
  4. メンテナンスコスト削減 - 実装変更のたびにテストを修正する必要がない

新規プロジェクトでは迷わずrequest specを選択しましょう。既存プロジェクトでも、新しいテストからrequest specで書き始めることをお勧めします。

参考リンク



0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?