RSpec

Ruby単体テストBDD行動駆動開発DSLMatcherテスト可読性TDD

単体テストツール

RSpec

概要

RSpecは、Ruby用の行動駆動開発(BDD)テストフレームワークです。Rubyのドメイン固有言語(DSL)として設計されており、テストを自然言語に近い形で記述できることが特徴です。単なるテストツールではなく、コードの振る舞いを明確に記述し、実行可能な仕様書として機能する革新的なアプローチを提供します。プロダクション環境で最も頻繁に使用されているRubyテストライブラリとして、現在も活発に開発が続けられています。

詳細

RSpecは2014年にバージョン3.0に到達し、現在はバージョン3.12まで進化を続けています。モジュラー設計により、必要な機能だけを選択して使用できる柔軟性を持っています。

RSpecの主なコンポーネント:

  • rspec-core: テストプロセス管理、テストスイートの基盤
  • rspec-expectations: 豊富な「マッチャー」でコードの期待する振る舞いを明確に表現
  • rspec-mocks: テストダブル(モック、スタブ、スパイ)の作成と管理

RSpecの核心的特徴:

  • BDDアプローチ: 実装ではなく振る舞いに焦点を当てたテスト
  • 自然言語ライクなDSL: 英語として読める表現力豊かな構文
  • 実行可能なドキュメント: テストが生きた仕様書として機能
  • 豊富なマッチャー: 期待値を明確に表現する多様なマッチャー
  • 柔軟なテスト構造: describecontextitによる階層的な整理
  • モック・スタブ機能: 依存関係の分離とテストの独立性確保
  • 継続的統合対応: Semaphore等のCI/CDプラットフォームとの統合

RSpecの根本的な哲学は「実装ではなく振る舞いをテストする」ことにあり、これによりテストがより価値のある設計ツールとなります。

メリット・デメリット

メリット

  1. 優れた可読性: 自然言語に近いDSLでテストの意図が明確
  2. BDDサポート: 行動駆動開発の完全なサポート
  3. 豊富なマッチャー: 様々な検証パターンに対応する表現力豊かなマッチャー
  4. モジュラー設計: 必要な機能のみを選択できる柔軟な構成
  5. 実行可能な仕様書: テストが同時にドキュメントとして機能
  6. 強力なモック機能: テストダブルによる効果的な依存関係管理
  7. 活発なコミュニティ: 充実したドキュメントとコミュニティサポート
  8. IDEサポート: 主要IDEでの豊富な支援機能

デメリット

  1. 学習コスト: DSL特有の記法と概念の理解が必要
  2. Ruby専用: 他の言語では使用不可
  3. パフォーマンス: 小規模テストではオーバーヘッドを感じる場合がある
  4. 設定の複雑さ: 大規模プロジェクトでの設定管理が複雑になることがある
  5. 記述の自由度: 過度に詳細な記述により保守性が下がるリスク

参考ページ

使い方の例

基本セットアップ

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