RSpec
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
- Excellent readability: Natural language-like DSL makes test intentions clear
- BDD support: Complete support for behavior-driven development
- Rich matchers: Expressive matchers for various verification patterns
- Modular design: Flexible configuration allowing selection of only needed features
- Executable specifications: Tests simultaneously function as documentation
- Powerful mock functionality: Effective dependency management through test doubles
- Active community: Comprehensive documentation and community support
- IDE support: Rich support features in major IDEs
Disadvantages
- Learning cost: Understanding DSL-specific notation and concepts required
- Ruby only: Cannot be used with other programming languages
- Performance: May feel overhead for small-scale tests
- Configuration complexity: Configuration management can become complex in large projects
- 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