A Guide to Writing RSpec Tests in Ruby

Jan 15, 2018

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

  1. Use Descriptive Names
# Bad
it "works" do
  # test code
end

# Good
it "calculates total price including tax" do
  # test code
end
  1. 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
  1. 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.

Mirzalazuardi Hermawan