Testing with RSpec

Ruby Intermediate/Advanced — Testing with RSpec
Ruby Intermediate/Advanced
Course 2 · Chapter 6 · Testing with RSpec

🧪 Testing with RSpec

RSpec is the de facto standard Ruby testing framework — not part of the standard library, but close to universal in real Ruby and Rails projects (installing it is exactly what Chapter 7's gems and Bundler chapter covers next). Its entire syntax is built from the blocks, yield, and DSL-building techniques this course has covered since Course 1 Chapter 8 — a full-circle payoff worth noticing as you read through it.

📁 describe and it — the Basic Structure

# spec/calculator_spec.rb describe Calculator do it "adds two numbers correctly" do calc = Calculator.new expect(calc.add(2, 3)).to eq(5) end it "subtracts two numbers correctly" do calc = Calculator.new expect(calc.subtract(10, 4)).to eq(6) end end

Spec files live in a spec/ directory and end in _spec.rb by convention. describe groups related examples (typically around a class or a feature); each it block is one individual test case with a plain-English description. Both describe and it are just methods that take a block — nothing more exotic than that, though it reads almost like a small custom language.

✅ expect().to — Expectations and Matchers

expect(5).to eq(5) # exact equality expect(nil).to be_nil # specific matcher for nil expect([1,2,3]).to include(2) # collection membership expect("ruby").to start_with("ru") # string matcher expect { 1 / 0 }.to raise_error(ZeroDivisionError) # wraps in a block -- must be lazy to catch the raise

The expect(...).to matcher phrasing is RSpec's core idiom, deliberately readable as close-to-English as Ruby syntax allows. Note the last example wraps the risky code in a block rather than evaluating it directly — raise_error needs to control exactly when the code runs so it can catch the exception itself.

🔄 before Hooks — Shared Setup

describe ShoppingCart do before(:each) do @cart = ShoppingCart.new # runs fresh before EVERY example below end it "starts empty" do expect(@cart.items).to be_empty end it "adds an item" do @cart.add("Widget") expect(@cart.items.count).to eq(1) end end

before(:each) runs before every it block in its describe, giving each test a fresh @cart instead of one shared across examples (which would let one test's state leak into another). This plays the same role as PHPUnit's setUp() method or a pytest fixture — different mechanism, same underlying purpose.

🎭 Mocks and Stubs

Real tests often need to isolate the code under test from something slow, unreliable, or external — a network call, a database, another class entirely. RSpec's doubles (fake objects) and stubs (fake method responses) handle this:

describe WeatherReporter do it "reports sunny weather" do fake_api = double("WeatherAPI") allow(fake_api).to receive(:current_conditions).and_return("sunny") # a stub reporter = WeatherReporter.new(fake_api) expect(reporter.report).to eq("It's sunny today!") end it "calls the API exactly once" do fake_api = double("WeatherAPI") expect(fake_api).to receive(:current_conditions).and_return("rainy") # a mock -- verifies the call happens WeatherReporter.new(fake_api).report end end

allow().to receive() — a stub

Configures what a method returns if it's called, without requiring that it be called at all. Use this to control a fake dependency's behavior so the code under test has something predictable to work with.

expect().to receive() — a mock

Asserts the method must be called during the test, and fails the test if it never is. Use this specifically when the interaction itself — "did we actually call the API?" — is what you're testing, not just the resulting value.

PHPUnit reaches for the separate Mockery library (or its own built-in mock objects) for this; pytest uses unittest.mock's Mock/patch. RSpec bakes the equivalent capability directly into the core framework rather than requiring a separate library.

📜 Testing Frameworks, Side by Side

PHPUnit vs pytest vs RSpec

ConceptPHPUnitpytestRSpec
Group testsA test class extending TestCaseA file/class of test_ functionsdescribe block
One test caseA method starting with testA function starting with test_it block
Assertion style$this->assertEquals(5, $result)assert result == 5expect(result).to eq(5)
Shared setupsetUp() method@pytest.fixturebefore(:each) block
MockingMockery (separate library)unittest.mock built indouble / allow / expect built in

💻 Coding Challenges

Challenge 1: Your First Spec File

Given a simple StringFormatter class with a method shout(text) that returns the text uppercased with an exclamation mark appended, write a describe/it spec with two examples: one confirming correct output for a normal string, one confirming it works on an already-uppercase string.

Goal: Write your first working RSpec examples using expect().to eq.

→ Solution

Challenge 2: before(:each) for Shared Setup

Write specs for a Stack class (with push, pop, and empty? methods) using before(:each) to create a fresh @stack before every example. Write at least three it blocks covering pushing, popping, and the empty check.

Goal: Practice avoiding shared, leaking state between test examples.

→ Solution

Challenge 3: Stubbing a Dependency

Given a class NotificationService that takes a mailer object in its constructor and calls mailer.send_email(message) inside a notify(message) method, write a spec using double and allow().to receive() to stub the mailer, then a second example using expect().to receive() to verify notify actually calls send_email.

Goal: Practice the difference between a stub (configuring a return value) and a mock (verifying a call happened).

→ Solution

💎 RSpec Is Blocks All the Way Down

Every piece of RSpec's syntax — describe, it, before, even the expect { ... }.to raise_error form — is an ordinary Ruby method that happens to accept a block. There's no special testing syntax baked into the language; RSpec is a library built entirely from the same block mechanics Course 1 Chapter 8 called "the single feature most responsible for Ruby's reputation as an unusually expressive language." If RSpec's DSL feels like a small custom language, that's because it genuinely is one — built entirely out of tools you already have.

🎯 What's Next

Next chapter: Gems & Bundler — how RSpec (and every other library used so far) actually gets installed, what a Gemfile does, and building a small gem of your own.