Skip to main content
Laravel, shipping fast.

The License API asks Statamic to issue licenses and reshapes the answer. Your tests are not allowed to make that call for real. Not because it is slow, but because a test suite that depends on a third party being up is a bet.

Http::fake() replaces the network and nothing else. Everything from the route to the driver runs for real:

it('returns the created license', function () {
    actingAsConsumer('licenses:write');

    Http::fake(['*/sites*' => Http::sequence()
        ->push(['data' => []])
        ->push(['data' => [
            'key' => 'lic_1',
            'name' => 'Acme',
            'created_at' => '2026-01-29T12:00:00Z',
        ]], 201),
    ]);

    $this->postJson('/api/licenses', [
        'name' => 'Acme',
        'domain' => 'acme.test',
    ])
        ->assertCreated()
        ->assertJsonPath('data.key', 'lic_1');
});

The sequence is the provider’s side of the conversation as Chapter 8 left it: first the list, which is empty, then the creation. That is the right place to cut, as far out as possible.

Faking is also the only way to put a provider into a specific failure state on demand, which you can’t do to a real dependency that happens to be healthy.

Factories That Describe Intent

A factory is documentation of what a valid record looks like, written as code.

// database/factories/ConsumerFactory.php
public function definition(): array
{
    return [
        'name' => fake()->unique()->company(),
        'settings' => [],
        'rate_limit' => null,
        'license_limit' => 50,
    ];
}

public function forPartner(string $token): static
{
    return $this->state(fn () => [
        'settings' => ['statamic_token' => $token],
    ]);
}

definition() is the baseline: the consumer you want in a test that isn’t about consumers. The state method is where a factory earns the word “intent.” Consumer::factory()->forPartner('tok')->create() reads like English. Building the same record inline makes every test author work out again what “a partner consumer” means, and when the model gains a required column, every one of those inline calls breaks at once where one factory would have absorbed the change.

license_limit is spelled out although the column has a default. A model fresh from a factory knows only the attributes it was given, and a limit that reads as null would refuse every create.

The baseline consumer has no provider token. A test that reaches the provider without saying whose credentials it uses gets the exception Chapter 4 promised, and that is the behavior you want a test to trip over.

Testing Failure

A suite that only proves the happy path has proven that your API works when nothing goes wrong, which is the one condition you didn’t need proof for. The interesting bugs live in what happens when the provider is slow, wrong, or gone.

it('treats a 404 on delete as success', function () {
    actingAsConsumer('licenses:write');
    Http::fake(['*' => Http::response([], 404)]);

    Licenses::delete('already-gone');
})->throwsNoExceptions();

it('lets a 500 on delete fail', function () {
    actingAsConsumer('licenses:write');
    Http::fake(['*' => Http::response([], 500)]);

    Licenses::delete('boom');
})->throws(RequestException::class);

These two tests look almost identical and mean opposite things. A 404 on delete means the thing you wanted gone is gone. A 500 means the provider broke, and the job from Chapter 10 must see the exception so that it retries. Collapse them into one “handles errors gracefully” test and you would never notice if someone swapped the two behaviors. Written separately, the distinction is part of the specification.

Retries are a failure mode too:

it('retries when the provider throttles', function () {
    actingAsConsumer('licenses:read');

    Http::fake(['*' => Http::sequence()
        ->push(status: 429, headers: ['Retry-After' => '2'])
        ->push(['data' => []]),
    ]);

    Licenses::all();

    Http::assertSentCount(2);
    Sleep::assertSleptTimes(1);
});

The first call is throttled, the second succeeds, and the test proves both that the retry happened and that the client waited. Because of Sleep::fake(), it waited for no time at all.

And the one this book has leaned on since Chapter 4: the wrong caller.

it('refuses a caller without the ability', function (
    string $ability,
    string $method,
    string $uri,
) {
    actingAsConsumer($ability);

    $this->json($method, $uri)->assertForbidden();
})->with([
    ['licenses:read', 'POST', '/api/licenses'],
    ['licenses:read', 'DELETE', '/api/licenses/lic_1'],
    ['licenses:write', 'GET', '/api/licenses'],
    ['licenses:write', 'GET', '/api/consumers'],
    ['licenses:write', 'POST', '/api/consumers/1/tokens'],
    ['licenses:write', 'PATCH', '/api/consumers/1'],
    ['licenses:write', 'DELETE', '/api/consumers/1'],
    ['licenses:read', 'POST', '/api/license-batches'],
]);

One row per route and per caller who must be refused. Chapter 19 calls this table the most valuable page a security review produces. Here it is as a test that runs on every commit.

The Test That Would Have Caught It

I’ve watched a provider change its response without announcing it, and not through carelessness. A field that had always been present started arriving as null, or missing, because an upstream change decided an empty list wasn’t worth serializing. Nothing in the contract said it couldn’t happen. Most test suites don’t check for it either, because the happy-path test is easy to write and the “what if the field just isn’t there” test requires imagining the failure first.

it('lists a license that has no domains', function () {
    actingAsConsumer('licenses:read');

    fakeProvider([[
        'key' => 'k1',
        'name' => 'No Domains',
        'created_at' => '2026-01-28T10:30:00Z',
    ]]);

    $this->getJson('/api/licenses')
        ->assertOk()
        ->assertJsonPath('data.0.domains', []);
});

The fake doesn’t return an empty domains. It leaves the field out, and the test asserts that the API doesn’t crash and doesn’t leak a null into a field its consumers expect to be an array. The code that makes this pass is small: a nullable rule and a ?? [] in License::fromProvider(). The test is what makes them a decision and not an accident, checked again on every future change.

That is the argument for testing failure: not that it catches today’s bug, but that it catches the version of it that hasn’t happened yet.

The audio could not be loaded. Try again in a moment.