Skip to main content
Laravel, shipping fast.

Chapter 8

Testing & Quality Assurance

Julian Beaujardin

Ship a bug to production and you lose an afternoon. Ship a passing test suite that never actually tested anything and you lose a lot more than that, because you won't find out until the afternoon you least expect it. This chapter covers what to test, what not to bother testing, how to test an API that leans on three or four services you don't control, and how to keep the suite fast enough that your team actually runs it. A test that doesn't assert on the contract isn't testing your API. It's testing that PHP still runs.

What an API test should assert

Here's the question every test should answer before you write a single assertion: what does the consumer of this endpoint depend on? Not what the controller does internally. Not which method the facade called. The consumer sees three things: the HTTP status code, the shape of the JSON body, and whatever side effect they were promised, an email sent, a job queued, a record created. Test those. Nothing else survives a refactor.

// tests/Feature/LicenseControllerTest.php
test('returns 200 with license data when authorized', function () {
    $response = getJson('/api/v1/licenses', [
        'Authorization' => "Bearer {$this->token->token}",
    ]);

    $response->assertStatus(Response::HTTP_OK);
    $response->assertJsonStructure([
        'items' => [
            '*' => [
                'key',
                'name',
                'domains',
                'created_at',
            ],
        ],
    ]);
});

Notice what this test doesn't do. It doesn't reach into LicenseController and check which private method ran. It doesn't assert that LicenseDTOMapper::toDTO() was called with specific arguments. It hits the route the way a real consumer would, with a real bearer token, and checks the response the way a real consumer would: status code first, then structure. If tomorrow you swap the internal mapper for something else entirely, this test doesn't care, as long as /api/v1/licenses still returns { "items": [...] } shaped the way it's documented.

// Bad: asserts on a collaborator, not the contract
test('creates a license', function () {
    LicenseFacade::shouldReceive('addLicense')
        ->once()
        ->andReturn(['key' => 'new-license-key', 'name' => 'New License', 'domains' => ['newdomain.com'], 'created_at' => '2026-01-29T12:00:00Z']);

    $this->postJson('/api/v1/license', ['name' => 'New License', 'domain' => 'newdomain.com']);
});

// Good: asserts on what a consumer actually sees
test('returns created license in response data', function () {
    $response = $this->postJson('/api/v1/license', [
        'name' => 'New License',
        'domain' => 'newdomain.com',
    ], ['Authorization' => "Bearer {$this->token->token}"]);

    expect($response->json())->toHaveKey('data');
    expect($response->json('data'))->toHaveKeys(['key', 'name', 'domains', 'created_at']);
});

The bad version breaks the moment you rename a method or extract a step into a job. It knows nothing about whether the client actually got a usable response, it just confirms that some internal wiring fired. The good version breaks only when the contract changes, which is exactly when you want to know.