03 · Testing Advanced¶
Level 2 covered RSpec basics — describe, it, expect(...).to eq(...).
Real test suites also need to isolate a unit from its collaborators
(mocks/stubs/doubles) and to exercise a whole HTTP layer end-to-end
(request specs). Both rely on the same RSpec you already know; this
module adds the vocabulary for talking to fakes instead of the real
thing.
Why fake collaborators at all¶
Suppose Notifier sends a welcome email through some mailer object:
class Notifier
def initialize(mailer)
@mailer = mailer
end
def notify(user)
@mailer.send_email(user, "Welcome!")
end
end
Testing this for real would mean actually sending email in your test
suite — slow, flaky, and annoying to run 500 times a day. Instead, you
hand Notifier a double: a fake object that stands in for the real
mailer and only needs to respond the way this one test cares about.
Doubles and message expectations¶
RSpec.describe Notifier do
it "calls send_email on the mailer with a double" do
mailer = double("mailer")
expect(mailer).to receive(:send_email).with("alice", "Welcome!")
Notifier.new(mailer).notify("alice")
end
end
double("mailer") creates an object that responds to nothing by default.
expect(mailer).to receive(:send_email) declares, before anything runs,
that send_email must be called with exactly those arguments — the
test fails if it's never called, or called with different arguments.
This is different from a normal expect(...).to eq(...): it's a promise
about behavior, checked after the example runs.
Stubbing return values¶
it "stubs a return value" do
mailer = double("mailer", send_email: "sent!")
result = Notifier.new(mailer).notify("bob")
expect(result).to eq("sent!")
end
double("mailer", send_email: "sent!") is shorthand for a double that
responds to send_email and always returns "sent!", with no
expectation about whether or how many times it's called — a stub,
not an expectation. Use a stub when you only care about the return
value; use expect(...).to receive when the call itself is the thing
under test.
instance_double — verified doubles¶
Plain double will happily let you stub a method that doesn't exist on
the real object — a typo like send_emial fails silently at test time
and only breaks in production. instance_double checks the method
actually exists (and its arity) on the named class:
it "spies on a real object" do
mailer = instance_double("RealMailer", send_email: true)
Notifier.new(mailer).notify("carol")
expect(mailer).to have_received(:send_email).with("carol", "Welcome!")
end
have_received is the "spy" style: let the stub record calls, then
assert afterward instead of declaring the expectation up front. Prefer
instance_double over plain double whenever the real class is defined
somewhere in your codebase — it catches drift between your test's fake
and the real interface.
Captured output running all three examples together:
$ rspec mocks_spec.rb
...
Finished in 0.00937 seconds (files took 0.05666 seconds to load)
3 examples, 0 failures
Request specs — testing a whole route¶
A request spec exercises your app the way an HTTP client would: send a
request, assert on the response. Sinatra apps use rack-test for this
(Rails calls the equivalent an integration/request spec built on the same
idea):
require 'rack/test'
require_relative 'app'
RSpec.describe "Notes API", type: :request do
include Rack::Test::Methods
def app; Sinatra::Application; end
it "GET / returns greeting" do
get '/'
expect(last_response.status).to eq(200)
expect(last_response.body).to eq("Hello, Sinatra!")
end
it "GET /greet/:name interpolates the name" do
get '/greet/Ruby'
expect(last_response.body).to eq("Hello, Ruby!")
end
end
Captured output:
$ rspec request_spec.rb
..
Finished in 0.00846 seconds (files took 0.22293 seconds to load)
2 examples, 0 failures
Request specs don't know or care how a route is implemented internally — only what goes in (the request) and what comes out (status, headers, body). That makes them resilient to refactoring the route's internals, unlike a unit test that mocks internal collaborators.
Unit vs. request spec — when to use which¶
- Unit spec + doubles: testing one class's logic in isolation, fast, pinpoints exactly which class broke.
- Request spec: testing that routing, params parsing, and your
actual handler code all wire together correctly — catches integration
bugs a unit test can't see (wrong route path, forgotten
require, middleware misconfiguration).
A healthy suite has many unit specs and a smaller number of request specs covering the critical paths — not the other way around, since request specs are slower and touch more code per failure.
Testing-specific traps¶
- A
doublewith no expectation set never fails on a missed call — ifNotifiernever callssend_emailat all, adouble("mailer", send_email: "sent!")stub doesn't notice. Useexpect(...).to receivewhen the call happening is what you're verifying. instance_doubleneeds the real class loaded at the time the spec runs, or it raisesRSpec::Mocks::MockExpectationErrorcomplaining it can't find the class — a common cause of specs that pass locally but fail in a differently-ordered CI run.- Over-mocking couples the test to implementation, not behavior. Stubbing every single collaborator method means the test only proves "the code calls things in the order I stubbed," not "the code produces the right result" — refactor the internals and the mock-heavy test breaks even though behavior didn't change.
rack-test'slast_responseis only set after a request method (get/post/etc.) — calling it before any request raisesNoMethodError: undefined method 'status' for nil, a confusing error if you forget the request line.- Request specs re-require the whole app file. If
app.rbhas top-level side effects (like an eager database connection), every request spec file that requires it pays that cost — keep expensive setup behind lazy initialization orbefore(:suite).
How It Actually Works¶
A test double (instance_double, double) is a real Ruby object created
at runtime with a singleton class whose method table is populated on the
fly from your allow(...).to receive(...) calls — RSpec uses
define_singleton_method internally to attach exactly the methods you
stub, and instance_double additionally reflects on the real class via
.instance_methods to verify at test time that the methods you're stubbing
actually exist on the real object, catching typos that a plain double
would silently allow. Mocking a method (expect(obj).to receive(:foo))
temporarily replaces the method in obj's singleton class for the duration
of the example, then RSpec restores the original method afterward — which
is why mocks never leak between examples even though they're implemented
by literally rewriting the method table. Database-backed test suites that
wrap each example in a transaction rely on the fact that a ROLLBACK after
the example undoes every write the example made — the app code doesn't
need to know it's being tested for this to work.
Cheat sheet¶
| Goal | RSpec syntax |
|---|---|
| Create a bare double | double("name") |
| Double with stubbed methods | double("name", method: value) |
| Verified double against a real class | instance_double("ClassName", method: value) |
| Expect a message will be sent | expect(obj).to receive(:method) |
| Expect specific arguments | .with(arg1, arg2) |
| Assert a message was already sent (spy style) | expect(obj).to have_received(:method) |
| Stub without checking it's called | allow(obj).to receive(:method).and_return(value) |
| Send a fake HTTP request | get '/path', post '/path', params |
| Inspect the fake response | last_response.status, last_response.body |
Exercise¶
Extend the Notifier/app.rb examples:
- Write a unit spec for a new
PasswordValidatorclass that calls out to an injecteddictionary_checkercollaborator — use a double to assert it's called with the candidate password, and stub two return values (true/false) to test both branches of your validator. - Write a request spec against the
POST /echoroute from module 1, asserting the response body echoes back the submittedmessage. - Deliberately break one assertion in each spec, run
rspec, and paste the failure output — then fix it and paste the passing output.