Beyond Unit Tests: Testing the API as Clients Actually Use It
Unit tests verify individual methods. End-to-end API tests verify that a real HTTP request produces the right response, side effects, and database state — the layer that actually matches how your API gets used in production.
A Full Request/Response Test
it('creates an order and returns the correct response shape', function () {
$user = User::factory()->create();
$product = Product::factory()->create(['price' => 29.99, 'stock' => 10]);
$response = $this->actingAs($user, 'sanctum')->postJson('/api/orders', [
'product_id' => $product->id,
'quantity' => 2,
]);
$response->assertStatus(201)
->assertJsonStructure(['id', 'total', 'status', 'items'])
->assertJsonPath('total', 59.98);
$this->assertDatabaseHas('orders', ['user_id' => $user->id, 'total' => 59.98]);
$this->assertDatabaseHas('products', ['id' => $product->id, 'stock' => 8]);
});
Testing Pagination and Filtering
it('paginates and filters the products list', function () {
Product::factory()->count(25)->create(['category' => 'electronics']);
Product::factory()->count(5)->create(['category' => 'books']);
$response = $this->getJson('/api/products?category=electronics&per_page=10');
$response->assertStatus(200)
->assertJsonCount(10, 'data')
->assertJsonPath('meta.total', 25);
});
Testing Rate Limiting
it('rate limits repeated login attempts', function () {
$user = User::factory()->create(['password' => Hash::make('correct-password')]);
for ($i = 0; $i < 6; $i++) {
$response = $this->postJson('/api/login', [
'email' => $user->email,
'password' => 'wrong-password',
]);
}
$response->assertStatus(429);
});
Testing Authorization Boundaries
it('prevents a user from accessing another user\'s order', function () {
$owner = User::factory()->create();
$intruder = User::factory()->create();
$order = Order::factory()->create(['user_id' => $owner->id]);
$this->actingAs($intruder, 'sanctum')
->getJson("/api/orders/{$order->id}")
->assertStatus(403);
});
Running Feature Tests Against a Real Database in CI
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: secret
ports: ['5432:5432']
steps:
- uses: actions/checkout@v4
- run: composer install
- run: php artisan migrate --force
- run: php artisan test --parallel
Keeping Test Suites Fast
- Use
RefreshDatabasewith transactions rather than migrating fresh for every test - Run independent test files in parallel with
--parallel - Avoid hitting real external APIs — fake them with
Http::fake()even in feature tests
Conclusion
End-to-end API tests catch the class of bugs unit tests miss entirely: broken response shapes, missing authorization checks, and side effects that don’t match what the response claims happened. Run them against a real database in CI, not just locally, so they reflect production behavior.