Testing with RSpec
๐งช Testing with RSpec
๐ 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
| Concept | PHPUnit | pytest | RSpec |
|---|---|---|---|
| Group tests | A test class extending TestCase | A file/class of test_ functions | describe block |
| One test case | A method starting with test | A function starting with test_ | it block |
| Assertion style | $this->assertEquals(5, $result) | assert result == 5 | expect(result).to eq(5) |
| Shared setup | setUp() method | @pytest.fixture | before(:each) block |
| Mocking | Mockery (separate library) | unittest.mock built in | double / 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.
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.
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).
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.