RSpec Tutorial: Getting Started with Ruby Testing

RSpec Tutorial: Getting Started with Ruby Testing

RSpec is the most widely used testing framework for Ruby. Its expressive syntax reads almost like natural language, which makes test suites easier to understand and maintain. This tutorial covers the core building blocks: describe, context, it, expect, and matchers.

Installing RSpec

Add RSpec to your Gemfile:

group :test do
  gem 'rspec'
end

Run bundle install, then initialize RSpec in your project:

bundle exec rspec --init

This creates .rspec (configuration file) and spec/spec_helper.rb.

For a standalone Ruby project, you can also install globally: gem install rspec.

Your First Spec

Create a file spec/calculator_spec.rb:

require 'spec_helper'
require_relative '../calculator'

RSpec.describe Calculator do
  describe '#add' do
    it 'returns the sum of two numbers' do
      calc = Calculator.new
      expect(calc.add(2, 3)).to eq(5)
    end
  end
end

And the implementation calculator.rb:

class Calculator
  def add(a, b)
    a + b
  end
end

Run it:

bundle exec rspec spec/calculator_spec.rb

The Core Structure

describe

describe groups related specs. You typically pass a class or a method name:

RSpec.describe Calculator do
  # specs for Calculator class
end

Nested describe blocks let you group by method:

RSpec.describe Calculator do
  describe '#add' do
    # specs for the add method
  end

  describe '#subtract' do
    # specs for the subtract method
  end
end

By convention, prefix instance methods with # and class methods with ..

context

context is an alias for describe, but it reads better when describing conditions:

RSpec.describe Calculator do
  describe '#divide' do
    context 'when divisor is zero' do
      it 'raises a ZeroDivisionError' do
        calc = Calculator.new
        expect { calc.divide(10, 0) }.to raise_error(ZeroDivisionError)
      end
    end

    context 'when divisor is non-zero' do
      it 'returns the correct quotient' do
        calc = Calculator.new
        expect(calc.divide(10, 2)).to eq(5)
      end
    end
  end
end

Good context names start with "when" or "with" to describe a state.

it

it defines a single example (a test). The string after it describes what the code should do:

it 'returns true when the user is active' do
  # test code
end

Keep it blocks focused on one behavior. If you have multiple unrelated assertions in one it, split them.

expect

expect is the assertion mechanism. The basic form:

expect(actual).to matcher
expect(actual).not_to matcher

Core Matchers

RSpec ships with a rich set of matchers.

Equality:

expect(result).to eq(5)        # value equality
expect(result).to be(object)   # object identity (same reference)
expect(result).to eql(5.0)     # type-sensitive equality

Truthiness:

expect(result).to be_truthy
expect(result).to be_falsy
expect(result).to be_nil
expect(result).not_to be_nil

Comparisons:

expect(score).to be > 90
expect(score).to be >= 80
expect(score).to be_between(1, 100).inclusive

Strings:

expect(message).to include('error')
expect(message).to start_with('Error:')
expect(message).to end_with('!')
expect(message).to match(/^\d{3}-\d{4}$/)

Collections:

expect(array).to include(3)
expect(array).to contain_exactly(1, 2, 3)  # order-independent
expect(array).to match_array([3, 1, 2])    # same as contain_exactly
expect(array).to have_attributes(length: 3)

Exceptions:

expect { risky_method }.to raise_error(RuntimeError)
expect { risky_method }.to raise_error(RuntimeError, 'message')
expect { risky_method }.not_to raise_error

Predicate matchers:

RSpec auto-generates matchers from predicate methods. If an object has a valid? method:

expect(user).to be_valid
expect(user).not_to be_valid

Any method ending in ? works this way: be_empty, be_frozen, be_nil, etc.

let and let!

Avoid repeating setup code with let:

RSpec.describe User do
  let(:user) { User.new(name: 'Alice', email: 'alice@example.com') }

  it 'has a name' do
    expect(user.name).to eq('Alice')
  end

  it 'has an email' do
    expect(user.email).to eq('alice@example.com')
  end
end

let is lazy — the block runs only when user is first referenced. let! is eager — runs before each example:

let!(:user) { User.create!(name: 'Alice') }

Use let! when you need the side effect (like a database record) to exist even if the example doesn't reference the variable directly.

before and after Hooks

before runs setup code before each example:

RSpec.describe Order do
  before(:each) do
    @order = Order.new
    @order.add_item(Item.new(price: 10))
  end

  it 'calculates total' do
    expect(@order.total).to eq(10)
  end
end

before(:all) (or before(:context)) runs once per describe block — use sparingly as it can cause test pollution.

after is the cleanup counterpart:

after(:each) do
  # cleanup
end

subject

subject defines the object under test. RSpec auto-sets it to an instance of the described class:

RSpec.describe Calculator do
  # subject is automatically Calculator.new

  it { is_expected.to respond_to(:add) }
end

You can override subject explicitly:

RSpec.describe User do
  subject { User.new(name: 'Bob', active: true) }

  it { is_expected.to be_active }
end

Running RSpec

Run all specs:

bundle exec rspec

Run a specific file:

bundle exec rspec spec/calculator_spec.rb

Run a specific line:

bundle exec rspec spec/calculator_spec.rb:15

Run with documentation format:

bundle exec rspec --format documentation

.rspec Configuration

Your .rspec file sets default flags:

--require spec_helper
--format documentation
--color

Organizing Specs

Mirror your lib/ or app/ structure in spec/:

lib/
  calculator.rb
  user.rb
spec/
  calculator_spec.rb
  user_spec.rb
  spec_helper.rb

In Rails, use spec/models/, spec/controllers/, spec/requests/, etc.

Best Practices

Name your examples clearly. The output of rspec --format documentation should read like a specification document.

One assertion per example. Focused tests are easier to debug when they fail.

Use context for branching. Each branch in your code (if/else, raise/return) deserves its own context.

Avoid before(:all). Shared mutable state across examples is a source of flaky tests.

Don't over-mock. Test real behavior where possible. Mock external services and I/O, not your own code.

What's Next

Once you're comfortable with the basics, explore:

  • Mocking and stubbing with double, allow, and expect
  • Shared examples with shared_examples_for
  • Custom matchers for domain-specific assertions
  • RSpec Rails for model, request, and system specs

RSpec's design philosophy — readable specs that serve as documentation — pays off as your project grows. Small investment in test clarity upfront, large dividends when something breaks.

Read more