📜

RSpecチートシート作成してみた

に公開

Railsのテスト開発における入門書であり名著でもある「Everyday Rails - RSpecによるRailsテスト入門」本を元にRSpecチートシートを作成してみました。

ベースはClaude Opus4にEveryday Rails本を読み込ませ、「これを見ればすぐにrspecが書ける様なチートシートを作成してください」と指示して作成。それを元に動作検証&一部加筆修正しました。

検証用のコードはこちら

https://github.com/JunichiIto/everydayrails-rspec-jp-2024/compare/main...okdyy75:everydayrails-rspec-jp-2024:rspec-cheat-sheet

🚀 基本セットアップ

Gemfile

group :development, :test do
  gem 'rspec-rails'
  gem 'factory_bot_rails'
end

group :test do
  gem 'capybara'
  gem 'selenium-webdriver'
  gem 'launchy'
  gem 'shoulda-matchers'
  gem 'vcr'
  gem 'webmock'
end

初期設定

# RSpecのインストール
bin/rails generate rspec:install

# rails_helper.rb(または spec_helper.rb) に追加
RSpec.configure do |config|
  # Deviseのヘルパーを使用
  config.include Devise::Test::ControllerHelpers, type: :controller
  config.include Devise::Test::IntegrationHelpers, type: :system
end
  • spec_helper.rb: RSpecの基本設定。タグや期待値(expect)まわり、モック/スタブ、出力フォーマットなど
  • rails_helper.rb: Railsの読み込みが必要な設定。Devise、Capybara等のライブラリ、spec/support/**/*.rb配下の個別のshared_contextやカスタムマッチャーなど

📝 モデルスペック

基本構造

# spec/models/user_spec.rb
require 'rails_helper'

RSpec.describe User, type: :model do
  # 有効なファクトリを持つこと
  it "factory" do
    expect(FactoryBot.build(:user)).to be_valid
  end

  # バリデーションのテスト
  describe "validations" do
    it { is_expected.to validate_presence_of :first_name }
    it { is_expected.to validate_presence_of :last_name }
    it { is_expected.to validate_presence_of :email }
    it { is_expected.to validate_uniqueness_of(:email).case_insensitive }
  end

  # アソシエーションのテスト
  describe "associations" do
    it { is_expected.to have_many(:projects) }
    it { is_expected.to have_many(:notes) }
  end

  # インスタンスメソッドのテスト
  describe "#name" do
    it "returns user's full name" do
      user = FactoryBot.build(:user, first_name: "John", last_name: "Doe")
      expect(user.name).to eq "John Doe"
    end
  end

  # スコープのテスト
  describe ".active" do
    it "returns active users" do
      active_user = FactoryBot.create(:user, sign_in_count: 1)
      inactive_user = FactoryBot.create(:user, sign_in_count: 0)
      expect(User.active).to include(active_user)
      expect(User.active).not_to include(inactive_user)
    end
  end
end

🏭 FactoryBot

ファクトリの定義

# spec/factories/users.rb
FactoryBot.define do
  factory :user, aliases: [:owner] do
    first_name { "Aaron" }
    last_name  { "Sumner" }
    sequence(:email) { |n| "tester#{n}@example.com" }
    password { "dottle-nouveau-pavilion-tights-furze" }
  end
end
# spec/factories/projects.rb
FactoryBot.define do
  factory :project do
    sequence(:name) { |n| "Project #{n}" }
    description { "A test project." }
    due_on { 1.week.from_now }
    association :owner

    # トレイト
    trait :with_notes do
      after(:create) { |project| create_list(:note, 5, project:) }
    end

    trait :due_yesterday do
      due_on { 1.day.ago }
    end

    # 複数トレイト
    factory :project_due_yesterday, traits: [:with_notes, :due_yesterday]
  end
end
# spec/factories/notes.rb
FactoryBot.define do
  factory :note do
    message { "My important note." }
    association :project
    # projectのユーザーを紐付け
    user { project.owner }
  end
end

使用例

# インスタンスの作成(DBに保存)
project = FactoryBot.create(:project)

# インスタンスの構築(DBに保存しない)
project = FactoryBot.build(:project)

# 属性のハッシュを取得
attrs = FactoryBot.attributes_for(:project)

# トレイトを使用
project = FactoryBot.create(:project, :with_notes)

# 属性を上書き
project = FactoryBot.create(:project, name: "My Project")

# 複数作成
projects = FactoryBot.create_list(:project, 3)

🎮 コントローラスペック

# spec/controllers/projects_controller_spec.rb
require 'rails_helper'

RSpec.describe ProjectsController, type: :controller do
  describe "#index" do
    context "as an authenticated user" do
      before do
        @user = FactoryBot.create(:user)
      end

      it "responds successfully" do
        sign_in @user
        get :index
        expect(response).to be_successful
        expect(response).to have_http_status "200"
      end
    end

    context "as a guest" do
      it "returns a 302 response" do
        get :index
        expect(response).to have_http_status "302"
      end

      it "redirects to the sign-in page" do
        get :index
        expect(response).to redirect_to "/users/sign_in"
      end
    end
  end

  describe "#create" do
    context "with valid attributes" do
      it "adds a project" do
        project_params = FactoryBot.attributes_for(:project)
        sign_in @user
        expect {
          post :create, params: { project: project_params }
        }.to change(@user.projects, :count).by(1)
      end
    end
  end
end

🖥️ システムスペック(統合テスト)

# spec/system/projects_spec.rb
require 'rails_helper'

RSpec.describe "Projects", type: :system do
  # JavaScript不要のテスト
  scenario "user creates a new project" do
    user = FactoryBot.create(:user)
    # using our custom login helper:
    # sign_in_as user
    # or the one provided by Devise:
    sign_in user

    visit root_path

    expect {
      click_link "New Project"
      fill_in "Name", with: "Test Project"
      fill_in "Description", with: "Trying out Capybara"
      click_button "Create Project"

      aggregate_failures do
        expect(page).to have_content "Project was successfully created"
        expect(page).to have_content "Test Project"
        expect(page).to have_content "Owner: #{user.name}"
      end
    }.to change(user.projects, :count).by(1)
  end
end
# spec/system/tasks_spec.rb
require 'rails_helper'

RSpec.describe "Tasks", type: :system do
  # JavaScript必要のテスト
  scenario "user toggles a task", js: true do
    user = FactoryBot.create(:user)
    project = FactoryBot.create(:project, name: "RSpec tutorial", owner: user)
    task = project.tasks.create!(name: "Finish RSpec tutorial")

    sign_in user
    visit project_path(project)

    check "Finish RSpec tutorial"

    expect(page).to have_css "label#task_#{task.id}.completed"
    expect(task.reload).to be_completed
  end
end

Capybara の主要メソッド

# ナビゲーション
visit "/path"
click_link "Link Text"
click_button "Button Text"
click_on "Link or Button"

# フォーム操作
fill_in "Label", with: "Value"
check "Checkbox Label"
uncheck "Checkbox Label"
choose "Radio Button"
select "Option", from: "Select Menu"
attach_file "File Input", "/path/to/file"

# 検証
expect(page).to have_content "Text"
expect(page).to have_css "h1.title"
expect(page).to have_selector "ul li"
expect(page).to have_current_path "/projects/new"

# スコープ指定
within "#sidebar" do
  click_link "Settings"
end

# デバッグ
save_and_open_page  # ページを保存してブラウザで開く
save_screenshot     # スクリーンショットを保存

🔧 便利なテクニック

let と let!

# spec/models/note_spec.rb
RSpec.describe Note, type: :model do
  let(:user) { FactoryBot.create(:user) }
  let(:project) { FactoryBot.create(:project, owner: user) }

  describe "search message for a term" do
    # 遅延評価(使われるまで実行されない)
    let(:note1) {
      FactoryBot.create(:note,
                        project: project,
                        user: user,
                        message: "This is the first note.",
                        )
    }
    # 即座に実行
    let!(:note2) {
      FactoryBot.create(:note,
                        project: project,
                        user: user,
                        message: "This is the second note.",
                        )
    }

    context "when a match is found" do
      it "returns notes that match the search term" do
        expect(Note.count).to eq 1
        # ここで初めてnote1が作成される
        expect(Note.search("first")).to include(note1)
        expect(Note.count).to eq 2
      end
    end
  end
end

before と after

require 'rails_helper'

RSpec.describe "Something", type: :system do
  describe "something" do
    before(:all) do
      # このブロック内の全テストの前に1回だけ実行(ロールバックされないのでafterで後片付けが必要)
      puts "before(:all)"
    end

    before(:each) do
      # 各テストの前に実行(ロールバックされるのでafterで後片付けは不要)
      puts "before(:each)"
    end

    after(:each) do
      puts "after(:each)"
    end

    after(:all) do
      puts "after(:all)"
    end

    context "context" do
      it "it-1" do
        puts "test-1"
        expect(true).to be true
      end
      it "it-2" do
        puts "test-2"
        expect(true).to be true
      end
    end
  end
end
bundle exec rspec spec/models/something_spec.rb

Something
  something
before(:all)
    context
before(:each)
test-1
after(:each)
      it-1
before(:each)
test-2
after(:each)
      it-2
after(:all)

shared_context

# `spec/support/contexts/project_setup.rb`
RSpec.shared_context "project setup" do
  let(:user) { FactoryBot.create(:user) }
  let(:project) { FactoryBot.create(:project, owner: user) }
  let(:task) { project.tasks.create!(name: "Test task") }
end

# 使用例
# spec/controllers/tasks_controller_spec.rb
RSpec.describe TasksController, type: :controller do
  include_context "project setup"
end

aggregate_failures

it "sent a valid email" do
  mail = ActionMailer::Base.deliveries.last

  aggregate_failures do
    expect(mail.to).to eq ["test@example.com"]
    expect(mail.from).to eq ["support@example.com"]
    expect(mail.subject).to eq "Welcome to Projects!"
    expect(mail.body).to match "Hello First,"
    expect(mail.body).to match "test@example.com"
  end
end

🎭 モックとスタブ

  • モック - 事前に本物のオブジェクトを模して振る舞いを定義する
  • スタブ - メソッドをオーバーライドして決められた固定値を返す
  • スパイ - 事後にメソッドの呼び出しを検証する
# モック(テストダブル)
user = double("user", name: "Fake User")

# モック(検証機能付きテストダブル)
user = instance_double("User", name: "Fake User")

# スタブ
allow(user).to receive(:name).and_return("full name")

# スパイ
allow(UserMailer).to receive_message_chain(:welcome_email, :deliver_later)
user = FactoryBot.create(:user)
expect(UserMailer).to have_received(:welcome_email).with(user)
  • instance_double: より安全で推奨される方法。指定したクラスに存在するメソッドのみ許可される
  • double: 外部ライブラリなど、より柔軟にモックしたい場合の方法。指定したクラスに存在するメソッドかどうかは検証しない

🏷️ タグとフィルタリング

# タグを付ける
it "processes payment", :slow do
  # 時間のかかるテスト
end

it "creates user", focus: true do
  # このテストだけ実行したい
end

# 実行コマンド
bundle exec rspec --tag focus      # focusタグのみ実行
bundle exec rspec --tag ~slow      # slowタグ以外を実行

その他のテスト

📎 ファイルアップロード(Active Storage)

Capybara でフォームからアップロード

# spec/system/notes_spec.rb
require 'rails_helper'

RSpec.describe "Notes", type: :system do
  let(:user) { FactoryBot.create(:user) }
  let(:project) {
    FactoryBot.create(:project,
                      name: "RSpec tutorial",
                      owner: user)
  }

  scenario "user uploads an attachment" do
    sign_in user
    visit project_path(project)
    click_link "Add Note"
    fill_in "Message", with: "My book cover"
    attach_file "Attachment", "#{Rails.root}/spec/files/attachment.jpg"
    click_button "Create Note"
    expect(page).to have_content "Note was successfully created"
    expect(page).to have_content "My book cover"
    expect(page).to have_content "attachment.jpg (image/jpeg"
  end
end

FactoryBot で添付済みレコードを用意

# spec/factories/notes.rb
FactoryBot.define do
  factory :note do
    message { "My important note." }
    association :project
    user { project.owner }
  end

  trait :with_attachment do
    attachment { Rack::Test::UploadedFile.new("#{Rails.root}/spec/files/attachment.jpg", 'image/jpeg') }
  end
end

後片付け(tmp/storage の削除)

# spec/rails_helper.rb
RSpec.configure do |config|
  config.after(:suite) do
    Pathname(ActiveStorage::Blob.service.root).each_child do |path|
      path.rmtree if path.directory?
    end
  end
end

🧵 ジョブ(ActiveJob)スペック

ジョブがキューに追加されることを確認

# spec/system/sign_ins_spec.rb
require 'rails_helper'

RSpec.describe "Sign in", type: :system do
  let(:user) { FactoryBot.create(:user) }

  before do
    ActiveJob::Base.queue_adapter = :test
  end

  scenario "user signs in" do
    visit root_path
    click_link "Sign In"
    fill_in "Email", with: user.email
    fill_in "Password", with: user.password
    click_button "Log in"

    # GeocodeUserJobがキューに追加されること
    expect {
      GeocodeUserJob.perform_later(user)
    }.to have_enqueued_job.with(user)
  end
end

ジョブが実際呼ばれるかを確認

# spec/jobs/geocode_user_job_spec.rb
require "rails_helper"

RSpec.describe GeocodeUserJob, type: :job do
  # userのgeocodeメソッドが呼ばれること
  it "calls geocode on the user" do
    user = instance_double("User")
    expect(user).to receive(:geocode)
    GeocodeUserJob.perform_now(user)
  end
end

✉️ メイラースペック

# spec/mailers/user_mailer_spec.rb
require 'rails_helper'

RSpec.describe UserMailer, type: :mailer do
  describe 'welcome_email' do
    let(:user) { FactoryBot.create(:user) }
    let(:mail) { UserMailer.welcome_email(user) }

    it 'renders the headers' do
      expect(mail.to).to eq([user.email])
      expect(mail.from).to eq(['support@example.com'])
      expect(mail.subject).to eq('Welcome to Projects!')
    end

    it 'renders the body' do
      expect(mail.body).to match(/Hello #{user.first_name},/)
      expect(mail.body).to match user.email
    end
  end
end

🌐 外部 API のテスト(VCR)

# spec/support/vcr.rb
VCR.configure do |config|
  config.cassette_library_dir = "#{::Rails.root}/spec/cassettes"
  config.hook_into :webmock
  config.ignore_localhost = true
  config.configure_rspec_metadata!
end

# 使用例
# spec/models/user_spec.rb
it "performs geocoding", vcr: true do
  user = FactoryBot.create(:user, last_sign_in_ip: "161.185.207.20")
  expect {
    user.geocode
  }.to change(user, :location).
    from(nil).
    to("New York City, New York, US")
end

✅ よく使うマッチャー

# 等価性
expect(actual).to eq(expected)
expect(actual).to be == expected

# 真偽値
expect(actual).to be_truthy
expect(actual).to be_falsey
expect(actual).to be true
expect(actual).to be false

# 比較
expect(actual).to be > expected
expect(actual).to be_between(min, max)

# 正規表現
expect(actual).to match(/expression/)

# 型
expect(actual).to be_a(String)
expect(actual).to be_an_instance_of(String)

# コレクション
expect(actual).to include(expected)
expect(actual).to start_with(expected)
expect(actual).to end_with(expected)
expect(actual).to be_empty
expect(actual).to have_attributes(first_name: "John", last_name: "Doe")

# 変化
expect { action }.to change(object, :count).by(1)
expect { action }.to change { object.count }.from(0).to(1)

# エラー
expect { action }.to raise_error(StandardError)
expect { action }.to raise_error("message")

# 述語マッチャ
expect(model).to be_valid
expect(model).to be_persisted

💡 テストの基本まとめ

  1. テストは明示的に記述 - example の結果がどうなるかを動詞を使って説明。1つの example につき1つの期待値
  2. 起きてほしいことと、起きてほしくないことをテストする - example を書くときは両⽅のパスを考え、その考えに沿ったテストを書く
  3. 境界値テストをする - もしパスワードのバリデーションが4⽂字以上10⽂字以下なら、8⽂字のパスワードのテストで満⾜せず、4⽂字と10⽂字、そして3⽂字と11⽂字をテストするのが良いテストケース。そしてなぜそんな短いパスワードなのかといったアプリケーションの要件とコードを熟考するための良い機会でもある
  4. 可読性を上げるためにスペックを整理する - describe と context を使って似たような example は分類し、before ブロックと after ブロックで重複を取り除く。しかし、テストの場合は DRY であることよりも読みやすさを優先

参照:3. モデルスペック - まとめ P56

🔗 参考リンク

Discussion