0

Testing strategy for a Laravel API — what do you test and how?

Advanced5 min read·eng-10-010
interview

Concept

Testing strategy for a Laravel API — what to test, at what level, and how to structure your test suite.

The test pyramid:

  • Unit tests (many): Test individual classes in isolation. Pure PHP classes, no framework, no database. Fast (milliseconds). Test domain logic, value objects, algorithms.
  • Feature / Integration tests (the core): Test complete HTTP requests through the full stack — routing, middleware, controller, service, database. Use RefreshDatabase. This is Laravel's sweet spot.
  • Browser / E2E tests (few): Test the full UI flow. Dusk. Slow, brittle. Only for critical user paths.

What to test in a Laravel API:

  • Happy path: Valid input → expected response + expected database state.
  • Validation: Invalid input → 422 Unprocessable Entity with proper error structure.
  • Authentication: Unauthenticated request → 401. Wrong user's resource → 403.
  • Not found: Missing resource → 404.
  • Business rules: Domain constraints (insufficient funds, out of stock).
  • Side effects: Email sent, event fired, job dispatched (via fakes).
  • Pagination: Correct page structure.

Key helpers:

  • $this->getJson('/api/orders') / postJson() / putJson() / deleteJson().
  • $this->actingAs($user) — authenticate as a user.
  • $response->assertStatus(201)->assertJsonStructure(['id', 'total']).
  • $this->assertDatabaseHas('orders', ['user_id' => $user->id]).
  • Mail::fake() / Event::fake() / Queue::fake() / Notification::fake().

Factory pattern: Use Model Factories to create test data. User::factory()->create(), Order::factory()->for($user)->has(OrderItem::factory()->count(3))->create().

Code Example

php
<?php
use App\Models\{User, Order, Product};
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Testing\Fluent\AssertableJson;

class OrderApiTest extends \Tests\TestCase
{
    use RefreshDatabase; // wraps each test in a transaction + rolls back

    // HAPPY PATH
    public function test_authenticated_user_can_create_order(): void
    {
        $user    = User::factory()->create();
        $product = Product::factory()->create(['price' => 25.00, 'stock' => 10]);

        $response = $this->actingAs($user)->postJson('/api/orders', [
            'items' => [['product_id' => $product->id, 'quantity' => 2]],
        ]);

        $response->assertStatus(201)
            ->assertJson(fn(AssertableJson $json) => $json
                ->has('id')
                ->where('total', 50.00)
                ->where('status', 'pending')
                ->etc()
            );

        $this->assertDatabaseHas('orders', ['user_id' => $user->id, 'total' => 50.00]);
        $this->assertDatabaseHas('order_items', ['product_id' => $product->id, 'quantity' => 2]);
    }

    // VALIDATION
    public function test_order_requires_at_least_one_item(): void
    {
        $this->actingAs(User::factory()->create())
            ->postJson('/api/orders', ['items' => []])
            ->assertStatus(422)
            ->assertJsonValidationErrors(['items']);
    }

    // AUTHENTICATION
    public function test_unauthenticated_user_cannot_create_order(): void
    {
        $this->postJson('/api/orders', [])->assertStatus(401);
    }

    // AUTHORIZATION (IDOR prevention)
    public function test_user_cannot_view_another_users_order(): void
    {
        $ownerOrder   = Order::factory()->create(); // belongs to someone else
        $requestingUser = User::factory()->create();

        $this->actingAs($requestingUser)
            ->getJson("/api/orders/{$ownerOrder->id}")
            ->assertStatus(403);  // or 404 — depends on your policy
    }

    // SIDE EFFECTS
    public function test_order_creation_dispatches_confirmation_email(): void
    {
        \Mail::fake(); // swap real mailer with fake

        $user = User::factory()->create();
        $this->actingAs($user)->postJson('/api/orders', [...]);

        \Mail::assertQueued(\App\Mail\OrderConfirmation::class, fn($mail) =>
            $mail->hasTo($user->email)
        );
    }

    // BUSINESS RULE
    public function test_cannot_order_more_than_available_stock(): void
    {
        $product = Product::factory()->create(['stock' => 2]);
        $user    = User::factory()->create();

        $this->actingAs($user)
            ->postJson('/api/orders', ['items' => [['product_id' => $product->id, 'quantity' => 5]]])
            ->assertStatus(422)
            ->assertJsonFragment(['message' => 'Insufficient stock']);
    }
}