Testing with RSpec
RSpec is Ruby’s most popular testing framework. It uses a Behavior-Driven Development (BDD) style that makes your tests read like natural language specifications. In this guide, you’ll learn how to write clean, maintainable tests that give you confidence in your code.
Setting Up RSpec
First, add RSpec to your Gemfile:
group :test do
gem 'rspec', '~> 3.12'
end
Then run bundle install and initialize RSpec:
bundle exec rspec --init
This creates a .rspec file and a spec directory where your tests will live.
Your First Test
RSpec organizes tests around two core concepts: describe blocks (which group related tests) and it blocks (individual test cases). Let’s test a simple calculator class:
# spec/calculator_spec.rb
RSpec.describe Calculator do
describe '#add' do
it 'returns the sum of two numbers' do
calculator = Calculator.new
expect(calculator.add(2, 3)).to eq(5)
end
end
end
The describe method takes a class name or string description. The it method defines a single test case. The expect syntax reads naturally: “expect the result to equal 5.”
Matchers
Matchers are RSpec’s way of making assertions readable. Here are the most common ones:
Equality Matchers
expect(actual).to eq(expected) # value equality (==)
expect(actual).to eql(expected) # deep equality
expect(actual).to be(expected) # same object (equal?)
expect(actual).to match(/pattern/) # regex match
Truthiness Matchers
expect(value).to be_truthy # not nil or false
expect(value).to be_falsey # nil or false
expect(value).to be_nil # nil
expect(value).to be true # exact boolean
Collection Matchers
expect(array).to include(item)
expect(array).to contain_exactly(item1, item2)
expect(hash).to have_key(:key)
expect(hash).to have_value(value)
Predicate Matchers
expect(collection).to be_empty
expect(number).to be > 5
expect(object).to be_instance_of(Class)
expect(object).to be_a(Class)
Before and After Hooks
Hooks let you run code before or after each test:
RSpec.describe User do
before(:each) do
@user = User.create(name: 'Alice')
end
after(:each) do
User.delete_all
end
describe '#greet' do
it 'greets the user by name' do
expect(@user.greet).to include('Alice')
end
end
end
The :each symbol runs the hook for every test. Use :all to run once for the entire describe block:
before(:all) do
DatabaseCleaner.start
end
after(:all) do
DatabaseCleaner.clean
end
Let and Subject
The let method defines memoized helpers that lazy-load only when needed:
RSpec.describe Order do
let(:customer) { Customer.new(name: 'Bob') }
let(:order) { Order.new(customer: customer) }
it 'belongs to a customer' do
expect(order.customer).to eq(customer)
end
it 'calculates total correctly' do
order.add_item(Item.new(price: 10))
order.add_item(Item.new(price: 20))
expect(order.total).to eq(30)
end
end
let is lazy—it won’t be evaluated until you use it. This makes tests faster and more focused.
Use subject when the main object being tested is used repeatedly:
RSpec.describe Calculator do
subject { Calculator.new }
it 'adds two numbers' do
expect(subject.add(2, 3)).to eq(5)
end
it 'subtracts two numbers' do
expect(subject.subtract(5, 3)).to eq(2)
end
end
Testing Exceptions
Use raise_error to test that code raises exceptions:
RSpec.describe Calculator do
describe '#divide' do
it 'raises an error when dividing by zero' do
expect { subject.divide(10, 0) }.to raise_error(ZeroDivisionError)
end
it 'accepts custom error messages' do
expect { subject.divide(10, 0) }.to raise_error(/division/)
end
end
end
Mocking and Stubbing
Mocks replace real objects with test doubles:
RSpec.describe OrderProcessor do
let(:payment_gateway) { double('PaymentGateway') }
let(:processor) { OrderProcessor.new(payment_gateway) }
it 'charges the customer via the payment gateway' do
expect(payment_gateway).to receive(:charge).with(100)
processor.process(order_with_total_of(100))
end
end
Use allow for stubs that may or may not be called:
it 'logs when payment fails' do
allow(payment_gateway).to receive(:charge).and_raise(PaymentError)
expect(logger).to receive(:error).with('Payment failed')
processor.process(order)
end
Method Stubs
Stub methods directly on objects:
it 'sends an email notification' do
user = User.new(email: 'test@example.com')
allow(user).to receive(:send_welcome_email)
user.register
expect(user).to have_received(:send_welcome_email)
end
Shared Examples
Shared examples let you reuse test logic across multiple contexts:
RSpec.shared_examples 'a CRUD resource' do
describe '#create' do
it 'creates a new resource' do
expect(subject.create(valid_attributes)).to be_persisted
end
end
describe '#update' do
it 'updates existing resource' do
resource = subject.create(valid_attributes)
resource.update(name: 'New Name')
expect(resource.name).to eq('New Name')
end
end
end
RSpec.describe Post do
it_behaves_like 'a CRUD resource'
end
RSpec.describe Comment do
it_behaves_like 'a CRUD resource'
end
Test Organization Tips
Group tests logically by method, feature, or behavior. Use context to describe different scenarios:
RSpec.describe User do
describe '#authenticate' do
context 'with valid credentials' do
it 'returns the user' do
user = User.create(email: 'test@example.com', password: 'secret')
expect(User.authenticate('test@example.com', 'secret')).to eq(user)
end
end
context 'with invalid password' do
it 'returns false' do
user = User.create(email: 'test@example.com', password: 'secret')
expect(User.authenticate('test@example.com', 'wrong')).to be_falsey
end
end
end
end
When to Use RSpec
RSpec excels at testing complex business logic, domain models, and service objects. Its readable syntax makes tests serve as documentation. Use RSpec when:
- You’re building domain logic that needs clear specifications
- Your team values readable, self-documenting tests
- You need powerful mocking and stubbing capabilities
For simple scripts or quick validation, Ruby’s built-in MiniTest may be sufficient.
Summary
You now have the tools to write comprehensive RSpec tests. Remember to keep tests focused, use descriptive names, and leverage hooks and shared examples to reduce duplication. Good tests give you confidence to refactor and ship faster.