Testing Laravel APIs End-to-End: HTTP Tests, Database Assertions, and CI Integration

A practical guide to testing Laravel applications with Pest, covering feature tests, factories, and mocking.

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 RefreshDatabase with 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.