The Testing Pyramid in Laravel
The testing pyramid is simple: lots of fast unit tests at the bottom, fewer integration tests in the middle, and a handful of end-to-end tests at the top. But in Laravel, the lines between these layers blur — and that's actually fine.
Laravel's feature tests (which hit your routes with a fake HTTP request) are the sweet spot. They test your controllers, middleware, validation, authorization, database queries, and business logic all at once. They're fast enough to run on every commit, and thorough enough to catch real bugs.
Feature Tests: Your Primary Testing Strategy
class CreateOrderTest extends TestCase
{
use RefreshDatabase;
public function test_authenticated_user_can_create_an_order(): void
{
$user = User::factory()->create();
$product = Product::factory()->create(['price' => 2999, 'stock' => 10]);
$response = $this->actingAs($user)
->postJson('/api/orders', [
'items' => [
['product_id' => $product->id, 'quantity' => 2],
],
'shipping_address' => '123 Main St',
]);
$response
->assertStatus(201)
->assertJsonStructure([
'data' => ['id', 'total', 'status', 'items'],
])
->assertJsonPath('data.total', 5998)
->assertJsonPath('data.status', 'pending');
$this->assertDatabaseHas('orders', [
'user_id' => $user->id,
'total' => 5998,
]);
$this->assertDatabaseHas('order_items', [
'product_id' => $product->id,
'quantity' => 2,
]);
// Verify stock was decremented
$this->assertEquals(8, $product->fresh()->stock);
}
public function test_guest_cannot_create_an_order(): void
{
$response = $this->postJson('/api/orders', [
'items' => [['product_id' => 1, 'quantity' => 1]],
]);
$response->assertStatus(401);
}
public function test_validation_rejects_empty_items(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)
->postJson('/api/orders', [
'items' => [],
]);
$response
->assertStatus(422)
->assertJsonValidationErrors(['items']);
}
public function test_cannot_order_more_than_available_stock(): void
{
$user = User::factory()->create();
$product = Product::factory()->create(['stock' => 2]);
$response = $this->actingAs($user)
->postJson('/api/orders', [
'items' => [
['product_id' => $product->id, 'quantity' => 5],
],
]);
$response->assertStatus(422);
$this->assertDatabaseCount('orders', 0);
}
}
Testing External Services
Never call real external APIs in tests. Use fakes, mocks, or Laravel's built-in faking:
class PaymentProcessingTest extends TestCase
{
use RefreshDatabase;
public function test_successful_payment_creates_transaction(): void
{
// Arrange: fake the payment gateway
$this->mock(PaymentGateway::class, function ($mock) {
$mock->shouldReceive('charge')
->once()
->with(
Mockery::on(fn (Money $m) => $m->inCents() === 5000),
Mockery::type(PaymentMethod::class)
)
->andReturn(PaymentResult::success('txn_abc123', new Money(5000)));
});
$user = User::factory()->create();
$order = Order::factory()->for($user)->create(['total' => 5000]);
// Act
$response = $this->actingAs($user)
->postJson("/api/orders/{$order->id}/pay", [
'payment_method' => 'pm_test_visa',
]);
// Assert
$response->assertStatus(200);
$this->assertEquals('paid', $order->fresh()->status);
}
}
// Testing email sending
class WelcomeEmailTest extends TestCase
{
public function test_welcome_email_is_sent_on_registration(): void
{
Mail::fake();
$this->postJson('/api/register', [
'name' => 'John Doe',
'email' => 'john@example.com',
'password' => 'password123',
'password_confirmation' => 'password123',
]);
Mail::assertSent(WelcomeEmail::class, function ($mail) {
return $mail->hasTo('john@example.com');
});
}
}
// Testing queued jobs
class OrderProcessingTest extends TestCase
{
public function test_order_creation_dispatches_sync_job(): void
{
Queue::fake();
// ... create order
Queue::assertPushed(SyncOrderToErp::class, function ($job) use ($order) {
return $job->orderId === $order->id;
});
}
}
Database Testing Strategies
// RefreshDatabase: migrates once, wraps each test in a transaction
// Best for most test suites
class MyTest extends TestCase
{
use RefreshDatabase;
}
// Factories: generate realistic test data
class UserFactory extends Factory
{
public function definition(): array
{
return [
'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(),
'password' => Hash::make('password'),
];
}
// State methods for specific scenarios
public function admin(): self
{
return $this->state(['role' => 'admin']);
}
public function suspended(): self
{
return $this->state(['suspended_at' => now()]);
}
}
// Usage in tests
$admin = User::factory()->admin()->create();
$suspended = User::factory()->suspended()->create();
$users = User::factory()->count(50)->create();
Testing Authorization
class AdminAccessTest extends TestCase
{
use RefreshDatabase;
public function test_admin_can_delete_users(): void
{
$admin = User::factory()->admin()->create();
$user = User::factory()->create();
$response = $this->actingAs($admin)
->deleteJson("/api/users/{$user->id}");
$response->assertStatus(204);
$this->assertSoftDeleted('users', ['id' => $user->id]);
}
public function test_regular_user_cannot_delete_users(): void
{
$user = User::factory()->create();
$otherUser = User::factory()->create();
$response = $this->actingAs($user)
->deleteJson("/api/users/{$otherUser->id}");
$response->assertStatus(403);
$this->assertDatabaseHas('users', ['id' => $otherUser->id]);
}
}
What to Test (And What to Skip)
- Always test: Critical business logic, authorization rules, validation rules, edge cases in calculations, API contracts
- Usually test: Complex database queries, job/event dispatch, notification sending
- Skip: Framework internals, simple getters/setters, Eloquent relationship definitions, obvious code that the framework guarantees
Making Tests Maintainable
Write tests that describe behavior, not implementation. If you refactor the internals and your tests break even though the behavior didn't change, your tests are too tightly coupled to the implementation.
- Name tests with
test_user_can_do_somethingformat — they read like documentation - Use the AAA pattern: Arrange, Act, Assert — clearly separated
- Don't test private methods directly — test them through the public API
- Keep each test independent — no test should depend on another test's state
- Run your tests on CI with every push. Tests that don't run regularly become stale.