Testing RSpec in Ruby: Practical BDD Tutorial
Before you start
Testing RSpec is shorthand for “writing specs in Ruby’s most popular testing framework.” RSpec uses a Behaviour-Driven Development (BDD) style that lets your tests read almost like natural-language specifications, which is what makes a healthy spec/ directory so pleasant to come back to weeks later. This tutorial shows the parts you’ll touch every day when testing RSpec projects.
You’ll learn how to install RSpec, organise files under spec/, write describe and context blocks, use built-in matchers, run shared setup with before hooks and let, and stub or mock collaborators. For broader Ruby testing context, see the getting started with Ruby tutorial and the Ruby blocks, procs, and lambdas guide.
If you are new to RSpec, the main habit to build is writing one example at a time. Start with the smallest behavior you can describe, then make the setup just large enough to support that behavior. When the example fails, the name of the spec should tell you where to look and the expectation should tell you what changed.
That approach keeps the suite readable as it grows. Instead of writing a single large test that tries to prove everything at once, you end up with a set of small checks that are easier to debug and easier to delete when the code changes. In practice, that means fewer surprises when you run the suite after a refactor.
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. The generated spec_helper.rb includes RSpec’s default configuration, and you can extend it with project-specific helpers like database cleaners or factory setup. The .rspec file controls RSpec’s command-line behavior, such as whether to show colours and which format to use for output.
RSpec also expects a little shared configuration in most projects, usually in spec/spec_helper.rb or spec/rails_helper.rb. Those files are the right place for global settings, custom matchers, and project-specific defaults. Centralizing that setup keeps the example files short, which is important because the more setup you repeat, the harder it becomes to see the actual behavior under test. Once the configuration is in place, you can start writing examples that focus on the behaviour itself rather than the ceremony of setting up test data. A well-configured spec_helper.rb also lets you require support files once, so every spec file starts clean without repeating the same require statements.
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
When you choose a matcher, think about the failure message you want to read later. eq is ideal when you care about value equality, while be is better for identity checks and booleans. For collections, include answers a membership question and contain_exactly answers a full-membership question.
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
Truthiness matchers are small but easy to misuse. be_truthy passes for any value except nil and false, which is usually too loose for precise tests. Prefer be true or be false when you care about the exact boolean value, and reserve be_truthy for cases where any present value is acceptable.
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)
Collection matchers help you check membership without reaching for loops or manual array comparisons. include works well for checking that a single item is present, while contain_exactly verifies the entire collection matches a set of expected items regardless of order.
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)
When you choose a matcher, think about the failure message you want to read later. eq is ideal when you care about value equality, while be is better for identity checks and booleans. For collections, include answers a membership question and contain_exactly answers a full-membership question. That distinction matters because the wrong matcher can make a passing test too loose or a failing test too noisy.
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, which keeps each example isolated but adds a small setup cost per test. For most unit tests, :each is the right default because you want a clean slate. For expensive setup that does not mutate between examples, switch to :all to save time:
before(:all) do
DatabaseCleaner.start
end
after(:all) do
DatabaseCleaner.clean
end
The before(:all) and after(:all) hooks are useful for expensive setup like loading a large fixture file or starting an external service. Use them sparingly because state shared across examples can lead to order-dependent test failures that are hard to debug. When in doubt, before(:each) is safer.
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 will not be evaluated until you reference it in an example. This makes tests faster and more focused because setup only runs for the examples that actually need it. A good rule is to reach for let when multiple examples share the same starting data and before hooks when you need side effects like database writes or file creation.
Use subject when the main object being tested is used repeatedly across several examples:
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
subject gives you a default test object that RSpec can refer to with is_expected syntax. It works best for simple classes where the constructor takes no arguments or the arguments are constant. For objects that need dynamic construction, let with an explicit name is usually clearer.
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
Testing exceptions with raise_error is essential for validating error paths. Always test both the exception class and the message pattern when the message carries important context. The block form expect { }.to raise_error(...) is required because the assertion needs to intercept the exception before it propagates.
Mocking and stubbing
Mocks replace real objects with test doubles so you can isolate the code under test from its dependencies:
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
Use expect(...).to receive(...) when the interaction with the dependency is part of what you are testing. The test will fail if the expected method is never called, which makes this pattern a good fit for verifying side effects like sending an email or logging a message. Use allow(...).to receive(...) when the stub is just setup and you do not care whether it is called.
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
Stubbing individual methods on real objects is a lightweight alternative to full test doubles. It is most useful when you only need to replace one or two methods on an otherwise real object, such as stubbing an external API call while keeping the rest of the object’s behaviour intact. The have_received matcher combined with allow gives you a clean way to verify that a stubbed method was called, without making the test fail if it was not called at all. This pattern works well for notification methods, logging calls, and other side effects where you want to confirm the interaction happened without controlling exactly how the stubbed method behaves internally.
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
Shared examples shine when multiple classes implement the same interface and should behave identically. Instead of copying the same expectations into each spec file, you define the shared behaviour once and include it wherever it applies. This pattern is especially useful for testing polymorphic relationships or serializers.
A practical testing rhythm
Most teams settle into a small routine when they write RSpec examples. They arrange the object under test, prepare only the data they need, and then make one clear expectation. That rhythm is worth practicing because it keeps the file easy to scan when you return to it next week. It also helps you avoid hidden dependencies, like a let helper that does more work than the example really needs.
If a spec starts to feel awkward, that is often a sign the production code needs a little shaping too. A method that is hard to test is usually doing too many things, or it hides its dependencies behind long chains of setup. RSpec can reveal that smell early, which is one reason it is so useful for refactoring confidence.
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
Good RSpec files usually follow the same rhythm: define the subject, prepare only the data you need, and keep each expectation narrow. When a spec fails, you want the failure to point to one behavior instead of a chain of unrelated setup. That discipline is what keeps the suite useful after the first few weeks of growth.
Once the basics feel natural, move on to Rails request specs and model specs so the same habits apply to real app code. The Rails getting started tutorial is a good next stop if you want to see how RSpec fits into the larger Rails workflow. That context helps because the framework stops feeling like a testing island and starts feeling like part of the whole application structure.
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 use hooks and shared examples to reduce duplication. Good tests give you confidence to refactor and ship faster.
Where to go next
With the basics of testing RSpec covered, look at how the framework fits into a real Ruby workflow: the getting started with Ruby tutorial covers the wider language toolchain, while the Ruby benchmark guide shows how to validate performance after refactors. For Rails-specific specs, see the Rails getting started tutorial.