📜
RSpecチートシート作成してみた
Railsのテスト開発における入門書であり名著でもある「Everyday Rails - RSpecによるRailsテスト入門」本を元にRSpecチートシートを作成してみました。
ベースはClaude Opus4にEveryday Rails本を読み込ませ、「これを見ればすぐにrspecが書ける様なチートシートを作成してください」と指示して作成。それを元に動作検証&一部加筆修正しました。
検証用のコードはこちら
🚀 基本セットアップ
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
💡 テストの基本まとめ
- テストは明示的に記述 - example の結果がどうなるかを動詞を使って説明。1つの example につき1つの期待値
- 起きてほしいことと、起きてほしくないことをテストする - example を書くときは両⽅のパスを考え、その考えに沿ったテストを書く
- 境界値テストをする - もしパスワードのバリデーションが4⽂字以上10⽂字以下なら、8⽂字のパスワードのテストで満⾜せず、4⽂字と10⽂字、そして3⽂字と11⽂字をテストするのが良いテストケース。そしてなぜそんな短いパスワードなのかといったアプリケーションの要件とコードを熟考するための良い機会でもある
- 可読性を上げるためにスペックを整理する - describe と context を使って似たような example は分類し、before ブロックと after ブロックで重複を取り除く。しかし、テストの場合は DRY であることよりも読みやすさを優先
参照:3. モデルスペック - まとめ P56
Discussion