The only HTTP this app makes
Before learning tools for stubbing outbound HTTP, it is worth knowing how much outbound HTTP there is. In this repository, a search for every HTTP client returns two files.
$ grep -rln "Net::HTTP\|Faraday\|HTTParty\|RestClient\|URI.open" app/ lib/
app/services/partners/fetch_site_icon_service.rb
app/services/partners/webhook_transport.rb
Two HTTP callers, plus an OAuth flow through a gem, plus outbound mail. Four integrations in an application of 204 spec files, which is normal. And it is why "how do we handle external services in tests" is usually a smaller question than it sounds.
What the four are
Partners::FetchSiteIconService. Given a partner's URL, it fetches the page, reads the <link rel="icon"> and apple-touch-icon tags, picks the largest, downloads it, and attaches it as the partner's logo.
def self.call(partner:, http: Net::HTTP)
new(partner, http).call
end
Read the signature. The HTTP client is a parameter with a default.
Partners::WebhookTransport. A thin Net::HTTP adapter that posts a signed event to a partner's endpoint, with five-second timeouts, raising DeliveryError on any non-2xx response so the job can retry. It exists as its own module so the delivery job can be tested without the network.
GitHub OAuth, through omniauth-github:
# config/initializers/omniauth.rb
Rails.application.config.middleware.use OmniAuth::Builder do
provider :github, ENV["GITHUB_KEY"], ENV["GITHUB_SECRET"], scope: "user:email"
end
Outbound mail, through Action Mailer and SMTP.
Why the network must not be in the suite
A test that makes a real request is worse in four separate ways, and they compound:
It is flaky. The service is down, rate-limits you, or is slow this morning. The test fails and nothing in your code changed, which teaches everyone to re-run the suite rather than read the failure.
It is slow. A network round trip is tens or hundreds of milliseconds against the roughly 0.05ms a local database query costs. One integration test can dominate a suite.
It is not repeatable. The response changes. A test asserting on today's data fails tomorrow.
It has side effects. A test that POSTs to a real service creates real records, sends real messages, or spends real money.
flowchart TB
T["a spec that makes a real request"] --> A["flaky: the service, not your code"]
T --> B["slow: tens of ms, or a timeout"]
T --> C["not repeatable: the response changes"]
T --> D["side effects: real records, real money"]
A --> R["the suite stops being evidence"]
B --> R
C --> R
D --> R
How this repository handles each one
Four integrations, four techniques, and none of them is WebMock or VCR:
| Integration | Technique | Where |
|---|---|---|
| Site icon fetch | The HTTP client is injected; the spec passes a fake class | fetch_site_icon_service_spec.rb |
| Partner webhooks | The job's spec stubs WebhookTransport.post with RSpec mocks; the adapter itself has no spec |
deliver_webhook_job_spec.rb |
| GitHub OAuth | OmniAuth.config.test_mode = true and mock_auth[:github] |
omniauth_callbacks_spec.rb |
Action Mailer's :test delivery method |
Rails default |
That is a deliberate set of choices rather than an oversight. And the rest of this course is about when each is right and what the two tools everyone reaches for add.
The question underneath
Every technique here is answering one question: at which layer do you cut the wire?
You can replace the object that makes the call, ask the library to pretend, intercept the HTTP at the socket boundary, or replay a real response recorded earlier. Each cut is at a different depth, and the depth determines how much of your code is still being tested.
That framing is lesson 2, and it is more useful than either tool.
Key takeaways
- This application makes outbound HTTP from two places (site icons and partner webhooks), plus OAuth and mail.
- Real network calls in a suite are flaky, slow, unrepeatable and side-effecting: four independent reasons.
FetchSiteIconServicetakes its HTTP client as a parameter:http: Net::HTTP.- The four integrations use four techniques: injection, a stubbed adapter, the library's test mode, and Action Mailer's
:testdelivery. - The organizing question is at which layer you cut the wire, because that decides how much code is still tested.
Keep going with lesson 2
Four places to cut the wire
Compares four places to cut the wire: injected fakes, library test modes, WebMock and VCR, and what each proves.
A free account also gives you the quizzes, your progress and notes, and the discussion.