Skip to main content
Laravel, shipping fast.

Chapter 7

Testing & Quality Assurance

Julian Beaujardin

If you want to ship fast without breaking things, you need tests. Full stop. Not for test coverage. For confidence.

Tests let you:

  1. Refactor without fear: Change implementation knowing you'll catch breaks
  2. Catch bugs early: Before production
  3. Document behavior: Tests show how things are supposed to work
  4. Catch regressions: A fix that broke something else is immediately obvious

Write tests as you build. Not after. Make testing part of your development workflow, not a chore at the end.

Setting Up Pest Testing

composer require pestphp/pest --dev
php artisan pest:install

Writing Feature Tests

Test entire request/response flows:

// tests/Feature/LicenseControllerTest.php
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Foundation\Testing\WithoutMiddleware;

describe('License Endpoints', function () {
    // Tests use transactions to roll back database
    use DatabaseTransactions;

    beforeEach(function () {
        $this->bearer = Bearer::factory()->create([
            'scopes' => ['licenses:read', 'licenses:write'],
        ]);
    });

    test('can fetch all licenses with valid token', function () {
        $response = $this->getJson(
            '/api/licenses',
            headers: ['Authorization' => "Bearer {$this->bearer->token}"]
        );

        $response->assertStatus(200)
            ->assertJsonHasPath('items')
            ->assertJsonIsArray('items');
    });

    test('returns 401 without bearer token', function () {
        $response = $this->getJson('/api/licenses');

        $response->assertStatus(401)
            ->assertJsonPath('error', 'Unauthorized');
    });

    test('returns 401 with expired token', function () {
        $this->bearer->update(['expires_at' => now()->subDay()]);

        $response = $this->getJson(
            '/api/licenses',
            headers: ['Authorization' => "Bearer {$this->bearer->token}"]
        );

        $response->assertStatus(401);
    });

    test('validates create license input', function () {
        $response = $this->postJson(
            '/api/license',
            [],  // Missing required fields
            ['Authorization' => "Bearer {$this->bearer->token}"]
        );

        $response->assertStatus(422)
            ->assertJsonPath('errors.name', fn ($error) => is_array($error));
    });

    test('accepts locale parameter', function () {
        $response = $this->getJson(
            '/api/licenses?locale=es',
            headers: ['Authorization' => "Bearer {$this->bearer->token}"]
        );

        // Should return in Spanish locale
        $response->assertStatus(200);
    });

    test('respects rate limits', function () {
        // Make 61 requests (limit is 60/minute)
        for ($i = 0; $i < 61; $i++) {
            $response = $this->getJson(
                '/api/licenses',
                headers: ['Authorization' => "Bearer {$this->bearer->token}"]
            );
        }

        // 61st should be throttled
        $response->assertStatus(429);
    });
});

Unit Tests

Test individual components:

describe('LicenseDTO', function () {
    test('constructs with required properties', function () {
        $dto = new LicenseDTO(
            key: 'lic_123',
            name: 'My License',
            domains: ['example.com'],
            created_at: '2024-01-01T00:00:00Z',
        );

        expect($dto->key)->toBe('lic_123')
            ->and($dto->name)->toBe('My License')
            ->and($dto->domains)->toBe(['example.com'])
            ->and($dto->created_at)->toBe('2024-01-01T00:00:00Z');
    });

    test('creates license DTO from API response', function () {
        $response = [
            'key' => 'lic_abc123',
            'name' => 'Test License',
            'domains' => ['example.com', 'test.org'],
            'created_at' => '2024-01-01T00:00:00Z',
        ];

        $dto = LicenseDTOMapper::toDTO($response);

        expect($dto->key)->toBe('lic_abc123')
            ->and($dto->name)->toBe('Test License')
            ->and($dto->domains)->toBe(['example.com', 'test.org'])
            ->and($dto->created_at)->toBe('2024-01-01T00:00:00Z');
    });

    test('maps multiple licenses to DTOs', function () {
        $licenses = collect([
            [
                'key' => 'lic_1',
                'name' => 'License 1',
                'domains' => ['example.com'],
                'created_at' => '2024-01-01T00:00:00Z',
            ],
            [
                'key' => 'lic_2',
                'name' => 'License 2',
                'domains' => ['test.org'],
                'created_at' => '2024-01-02T00:00:00Z',
            ],
        ]);

        $dtos = LicenseDTOMapper::toDTOCollection($licenses);

        expect($dtos)->toHaveCount(2)
            ->and($dtos[0]->name)->toBe('License 1')
            ->and($dtos[1]->name)->toBe('License 2');
    });
});