RSpec

Rubyunit testingBDDbehavior driven developmentDSLmatchertest readabilityTDD

Unit Testing Tool

RSpec

Overview

RSpec is a behavior-driven development (BDD) testing framework for Ruby. Designed as a domain-specific language (DSL) for Ruby, it enables writing tests in a form close to natural language. Rather than being just a testing tool, RSpec provides an innovative approach that clearly describes code behavior and functions as executable specifications. As the most frequently used Ruby testing library in production environments, it continues to be actively developed.

Details

RSpec reached version 3.0 in 2014 and has continued evolving to version 3.12. Its modular design provides flexibility to select and use only the required functionality.

Main components of RSpec:

  • rspec-core: Test process management, test suite foundation
  • rspec-expectations: Rich "matchers" for clearly expressing expected code behavior
  • rspec-mocks: Creation and management of test doubles (mocks, stubs, spies)

Core features of RSpec:

  • BDD approach: Testing focused on behavior rather than implementation
  • Natural language-like DSL: Expressive syntax readable as English
  • Executable documentation: Tests function as living specifications
  • Rich matchers: Diverse matchers for clearly expressing expectations
  • Flexible test structure: Hierarchical organization using describe, context, it
  • Mock and stub functionality: Dependency isolation and test independence
  • CI/CD integration: Integration with CI/CD platforms like Semaphore

RSpec's fundamental philosophy is to "test behavior, not implementation," making tests more valuable design tools.

Advantages and Disadvantages

Advantages

  1. Excellent readability: Natural language-like DSL makes test intentions clear
  2. BDD support: Complete support for behavior-driven development
  3. Rich matchers: Expressive matchers for various verification patterns
  4. Modular design: Flexible configuration allowing selection of only needed features
  5. Executable specifications: Tests simultaneously function as documentation
  6. Powerful mock functionality: Effective dependency management through test doubles
  7. Active community: Comprehensive documentation and community support
  8. IDE support: Rich support features in major IDEs

Disadvantages

  1. Learning cost: Understanding DSL-specific notation and concepts required
  2. Ruby only: Cannot be used with other programming languages
  3. Performance: May feel overhead for small-scale tests
  4. Configuration complexity: Configuration management can become complex in large projects
  5. Descriptive freedom: Risk of reduced maintainability due to overly detailed descriptions

Reference Pages

Usage Examples

Basic Setup

Adding to Gemfile

# Gemfile
group :development, :test do
  gem 'rspec-rails', '~> 6.0'
  gem 'rspec'
  # Related gems
  gem 'factory_bot_rails'
  gem 'faker'
end

Installation and Initialization

bundle install
rails generate rspec:install

# Or for pure Ruby projects
bundle exec rspec --init

Basic spec file

# 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

Basic Structure with describe, context, it

Hierarchical Test Structure

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

Using Matchers

Basic Matchers

RSpec.describe 'Basic matchers' do
  it 'demonstrates equality matchers' do
    expect(1 + 1).to eq(2)           # Value equality
    expect(1 + 1).to eql(2)          # Value and type equality
    expect('hello').to equal('hello') # Object identity (usually avoided)
  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

Collection Matchers

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

String Matchers

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)  # Case insensitive
    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 Hooks

Setup and Teardown

RSpec.describe 'Hooks example' do
  before(:suite) do
    puts "Before suite: executed once before entire test suite"
  end
  
  after(:suite) do
    puts "After suite: executed once after entire test suite"
  end
  
  before(:all) do
    puts "Before all: executed once before all examples in this describe block"
    @shared_data = "shared data"
  end
  
  after(:all) do
    puts "After all: executed once after all examples in this describe block"
  end
  
  before(:each) do
    puts "Before each: executed before each example"
    @user = User.new(name: 'Test User')
  end
  
  after(:each) do
    puts "After each: executed after each example"
    # Cleanup processing
  end
  
  around(:each) do |example|
    puts "Around start"
    example.run  # Execute test
    puts "Around end"
  end
  
  it 'first example' do
    expect(@user.name).to eq('Test User')
  end
  
  it 'second example' do
    expect(@shared_data).to eq('shared data')
  end
end

let and subject

Efficient Setup with Lazy Evaluation

RSpec.describe User do
  # Regular let (lazy evaluation)
  let(:user) { User.new(name: 'John', email: '[email protected]') }
  
  # let! (immediate evaluation)
  let!(:admin) { User.create(name: 'Admin', role: 'admin') }
  
  # subject (definition of test target)
  subject { user.full_profile }
  
  # Named 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

Mocks and Stubs

RSpec Mock Functionality

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

Spies and Doubles

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

Exception and Error Handling

Testing Exceptions

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

Shared Examples

Code Reuse

# 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

# Usage examples
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

Factories and Fixtures

Integration with 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

Custom Matchers

Creating Custom Matchers

# 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

# Usage examples
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