Testing Laravel without mocking everything
Published April 11, 2026 · by Majd Ghithan, written with the help of AI
I inherited a codebase once with 900 unit tests and no confidence. Every test mocked the repository, mocked the model, mocked the mailer, mocked the thing three layers down. They all passed. The app was still broken in production, because the mocks described how the author thought the pieces fit together, not how they actually did. The tests were testing the mocks.
The instinct to mock everything comes from a good place — isolation, speed — but in a Laravel app it usually buys you neither. It buys you tests that are green and useless. Here's how I test instead.
Feature tests are the workhorse
A Laravel feature test hits a real route, through real middleware, into a real controller, against a real (test) database, and asserts on the real response. That's not a slow integration test to avoid — in Laravel it's fast, and it's the test that actually proves the feature works.
it('places a booking and emails the guest', function () {
$user = User::factory()->create();
$room = Room::factory()->create();
actingAs($user)
->post('/bookings', [
'room_id' => $room->id,
'starts_at' => now()->addDay()->toDateTimeString(),
'ends_at' => now()->addDay()->addHour()->toDateTimeString(),
'guest_email' => 'GUEST@Example.com',
])
->assertRedirect();
expect(Booking::where('room_id', $room->id)->exists())->toBeTrue();
});
This one test exercises the route, the Form Request, the normalization, the controller, the Action, and the database write — the whole path. No mocks. If any layer is wired wrong, it fails. That's the confidence the 900 mocked tests never gave me.
The pyramid, honestly
The classic advice is "lots of unit tests, few integration tests." In a framework-heavy app I flip the proportions toward feature tests, because the framework is most of your logic and you want it in the loop. Fast unit tests still matter — for pure logic with lots of branches, a pricing calculator, a date-range overlap, a state machine. Those I test in isolation because there's real combinatorial logic and no I/O.
The point of the chart isn't the exact numbers. It's that the confidence lives in the feature tests, the speed lives in the unit tests, and mocking is a thin sliver you reach for only when you have to.
Factories and states carry the setup
The reason feature tests stay readable is factories. Don't hand-build models in tests — describe the state you need and let the factory fill the rest.
class BookingFactory extends Factory
{
public function definition(): array
{
return [
'room_id' => Room::factory(),
'starts_at' => now()->addDay(),
'ends_at' => now()->addDay()->addHour(),
'status' => 'confirmed',
];
}
public function cancelled(): static
{
return $this->state(['status' => 'cancelled']);
}
}
Now a test that needs a cancelled booking says exactly that: Booking::factory()->cancelled()->create(). The intent is in the test; the noise is in the factory.
When to actually mock
Mock at the edges you don't own or can't control. Two cases, mostly:
External APIs. You do not want your test suite calling Stripe. Fake the boundary, and Laravel gives you a real fake for HTTP:
Http::fake([
'api.stripe.com/*' => Http::response(['id' => 'ch_123'], 200),
]);
// ...run the code that charges...
Http::assertSent(fn ($req) =>
$req->url() === 'https://api.stripe.com/v1/charges'
);
Time. Anything that depends on "now" is a flaky test waiting to happen. Freeze it instead of mocking a clock:
$this->travelTo(now()->parse('2026-12-25 10:00'), function () {
// the December discount branch, tested deterministically
});
Notice these aren't mocks of your classes. They're fakes of the outside world — the network, the clock. That's the line. Mock what you don't control; use the real thing for what you wrote.
$repo = Mockery::mock(
OrderRepository::class
);
$repo->shouldReceive('save')
->once()
->andReturn($order);
// green, but proves nothing
// about the real save()
$order = Order::factory()
->create();
expect(Order::find($order->id))
->status->toBe('paid');
// hits the real DB;
// fails if save() is broken
Pest makes it pleasant
I write all of this in Pest now. The it()/expect() syntax reads like a sentence, datasets kill repetitive cases, and higher-order tests trim the boilerplate. But Pest is the syntax, not the strategy. The strategy is: prove the feature with a real request, isolate the gnarly pure logic, and fake only the network and the clock.
The test suite you want isn't the one with the most tests. It's the one where a green run actually means the thing works — and you only get that by testing the real thing more than you test your idea of it.
