RSpec Getting Started: Installation, Structure, and Core Syntax
RSpec is the dominant testing framework in the Ruby ecosystem. It's been around since 2005 and has shaped how Ruby developers think about tests. Unlike Minitest, RSpec leans hard into a domain-specific language that reads close to plain English. Whether that's a feature or a liability depends on your team, but there's no denying that RSpec is the default choice for most Rails projects.
This post covers everything you need to get up and running: installation, project structure, the core DSL, expect syntax, matchers, and how to run your specs.
Installation
Add RSpec to your Gemfile:
group :test do
gem 'rspec', '~> 3.13'
endThen run:
bundle install
rspec --initrspec --init creates two files:
.rspec— default CLI flagsspec/spec_helper.rb— RSpec configuration
Your .rspec file will look like:
--require spec_helper
--format documentationThe --format documentation flag gives you human-readable output instead of dots. Useful during development, but slower. You can switch back to --format progress (the dot formatter) for CI runs.
For a Rails project, use the rspec-rails gem instead:
group :development, :test do
gem 'rspec-rails', '~> 6.1'
endThen:
rails generate rspec:installThis creates spec/spec_helper.rb, spec/rails_helper.rb, and .rspec.
Directory Structure
RSpec follows a convention: specs live in spec/ and mirror your lib/ or app/ structure.
my_project/
├── lib/
│ ├── user.rb
│ └── order.rb
├── spec/
│ ├── spec_helper.rb
│ ├── lib/
│ │ ├── user_spec.rb
│ │ └── order_spec.rb
│ └── support/
│ └── shared_examples.rbEvery spec file ends in _spec.rb. RSpec won't pick it up otherwise.
The Core DSL: describe, context, it
These three methods are the foundation of every RSpec test.
describe
describe defines an example group. It wraps a set of related tests. Pass it a class or a string:
describe User do
# tests about User go here
end
describe "authentication" do
# tests about authentication
endWhen you pass a class, RSpec uses it as the implicit subject (more on that later).
context
context is an alias for describe. The convention is: use describe for the thing you're testing, and context for the conditions under which you're testing it.
describe User do
context "when the user is an admin" do
# ...
end
context "when the user is a guest" do
# ...
end
endThis distinction is purely stylistic — RSpec treats them identically — but it makes test output much more readable.
it
it defines an example (a single test). The string argument describes what should happen:
describe User do
it "returns the full name" do
user = User.new(first_name: "Alice", last_name: "Smith")
expect(user.full_name).to eq("Alice Smith")
end
endYou'll also see specify as an alias for it, useful when the description would be grammatically awkward with "it" in front.
The expect Syntax
RSpec uses expect(...).to and expect(...).not_to for assertions:
expect(actual).to eq(expected)
expect(actual).not_to eq(unexpected)The older should syntax still works but is deprecated for most use cases. Stick with expect.
Matchers
Matchers are the right-hand side of an expectation. RSpec ships with a large standard library of them.
Equality
expect(2 + 2).to eq(4) # value equality (==)
expect("hello").to eql("hello") # stricter equality (eql?)
expect(obj).to equal(same_obj) # object identity (equal?)Truthiness
expect(value).to be_truthy
expect(value).to be_falsy
expect(value).to be_nil
expect(value).not_to be_nil
expect(value).to be true # must be exactly true
expect(value).to be false # must be exactly falseComparisons
expect(10).to be > 5
expect(10).to be >= 10
expect(10).to be < 20
expect(10).to be_between(5, 15).inclusiveString matchers
expect("hello world").to include("world")
expect("hello").to start_with("hel")
expect("hello").to end_with("llo")
expect("hello123").to match(/\d+/)Collection matchers
expect([1, 2, 3]).to include(2)
expect([1, 2, 3]).to include(1, 3)
expect([1, 2, 3]).to contain_exactly(3, 1, 2) # order doesn't matter
expect([1, 2, 3]).to match_array([3, 2, 1]) # same as contain_exactly
expect([]).to be_empty
expect([1, 2, 3]).to have_attributes(length: 3)Type matchers
expect("hello").to be_a(String)
expect(42).to be_an(Integer)
expect([]).to be_an(Array)
expect(user).to be_a(User)
expect(user).to be_kind_of(User)
expect(user).to be_instance_of(User) # exact class, no inheritancePredicate matchers
RSpec auto-generates matchers for any predicate method (methods ending in ?):
expect(user).to be_admin # calls user.admin?
expect(array).to be_empty # calls array.empty?
expect(string).to be_frozen # calls string.frozen?
expect(hash).to have_key(:id) # calls hash.has_key?(:id)Change matcher
One of RSpec's most useful matchers — tests that something changes as a result of an action:
expect { user.save }.to change { User.count }.by(1)
expect { user.save }.to change { User.count }.from(0).to(1)
expect { order.cancel }.to change(order, :status).from("pending").to("cancelled")raise_error matcher
expect { divide(1, 0) }.to raise_error(ZeroDivisionError)
expect { divide(1, 0) }.to raise_error(ZeroDivisionError, "divided by 0")
expect { fetch_user(nil) }.to raise_error(ArgumentError, /invalid id/)have_received (for mocking — preview)
expect(mailer).to have_received(:send_email).with("alice@example.com")We'll cover mocking in depth in the next post.
Hooks: before and after
Run setup and teardown code around your examples:
describe User do
before(:each) do # runs before every example in this group
@user = User.new(name: "Alice")
end
after(:each) do # runs after every example
# cleanup
end
before(:all) do # runs once before all examples in this group
# expensive setup
end
it "has a name" do
expect(@user.name).to eq("Alice")
end
endIn practice, before(:each) (also written as before) is by far the most common. before(:all) is useful for expensive setup like starting a database, but be careful — state leaks between examples.
let and let!
let is the preferred way to define helper values in RSpec. It's lazy — the block only runs when the value is first accessed in an example:
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
endlet! is eager — it runs before every example regardless of whether you reference it:
let!(:user) { User.create!(name: "Alice") }Use let! when the side effect matters (like inserting into a database), not just the return value.
subject
When you pass a class to describe, RSpec sets an implicit subject:
describe User do
it { is_expected.to respond_to(:full_name) }
endis_expected is shorthand for expect(subject). You can also define subject explicitly:
describe User do
subject(:user) { User.new(name: "Alice") }
it "responds to full_name" do
expect(user).to respond_to(:full_name)
end
endRunning Specs
Run the full suite:
bundle exec rspecRun a single file:
bundle exec rspec spec/lib/user_spec.rbRun a specific example by line number:
bundle exec rspec spec/lib/user_spec.rb:42Run examples matching a description:
bundle exec rspec --example "returns the full name"Run only failed examples from the last run (requires --format json or persistent formatter):
bundle exec rspec --only-failuresTo enable --only-failures, add this to spec_helper.rb:
RSpec.configure do |config|
config.example_status_persistence_file_path = ".rspec_status"
endPending and Skipped Examples
Mark an example as pending:
it "does something not yet implemented" do
pending "not built yet"
expect(something).to eq(something_else)
endPending examples show up in output but don't fail the suite. If a pending example passes (the assertion succeeds), RSpec warns you — the pending annotation is no longer needed.
Skip an example entirely:
xit "is skipped" do
# never runs
end
xdescribe "skipped group" do
# nothing in here runs
endTagging Examples
You can tag examples and run only tagged subsets:
it "is slow", :slow do
# ...
end
describe "integration tests", :integration do
# ...
endRun only tagged examples:
bundle exec rspec --tag slow
bundle exec rspec --tag ~slow # exclude slow testsConfigure tags globally in spec_helper.rb:
RSpec.configure do |config|
config.filter_run_when_matching :focus
config.run_all_when_everything_filtered = true
endThen use fit or :focus to temporarily focus on one test:
fit "I'm focused" do
# only this runs
endA Complete Example
Putting it all together — a spec for a simple BankAccount class:
# spec/lib/bank_account_spec.rb
require 'spec_helper'
require 'bank_account'
describe BankAccount do
subject(:account) { BankAccount.new(balance: 100) }
describe "#deposit" do
context "with a positive amount" do
it "increases the balance" do
expect { account.deposit(50) }.to change { account.balance }.by(50)
end
end
context "with a negative amount" do
it "raises ArgumentError" do
expect { account.deposit(-10) }.to raise_error(ArgumentError, "amount must be positive")
end
end
end
describe "#withdraw" do
context "when funds are sufficient" do
it "decreases the balance" do
expect { account.withdraw(30) }.to change { account.balance }.from(100).to(70)
end
end
context "when funds are insufficient" do
it "raises InsufficientFundsError" do
expect { account.withdraw(200) }.to raise_error(BankAccount::InsufficientFundsError)
end
it "does not change the balance" do
expect { account.withdraw(200) rescue nil }.not_to change { account.balance }
end
end
end
endThis structure — describe the class, describe each method, context for conditions, it for outcomes — is the standard RSpec pattern. Follow it consistently and your test output becomes self-documenting.
What's Next
RSpec's core DSL is intentionally small. The real power comes from mocking and stubbing (for isolating units), shared examples (for DRY test suites), and the Rails-specific helpers that tie everything together. The next post in this series covers doubles, allow, expect, and verifying doubles in depth.