Laravel: A replacement for Http::assertSent() with better feedback on error

Laravel: A replacement for Http::assertSent() with better feedback on error
Photo by National Cancer Institute / Unsplash

When writing PHPUnit tests for your Http clients in Laravel, you probably know about Http::fake() and Http::assertSent().

Here is an example of this:

use Illuminate\Http\Client\Request; 

Http::fake([
    'hehe' => Http::response('hehehe'),
    'hihi' => Http::response('hihihi'),
]);

// Let's pretend an actual service is called here
// that makes requests using the Http facade
Http::withToken('my-secret')->get('hehe');
Http::withToken('my-secret')->get('hihi');

Http::assertSent(fn(Request $request) =>
    $request->url() === 'hehe' &&
    $request->header('Authorization') === ['Bearer my-secret']);

First we fake the requests that we expect to happen, then we call the service that (supposedly) makes those requests, afterwards we perform an assertSent() assertion.

The issue

Let's change the expectations so that they no longer match what actually happens:

Http::assertSent(fn(Request $request) =>
    $request->url() === 'hehe' &&
    $request->header('Authorization') === ['Bearer my_secret']);

Then the test will fail, as it should, but the output is lackluster:

An expected request was not recorded.
Failed asserting that false is true.

Did you see what changed? (click for spoiler)

Bearer my-secret was changed to Bearer my_secret


Since I deal with quite complex requests at work, it can be extremely time consuming and frustrating to find out WHY an expected request was not recorded. Was it not sent at all? Are the headers wrong? Or a field in the deeply nested body?

The solution

I wrote a little trait that you can add to your tests. It has one method called assertHttpRequestWithFeedback().

<?php

namespace Tests\Traits;

use Closure;
use Illuminate\Http\Client\Request;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use PHPUnit\Framework\Assert;

trait AssertsHttpRequestsWithFeedback
{
    /**
     * Asserts that a given HTTP request was made using the Http facade.
     * Make sure to call Http::fake() in your test before calling this method.
     * fixme: add a check whether Http::fake() was called before this method
     *
     * @param Closure(Request): bool $callback
     */
    public function assertHttpRequestWithFeedback(Closure $callback): void
    {
        $matches = Http::recorded($callback);

        if ($matches->count() > 0) {
            // Let PHPUnit know that a successful assertion was performed
            Assert::assertTrue(true);

            return;
        }

        $allRequests = Http::recorded()->map(function (array $pair) {
            /** @var array{Request, Response} $pair */
            [$request, ] = $pair;

            return [
                'url'     => $request->url(),
                'method'  => $request->method(),
                'headers' => $request->headers(),
                'body'    => $request->body(),
            ];
        });

        $this->fail(
            'No matching HTTP request was recorded.' . PHP_EOL .
            'Recorded requests:' . PHP_EOL .
            json_encode($allRequests, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)
        );
    }
}

To use it, import the AssertsHttpRequestsWithFeedback trait in your test and replace Http::assertSent() with $this->assertHttpRequestWithFeedback()

use Illuminate\Http\Client\Request;
use Tests\Traits\AssertsHttpRequestsWithFeedback;

Http::fake([
    'hehe' => Http::response('hehehe'),
    'hihi' => Http::response('hihihi'),
]);

Http::withToken('my-secret')->get('hehe');
Http::withToken('my-secret')->get('hihi');

$this->assertHttpRequestWithFeedback(fn (Request $request) =>
    $request->url() === 'hehe' &&
    $request->header('Authorization') === ['Bearer my_secret']);

$this->assertHttpRequestWithFeedback() has the same signature as Http::assertSent() so it is very straight forward to replace an existing assertion. My method performs the same check, but additionally, on failure, it informs you about the recorded requests.

Now, the error output is:

No matching HTTP request was recorded.
Recorded requests:
[
    {
        "url": "hehe",
        "method": "GET",
        "headers": {
            "User-Agent": [
                "GuzzleHttp/7"
            ],
            "Authorization": [
                "Bearer my-secret"
            ]
        },
        "body": ""
    },
    {
        "url": "hihi",
        "method": "GET",
        "headers": {
            "User-Agent": [
                "GuzzleHttp/7"
            ],
            "Authorization": [
                "Bearer my-secret"
            ]
        },
        "body": ""
    },
]

Unfortunately, it's not a nice little diff showing the exact difference. But:

  • it lets us look at whether the request occured at all (and how many times)
  • lets us manually check where the output deviates from the expectations
  • without jumping into a debugger or adding dump() statements all over the place

At least for me, this saves a lot of time and trouble.

If you want to know how exactly my implementation differs from Illuminate\Http\Client\Factory::assertSent(), check out their source code: https://github.com/illuminate/http/blob/master/Client/Factory.php#L352

If you don't want to miss more Laravel life hacks like this, consider signing up for the newsletter!