artisanplaybook .dev
ARTICLES / TESTING

Http::fake() isn't catching every request you think it is

Http::fake() feels like a hard guarantee — call it, and your tests stop touching the network. In practice it's a facade-level intercept, not a network-level one, and it comes with a default failure mode (an unmatched URL quietly returns an empty 200 rather than failing the test) that's easy to build an entire test suite around without realizing it.

ON THIS PAGE

What Http::fake() actually intercepts

Http::fake() works by swapping the underlying HTTP client that Illuminate\Support\Facades\Http uses. Any call made through that facade — Http::get(), Http::post(), and so on — gets intercepted and matched against whatever fakes you've registered:

Http::fake([
    'api.stripe.com/*' => Http::response(['id' => 'ch_123'], 200),
]);

$response = Http::post('https://api.stripe.com/v1/charges', [...]);
// intercepted, returns the fake response, no network call happens

This is the mechanism, and it's important to be precise about it: it fakes the Http facade specifically. It doesn't fake the network, doesn't fake curl, and doesn't fake any HTTP client instantiated independently of that facade.

The silent gap: SDKs with their own HTTP client

Plenty of third-party packages — Stripe's SDK is the canonical example — construct their own Guzzle client internally rather than routing through Laravel's Http facade. Http::fake() has no visibility into that client at all:

Http::fake(['api.stripe.com/*' => Http::response([...])]);

// This goes through Stripe's own Guzzle instance, not the Http facade —
// the fake above does nothing here, and this hits the real network:
$charge = \Stripe\Charge::create([...]);

The test doesn't error. It doesn't warn you. It just makes a real HTTP call to Stripe during a test run, which might work fine in Stripe's test mode, or might rack up API calls against a rate limit, or might fail unpredictably in CI depending on network access — none of which has anything to do with the logic you're actually trying to test.

The fix here isn't a Laravel feature, it's knowing where the boundary is: anything not using the Http facade needs its own mocking strategy — Stripe's SDK ships test helpers for exactly this, and most well-built SDKs do. Http::fake() covers your own outbound calls and any package that's specifically built on top of the Http facade; it does not cover a package with its own client underneath.

Unmatched URLs get a fake 200, not a failure

This is the sharper edge. If a request goes through the Http facade but doesn't match any pattern you registered, Laravel's default behavior isn't to throw — it's to return an empty, successful fake response:

Http::fake([
    'api.stripe.com/*' => Http::response(['id' => 'ch_123']),
]);

// Your code also calls this, which you forgot to fake:
Http::get('https://api.sendgrid.com/v3/mail/send');
// Returns an empty 200 response. No error. No indication anything's wrong.

If your code checks $response->successful() before proceeding, this passes that check — the test can go green while silently not testing the SendGrid call at all, and worse, masking the fact that you forgot to fake it in the first place.

Making stray requests loud

Http::preventStrayRequests() flips this default: any request through the Http facade that doesn't match a registered fake throws instead of silently succeeding.

Http::preventStrayRequests();

Http::fake([
    'api.stripe.com/*' => Http::response(['id' => 'ch_123']),
]);

Http::get('https://api.sendgrid.com/v3/mail/send');
// throws an exception now, pointing directly at the unfaked call

Calling this once in your base TestCase setup (or a testing service provider) turns "a call I forgot to fake passes silently" into "a call I forgot to fake fails the test immediately with a clear message" — which is almost always what you actually want during tests, since a stray real request during a test run is never the intended behavior.

Assertion pitfalls: assertSent() and matching

Http::assertSent() checks the recorded request history against a closure. It's easy to write a passing assertion that isn't actually as specific as it looks:

Http::assertSent(function ($request) {
    return $request->url() === 'https://api.stripe.com/v1/charges';
    // passes as long as *any* recorded request matches this —
    // says nothing about the request body, method, or headers
});

If the thing that matters is the actual payload sent — the charge amount, the currency — assert on that explicitly rather than just the URL:

Http::assertSent(function ($request) {
    return $request->url() === 'https://api.stripe.com/v1/charges'
        && $request['amount'] === 4999
        && $request['currency'] === 'usd';
});

A URL-only assertion will happily pass even if your code sent the wrong amount, since it was never checking that in the first place.

Checklist before trusting a faked HTTP test

  • Does every outbound call in the code under test actually go through the Http facade, or does any dependency (an SDK, a package) use its own client that needs separate mocking?
  • Is Http::preventStrayRequests() enabled globally in tests, so a forgotten fake fails loudly instead of silently returning an empty 200?
  • Do assertSent() closures check the actual payload/method/headers that matter, or only the URL?
  • If a test is green, would it still be green if the code under test sent completely wrong data to a correctly-faked URL?

Related reading

ARCHIVE →