RSpec Testing Basics
RSpec is Ruby's dominant behavior-driven development (BDD) testing framework, used to describe expected behavior in a readable, English-like syntax that still executes as real, precise test code. Rather than writing assertEqual(a, b) as in xUnit-style frameworks, RSpec expresses intent with expect(a).to eq(b) — a small but meaningful shift toward specifications that read like documentation of what the code should do. It's distributed as a set of gems (rspec-core, rspec-expectations, rspec-mocks) and is typically added to a project via Bundler and configured with a spec_helper.rb or rails_helper.rb.
Cricket analogy: RSpec's expect(a).to eq(b) reads like a commentator narrating expected outcomes rather than a scorer's raw tally sheet — similar to describing 'the batter should reach fifty' as readable prose instead of just logging a number, gems bundled via Bundler like kit in a cricket bag.
describe, context, and it blocks
describe groups examples around a class, method, or feature; context is a semantic alias for describe conventionally used to group examples under a particular condition or state ("when the account is empty", "with invalid input"). Each individual test lives inside an it block, whose string argument should read naturally after "it" — together they form a sentence describing behavior.
Cricket analogy: describe groups examples around a specific batter or bowler, context narrows to a game state like 'when chasing under pressure', and each it block reads as a sentence, like 'it hits a boundary off the last ball'.
# spec/account_spec.rb
require "account"
RSpec.describe Account do
describe "#withdraw" do
context "when the balance is sufficient" do
it "reduces the balance by the withdrawn amount" do
account = Account.new(100)
account.withdraw(30)
expect(account.balance).to eq(70)
end
end
context "when the balance is insufficient" do
it "raises InsufficientFundsError" do
account = Account.new(10)
expect { account.withdraw(50) }.to raise_error(InsufficientFundsError)
end
end
end
endNotice expect { ... }.to raise_error(...) uses a block, while expect(account.balance).to eq(70) uses a value directly — RSpec requires the block form specifically for matchers that need to observe an action happening (raising an error, changing a value) rather than inspecting a value that already exists.
let, let!, and before hooks
Repeating setup code (like account = Account.new(100)) across many examples is tedious and error-prone. let(:name) { ... } defines a memoized helper method: the block runs lazily, only the first time name is referenced in a given example, and its value is cached for the rest of that example. before(:each) (the default scope) runs a block before every example in its scope, useful for side-effecting setup rather than defining a value. let! forces the let block to run eagerly before each example, useful when the setup has necessary side effects even if the example itself never directly references the let-defined name.
Cricket analogy: Instead of re-padding up before every net session, let(:account) is like a coach setting up practice gear lazily only when a player actually steps up to bat, memoized for that session, while let! sets it up eagerly before every drill regardless.
RSpec.describe Account do
let(:account) { Account.new(100) }
before do
allow(Logger).to receive(:info) # stub out logging noise
end
it "starts with the given balance" do
expect(account.balance).to eq(100)
end
it "allows a valid withdrawal" do
account.withdraw(40)
expect(account.balance).to eq(60)
end
endlet is memoized per example, not shared across examples — each it block gets a completely fresh evaluation of the let block, so mutating the object in one test never leaks into another. Relying on shared mutable state between examples (e.g. via a global variable or class-level instance variable) instead of let is a common source of flaky, order-dependent test suites.
Common matchers
RSpec ships a large library of matchers beyond eq: be compares object identity, include checks membership in a collection or substring in a string, be_truthy/be_falsey check general truthiness rather than exact equality, change { ... }.by(n) asserts a numeric delta, and raise_error asserts an exception class (and optionally message) was raised. Custom matchers can also be defined for domain-specific assertions.
Cricket analogy: change { ... }.by(n) is like checking a batter's score rose by exactly six runs after one shot, while include checks if a player's name is in the squad list and raise_error checks that a no-ball was correctly called.
expect([1, 2, 3]).to include(2)
expect("hello world").to include("world")
expect(nil).to be_falsey
expect { account.withdraw(10) }.to change { account.balance }.by(-10)- RSpec expresses tests as readable specifications using describe/context/it, backed by exact, executable expectations.
- expect(value).to matcher(...) checks a value directly; expect { block }.to matcher(...) is required for matchers observing an action (raising, changing).
- let(:name) { ... } lazily memoizes a helper value per example; let! forces it to run eagerly before the example.
- before(:each) runs setup code before every example in its scope, distinct from let which defines a named, lazy value.
- let-defined values are fresh for every example, preventing state leakage between tests — unlike shared instance/global variables.
- RSpec's matcher library (eq, include, change, raise_error, be_truthy, etc.) covers most common assertion needs out of the box.
Practice what you learned
1. What is the semantic difference between describe and context in RSpec?
2. Why does `expect { account.withdraw(50) }.to raise_error(InsufficientFundsError)` use a block instead of a plain value?
3. How does let(:account) { Account.new(100) } behave across multiple examples in the same describe block?
4. What is the key difference between let and let!?
5. What does `expect { account.withdraw(10) }.to change { account.balance }.by(-10)` assert?
Was this page helpful?
You May Also Like
Bundler and Gems
Understand how RubyGems packages and distributes Ruby libraries, and how Bundler locks dependency versions with a Gemfile and Gemfile.lock for reproducible builds.
Custom Exceptions in Ruby
Learn to design your own exception classes by subclassing StandardError, giving callers precise, catchable error types instead of generic RuntimeErrors or string messages.
Exception Handling in Ruby
Understand Ruby's begin/rescue/ensure/raise vocabulary for handling errors gracefully, including exception hierarchies, retry, and best practices for robust code.
Blocks, Procs, and Lambdas
Explore Ruby's three flavors of closures -- blocks, Procs, and lambdas -- and how they differ in argument strictness, return semantics, and reusability.
Related Reading
Related Study Notes in Programming
Browse all study notesApache Spark Study Notes
Programming · 30 topics
ProgrammingApache Flink Study Notes
Programming · 30 topics
ProgrammingHadoop Study Notes
Programming · 30 topics
ProgrammingSnowflake Study Notes
Programming · 30 topics
ProgrammingApache Airflow Study Notes
Programming · 30 topics
Programmingdbt (Data Build Tool) Study Notes
Programming · 30 topics