If you want to ship fast without breaking things, you need tests. Full stop. Not for test coverage. For confidence.
Tests let you:
- Refactor without fear: Change implementation knowing you'll catch breaks
- Catch bugs early: Before production
- Document behavior: Tests show how things are supposed to work
- 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');
});
});