RSpec
単体テストツール
RSpec
概要
RSpecは、Ruby用の行動駆動開発(BDD)テストフレームワークです。Rubyのドメイン固有言語(DSL)として設計されており、テストを自然言語に近い形で記述できることが特徴です。単なるテストツールではなく、コードの振る舞いを明確に記述し、実行可能な仕様書として機能する革新的なアプローチを提供します。プロダクション環境で最も頻繁に使用されているRubyテストライブラリとして、現在も活発に開発が続けられています。
詳細
RSpecは2014年にバージョン3.0に到達し、現在はバージョン3.12まで進化を続けています。モジュラー設計により、必要な機能だけを選択して使用できる柔軟性を持っています。
RSpecの主なコンポーネント:
- rspec-core: テストプロセス管理、テストスイートの基盤
- rspec-expectations: 豊富な「マッチャー」でコードの期待する振る舞いを明確に表現
- rspec-mocks: テストダブル(モック、スタブ、スパイ)の作成と管理
RSpecの核心的特徴:
- BDDアプローチ: 実装ではなく振る舞いに焦点を当てたテスト
- 自然言語ライクなDSL: 英語として読める表現力豊かな構文
- 実行可能なドキュメント: テストが生きた仕様書として機能
- 豊富なマッチャー: 期待値を明確に表現する多様なマッチャー
- 柔軟なテスト構造:
describe、context、itによる階層的な整理 - モック・スタブ機能: 依存関係の分離とテストの独立性確保
- 継続的統合対応: Semaphore等のCI/CDプラットフォームとの統合
RSpecの根本的な哲学は「実装ではなく振る舞いをテストする」ことにあり、これによりテストがより価値のある設計ツールとなります。
メリット・デメリット
メリット
- 優れた可読性: 自然言語に近いDSLでテストの意図が明確
- BDDサポート: 行動駆動開発の完全なサポート
- 豊富なマッチャー: 様々な検証パターンに対応する表現力豊かなマッチャー
- モジュラー設計: 必要な機能のみを選択できる柔軟な構成
- 実行可能な仕様書: テストが同時にドキュメントとして機能
- 強力なモック機能: テストダブルによる効果的な依存関係管理
- 活発なコミュニティ: 充実したドキュメントとコミュニティサポート
- IDEサポート: 主要IDEでの豊富な支援機能
デメリット
- 学習コスト: DSL特有の記法と概念の理解が必要
- Ruby専用: 他の言語では使用不可
- パフォーマンス: 小規模テストではオーバーヘッドを感じる場合がある
- 設定の複雑さ: 大規模プロジェクトでの設定管理が複雑になることがある
- 記述の自由度: 過度に詳細な記述により保守性が下がるリスク
参考ページ
使い方の例
基本セットアップ
Gemfileへの追加
# Gemfile
group :development, :test do
gem 'rspec-rails', '~> 6.0'
gem 'rspec'
# 関連gem
gem 'factory_bot_rails'
gem 'faker'
end
インストールと初期化
bundle install
rails generate rspec:install
# または、純粋なRubyプロジェクトの場合
bundle exec rspec --init
基本的なspec ファイル
# spec/models/user_spec.rb
require 'spec_helper'
RSpec.describe User do
describe '#full_name' do
context 'when both first and last names are provided' do
it 'returns the concatenated full name' do
user = User.new(first_name: 'John', last_name: 'Doe')
expect(user.full_name).to eq('John Doe')
end
end
context 'when only first name is provided' do
it 'returns just the first name' do
user = User.new(first_name: 'John', last_name: nil)
expect(user.full_name).to eq('John')
end
end
end
end
describe、context、it の基本構造
階層的なテスト構造
RSpec.describe Calculator do
describe '#add' do
context 'when adding positive numbers' do
it 'returns the sum' do
calculator = Calculator.new
result = calculator.add(2, 3)
expect(result).to eq(5)
end
it 'handles multiple additions' do
calculator = Calculator.new
result = calculator.add(1, 2, 3, 4)
expect(result).to eq(10)
end
end
context 'when adding negative numbers' do
it 'returns the correct negative sum' do
calculator = Calculator.new
result = calculator.add(-2, -3)
expect(result).to eq(-5)
end
end
context 'when adding zero' do
it 'returns the other number unchanged' do
calculator = Calculator.new
expect(calculator.add(5, 0)).to eq(5)
expect(calculator.add(0, 7)).to eq(7)
end
end
end
describe '#divide' do
context 'when dividing by zero' do
it 'raises a ZeroDivisionError' do
calculator = Calculator.new
expect { calculator.divide(10, 0) }.to raise_error(ZeroDivisionError)
end
end
end
end
マッチャーの使用
基本的なマッチャー
RSpec.describe 'Basic matchers' do
it 'demonstrates equality matchers' do
expect(1 + 1).to eq(2) # 値の等価性
expect(1 + 1).to eql(2) # 値と型の等価性
expect('hello').to equal('hello') # オブジェクトの同一性(通常は避ける)
end
it 'demonstrates truthiness matchers' do
expect(true).to be_truthy
expect(false).to be_falsy
expect(nil).to be_nil
expect('hello').not_to be_nil
end
it 'demonstrates comparison matchers' do
expect(10).to be > 5
expect(10).to be >= 10
expect(5).to be < 10
expect(5).to be <= 5
expect(5).to be_between(1, 10).inclusive
end
it 'demonstrates type matchers' do
expect('hello').to be_a(String)
expect([1, 2, 3]).to be_an(Array)
expect(42).to be_an_instance_of(Integer)
end
end
コレクションマッチャー
RSpec.describe 'Collection matchers' do
let(:fruits) { ['apple', 'banana', 'cherry'] }
it 'checks collection contents' do
expect(fruits).to include('apple')
expect(fruits).to include('apple', 'banana')
expect(fruits).not_to include('orange')
expect(fruits).to contain_exactly('cherry', 'apple', 'banana')
expect(fruits).to match_array(['banana', 'apple', 'cherry'])
end
it 'checks collection size' do
expect(fruits).to have(3).items
expect(fruits.size).to eq(3)
expect(fruits).not_to be_empty
end
it 'checks specific positions' do
expect(fruits).to start_with('apple')
expect(fruits).to end_with('cherry')
end
end
文字列マッチャー
RSpec.describe 'String matchers' do
let(:text) { 'Hello, RSpec World!' }
it 'matches string patterns' do
expect(text).to match(/RSpec/)
expect(text).to match(/hello/i) # 大文字小文字を無視
expect(text).not_to match(/goodbye/)
expect(text).to start_with('Hello')
expect(text).to end_with('World!')
expect(text).to include('RSpec')
end
end
before、after、around フック
セットアップとティアダウン
RSpec.describe 'Hooks example' do
before(:suite) do
puts "実行前(全体): テストスイート開始前に一度だけ実行"
end
after(:suite) do
puts "実行後(全体): テストスイート終了後に一度だけ実行"
end
before(:all) do
puts "実行前(全例): このdescribeブロック内の全例開始前に一度だけ実行"
@shared_data = "共有データ"
end
after(:all) do
puts "実行後(全例): このdescribeブロック内の全例終了後に一度だけ実行"
end
before(:each) do
puts "実行前(各例): 各exampleの前に実行"
@user = User.new(name: 'Test User')
end
after(:each) do
puts "実行後(各例): 各exampleの後に実行"
# クリーンアップ処理
end
around(:each) do |example|
puts "around開始"
example.run # テストの実行
puts "around終了"
end
it 'first example' do
expect(@user.name).to eq('Test User')
end
it 'second example' do
expect(@shared_data).to eq('共有データ')
end
end
let と subject
遅延評価による効率的なセットアップ
RSpec.describe User do
# 通常のlet(遅延評価)
let(:user) { User.new(name: 'John', email: '[email protected]') }
# let!(即座に評価)
let!(:admin) { User.create(name: 'Admin', role: 'admin') }
# subject(テスト対象の定義)
subject { user.full_profile }
# 名前付きsubject
subject(:user_profile) { user.full_profile }
describe '#full_profile' do
context 'when user has complete information' do
it 'returns formatted profile' do
expect(subject).to include('John')
expect(subject).to include('[email protected]')
end
it { is_expected.to be_a(String) }
it { is_expected.not_to be_empty }
end
context 'when user has minimal information' do
let(:user) { User.new(name: 'Jane') }
it 'returns basic profile' do
expect(user_profile).to include('Jane')
expect(user_profile).not_to include('@')
end
end
end
describe '#save' do
it 'persists the user' do
expect { user.save }.to change(User, :count).by(1)
end
end
end
モックとスタブ
RSpec のモック機能
RSpec.describe EmailService do
describe '#send_notification' do
let(:user) { instance_double('User', email: '[email protected]', name: 'John') }
let(:mailer) { class_double('UserMailer') }
before do
allow(UserMailer).to receive(:notification_email).and_return(mailer)
allow(mailer).to receive(:deliver_now)
end
it 'sends email to user' do
service = EmailService.new
service.send_notification(user, 'Welcome!')
expect(UserMailer).to have_received(:notification_email)
.with(user.email, 'Welcome!')
expect(mailer).to have_received(:deliver_now)
end
context 'when email delivery fails' do
before do
allow(mailer).to receive(:deliver_now)
.and_raise(Net::SMTPError, 'Server unavailable')
end
it 'handles the error gracefully' do
service = EmailService.new
expect { service.send_notification(user, 'Test') }
.not_to raise_error
end
end
end
end
スパイとダブル
RSpec.describe PaymentProcessor do
describe '#process_payment' do
let(:gateway) { spy('PaymentGateway') }
let(:payment) { double('Payment', amount: 100, currency: 'USD') }
before do
allow(gateway).to receive(:charge).and_return(true)
end
it 'charges the payment gateway' do
processor = PaymentProcessor.new(gateway)
processor.process_payment(payment)
expect(gateway).to have_received(:charge)
.with(100, 'USD')
end
it 'returns success status' do
processor = PaymentProcessor.new(gateway)
result = processor.process_payment(payment)
expect(result).to be_truthy
end
end
end
例外とエラーハンドリング
例外のテスト
RSpec.describe BankAccount do
let(:account) { BankAccount.new(balance: 100) }
describe '#withdraw' do
context 'when sufficient funds are available' do
it 'deducts the amount from balance' do
expect { account.withdraw(50) }
.to change(account, :balance).from(100).to(50)
end
end
context 'when insufficient funds are available' do
it 'raises an InsufficientFundsError' do
expect { account.withdraw(150) }
.to raise_error(InsufficientFundsError)
end
it 'raises error with specific message' do
expect { account.withdraw(150) }
.to raise_error(InsufficientFundsError, 'Insufficient funds')
end
it 'does not change the balance' do
expect { account.withdraw(150) rescue nil }
.not_to change(account, :balance)
end
end
end
end
共有examples
コードの再利用
# spec/support/shared_examples.rb
RSpec.shared_examples 'a timestamped model' do
it 'has created_at timestamp' do
expect(subject.created_at).to be_present
expect(subject.created_at).to be_a(Time)
end
it 'has updated_at timestamp' do
expect(subject.updated_at).to be_present
expect(subject.updated_at).to be_a(Time)
end
end
RSpec.shared_examples 'a valid email format' do |email_field|
it 'accepts valid email addresses' do
valid_emails = ['[email protected]', '[email protected]']
valid_emails.each do |email|
subject.send("#{email_field}=", email)
expect(subject).to be_valid
end
end
it 'rejects invalid email addresses' do
invalid_emails = ['invalid', '@domain.com', 'user@']
invalid_emails.each do |email|
subject.send("#{email_field}=", email)
expect(subject).not_to be_valid
end
end
end
# 使用例
RSpec.describe User do
subject { User.new(name: 'John', email: '[email protected]') }
it_behaves_like 'a timestamped model'
it_behaves_like 'a valid email format', :email
end
RSpec.describe Admin do
subject { Admin.new(name: 'Admin', contact_email: '[email protected]') }
it_behaves_like 'a timestamped model'
it_behaves_like 'a valid email format', :contact_email
end
Factory とフィクスチャ
FactoryBot との組み合わせ
# spec/factories/users.rb
FactoryBot.define do
factory :user do
first_name { Faker::Name.first_name }
last_name { Faker::Name.last_name }
email { Faker::Internet.email }
trait :admin do
role { 'admin' }
end
trait :with_posts do
after(:create) do |user|
create_list(:post, 3, author: user)
end
end
end
end
# spec/models/user_spec.rb
RSpec.describe User do
describe 'associations' do
let(:user) { create(:user, :with_posts) }
it 'has many posts' do
expect(user.posts).to have(3).items
expect(user.posts.first).to be_a(Post)
end
end
describe 'admin privileges' do
let(:admin) { create(:user, :admin) }
it 'can access admin panel' do
expect(admin.can_access_admin_panel?).to be_truthy
end
end
end
カスタムマッチャー
独自マッチャーの作成
# spec/support/custom_matchers.rb
RSpec::Matchers.define :be_a_valid_email do
match do |actual|
actual =~ /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i
end
failure_message do |actual|
"expected '#{actual}' to be a valid email address"
end
failure_message_when_negated do |actual|
"expected '#{actual}' not to be a valid email address"
end
end
RSpec::Matchers.define :have_error_on do |field|
match do |actual|
actual.valid?
actual.errors[field].present?
end
failure_message do |actual|
"expected #{actual.class} to have error on #{field}, but got errors: #{actual.errors.full_messages}"
end
end
# 使用例
RSpec.describe User do
describe 'email validation' do
it 'validates email format' do
expect('[email protected]').to be_a_valid_email
expect('invalid-email').not_to be_a_valid_email
end
end
describe 'validations' do
let(:user) { User.new }
it 'requires name' do
expect(user).to have_error_on(:name)
end
end
end