Skip to main content
Laravel, shipping fast.
Chapter 8 · Testing & Quality Assurance

Feature tests over unit tests, and why

Julian Beaujardin

Here's a controversial opinion: most of your test suite should be feature tests, not unit tests. Not "should lean that way." Almost entirely. api-server has 72 test files under tests/Feature and zero under tests/Unit. api-license has 3 feature files and no unit directory at all. merchants, the largest app in the fleet, runs 187 feature tests against 3 unit tests.

Why? A unit test proves a function works in isolation. An API doesn't ship isolated functions, it ships a request/response cycle threading through middleware, validation, a facade, an external call, a resource, and a response wrapper.

Request
EnsureTokenIsValidMiddleware
CreateLicenseRequest (validation)
LicenseFacade → StatamicAPI (external call)
LicenseDTOMapper → LicenseResource
ModelResponse

Unit-test any single box in that chain and you've proven nothing about whether the chain works. The middleware might reject a token the facade would've accepted. The mapper might choke on a shape the resource never produces in practice. A feature test exercises the whole pipeline the way production will, which is the only place bugs in Laravel apps actually live: at the seams between components, not inside them.

The handful of unit tests that do exist earn their place. api-ai keeps a small tests/Unit directory for pure logic that has no business touching the HTTP kernel: a content-schema validator, a DTO factory, an image-ranking algorithm. merchants keeps the same discipline for its own pure logic: a phone-number normalization action, a category enum's business rules. No routes, no middleware, no database, just a function and an assertion. That's the bar for a unit test in this fleet: if it needs getJson() to prove anything, it's a feature test.

Faking the services you do not own

The Webplo License API doesn't issue licenses. It asks Statamic to issue them and reshapes the answer. Your tests are not allowed to make that call for real, not because it's slow, but because a test suite that depends on a third party's uptime isn't a test suite, it's a bet.

// tests/Feature/LicenseControllerTest.php
beforeEach(function () {
    $this->token = Bearer::factory()->create();

    Http::fake(function ($request) {
        if ($request->method() === 'GET') {
            return Http::response(['data' => [
                ['key' => 'license-1', 'name' => 'Production License', 'domains' => ['example.com'], 'created_at' => '2026-01-28T10:30:00Z'],
            ]], 200);
        } elseif ($request->method() === 'POST') {
            return Http::response(['data' => [
                'key' => 'new-license-key', 'name' => 'New License', 'domains' => ['newdomain.com'], 'created_at' => '2026-01-29T12:00:00Z',
            ]], 201);
        } elseif ($request->method() === 'DELETE') {
            return Http::response([], 202);
        }

        return Http::response([], 404);
    });
});

Http::fake() intercepts every outbound call Laravel's HTTP client makes and hands back whatever you tell it to, no socket opened, no DNS lookup, no dependency on Statamic being awake. The closure form matters here: instead of one fixed response, it branches on the method, so the same fake covers list, create, and delete without three separate setup blocks scattered across the file.

You can go further than faking the happy path. When a shared SendsRequests trait retries on a 429, Http::sequence() queues up responses in order instead of one fixed reply, so the first call gets throttled and the second succeeds. Http::assertSentCount(2) then proves the retry actually happened, not just that the final response looked right. Faking isn't just about speed. It's the only way to put a leaf service in a specific failure state on demand, something you can't do to a real, currently-healthy dependency.