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']);
}
}