RSpec is the most popular testing framework in Ruby. Let's explore how to write clear, maintainable tests using RSpec's best practices and conventions.
Basic Structure
File Organization
# spec directory structure
spec/
├── models/
│ └── user_spec.rb
├── services/
│ └── payment_service_spec.rb
├── controllers/
│ └── users_controller_spec.rb
└── spec_helper.rb
Basic Example
RSpec.describe User do
let(:user) { User.new(name: "John", email: "john@example.com") }
describe "#full_name" do
it "returns the capitalized full name" do
expect(user.full_name).to eq("John")
end
end
end
Let and Subject
RSpec.describe Order do
# Use let for variables that are reused
let(:product) { Product.new(name: "Book", price: 20) }
let(:user) { User.new(name: "John") }
# let! forces the variable to be created immediately
let!(:order) { Order.new(user: user, product: product) }
# Use subject when testing the main object
subject { described_class.new(user: user, product: product) }
describe "#total_price" do
it "calculates the total price with tax" do
expect(subject.total_price).to eq(24) # assuming 20% tax
end
end
end
Context and Describe Blocks
RSpec.describe PaymentService do
describe "#process_payment" do
let(:payment) { Payment.new(amount: 100) }
context "when payment is successful" do
it "returns true" do
expect(subject.process_payment(payment)).to be true
end
it "updates payment status" do
subject.process_payment(payment)
expect(payment.status).to eq("completed")
end
end
context "when payment fails" do
before do
allow(payment).to receive(:process).and_return(false)
end
it "returns false" do
expect(subject.process_payment(payment)).to be false
end
it "sets error message" do
subject.process_payment(payment)
expect(payment.error_message).not_to be_nil
end
end
end
end
Matchers
Boolean Matchers
RSpec.describe User do
let(:user) { User.new }
describe "#admin?" do
context "with admin role" do
before { user.role = "admin" }
it { is_expected.to be_admin } # tests user.admin?
end
context "with regular role" do
before { user.role = "regular" }
it { is_expected.not_to be_admin }
end
end
end
Comparison Matchers
RSpec.describe Product do
let(:product) { Product.new(price: 100) }
describe "#discounted_price" do
it "applies 20% discount" do
expect(product.discounted_price).to eq(80)
expect(product.discounted_price).to be < product.price
expect(product.discounted_price).to be_between(70, 90)
end
end
end
Collection Matchers
RSpec.describe ShoppingCart do
let(:cart) { ShoppingCart.new }
let(:product) { Product.new(name: "Book") }
describe "#add_item" do
before { cart.add_item(product) }
it "includes the item" do
expect(cart.items).to include(product)
expect(cart.items).not_to be_empty
expect(cart.items).to have_exactly(1).items
end
end
end
Mocking and Stubbing
Method Stubs
RSpec.describe OrderProcessor do
let(:payment_gateway) { double("PaymentGateway") }
let(:order) { Order.new(amount: 100) }
before do
allow(payment_gateway).to receive(:charge).and_return(true)
end
it "processes payment" do
processor = OrderProcessor.new(payment_gateway)
expect(processor.process(order)).to be true
end
end
Mock Expectations
RSpec.describe OrderProcessor do
let(:payment_gateway) { double("PaymentGateway") }
let(:order) { Order.new(amount: 100) }
it "charges correct amount" do
expect(payment_gateway).to receive(:charge)
.with(amount: 100, currency: "USD")
.once
.and_return(true)
processor = OrderProcessor.new(payment_gateway)
processor.process(order)
end
end
Custom Matchers
RSpec::Matchers.define :be_recent do
match do |actual|
actual > 5.minutes.ago
end
failure_message do |actual|
"expected #{actual} to be within last 5 minutes"
end
end
RSpec.describe Post do
let(:post) { Post.new(created_at: Time.now) }
it "is recent" do
expect(post.created_at).to be_recent
end
end
Shared Examples
RSpec.shared_examples "a purchasable item" do
it { is_expected.to respond_to(:price) }
it { is_expected.to respond_to(:purchase) }
describe "#purchase" do
it "decrements stock" do
expect { subject.purchase }.to change { subject.stock }.by(-1)
end
end
end
RSpec.describe Book do
it_behaves_like "a purchasable item"
# Additional Book-specific tests...
end
RSpec.describe Movie do
it_behaves_like "a purchasable item"
# Additional Movie-specific tests...
end
Testing Exceptions
RSpec.describe Calculator do
describe "#divide" do
context "when dividing by zero" do
it "raises an error" do
expect { subject.divide(10, 0) }.to raise_error(ZeroDivisionError)
end
it "raises error with specific message" do
expect { subject.divide(10, 0) }
.to raise_error(ZeroDivisionError, "Cannot divide by zero")
end
end
end
end
Before and After Hooks
RSpec.describe Database do
before(:all) do
# Runs once before all tests in this describe block
Database.connect
end
after(:all) do
# Runs once after all tests in this describe block
Database.disconnect
end
before(:each) do
# Runs before each test
Database.clean
end
after(:each) do
# Runs after each test
Database.reset
end
it "performs database operations" do
# Test code
end
end
Testing Asynchronous Code
RSpec.describe AsyncJob do
describe "#perform" do
it "completes the job", :async do
job = AsyncJob.new
expect { job.perform }
.to change { job.status }
.from("pending")
.to("completed")
.within(5.seconds)
end
end
end
Best Practices
- Use Descriptive Names
# Bad
it "works" do
# test code
end
# Good
it "calculates total price including tax" do
# test code
end
- One Expectation Per Test
# Bad
it "creates user" do
user = User.create(params)
expect(user).to be_valid
expect(user.name).to eq("John")
expect(user.email).to eq("john@example.com")
end
# Good
describe "user creation" do
let(:user) { User.create(params) }
it "is valid" do
expect(user).to be_valid
end
it "sets the correct name" do
expect(user.name).to eq("John")
end
it "sets the correct email" do
expect(user.email).to eq("john@example.com")
end
end
- Use Context for Different Scenarios
RSpec.describe OrderProcessor do
describe "#process" do
context "with valid payment" do
# tests for valid payment
end
context "with invalid payment" do
# tests for invalid payment
end
context "when service is unavailable" do
# tests for service unavailability
end
end
end
This guide covers the main aspects of writing RSpec tests. Remember that good tests are readable, maintainable, and reliable. Follow these patterns and practices to create a robust test suite for your Ruby applications.