home / blog / Testing Strategies in Laravel: Write Tes...
Laravel Feb 10, 2026

Testing Strategies in Laravel: Write Tests That Actually Catch Bugs

A practical testing guide that goes beyond "assert true is true" — integration tests, feature tests, test doubles, and the testing pyramid applied to Laravel.

Testing Strategies in Laravel: Write Tests That Actually Catch Bugs

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_something format — 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.
back to all posts