Skip to content
All posts

Your Test Suite Is Green. Your Bug Is Already in Production.

May 29, 2026·Read on Medium·

Coverage percentage tells you how much code your tests touch, not whether your system works.

There is a very specific kind of engineering confidence that exists only in the few minutes between “all tests pass” and “this goes to production.” It feels like certainty. It is not. Most teams discover this at the worst possible time, usually during an incident, usually after someone has already asked why the tests didn’t catch it.

The honest answer is that the tests did exactly what they were designed to do. They verified that the code you wrote behaves like the code you wrote, under conditions you controlled, using dependencies you faked. What they didn’t verify is whether any of that corresponds to how the system actually behaves when real users interact with it.

This is the fundamental lie of the coverage percentage. It tells you how many lines of code your tests execute. It says nothing about whether those lines of code matter, whether the behavior they implement is correct or whether the boundaries between your components will hold when you stop mocking them.

Coverage Is a Proxy Metric That Became the Goal

Coverage percentage exists because it is easy to measure. You can see it in CI, track it over time and set a threshold. Teams adopt it for the same reason teams adopt any metric: it is concrete, comparable and gives management something to put in a dashboard.

The problem is that proxy metrics have a way of becoming the goal rather than the indicator. Once you’re targeting 80% coverage or 90% coverage, you’re optimizing for a number rather than for confidence in your system. And optimizing for a number is something developers are very good at. You can write a test that executes every line of a function without testing a single meaningful behavior. A test that calls your order processing function with a fixed input and asserts that no exception was thrown will contribute to your coverage report. It will not catch the edge case that crashes your checkout flow at 11pm on a Friday.

// This test hits 100% branch coverage of the method.
// It tests almost nothing.
it('runs process without throwing an exception', function () {
$order = Order::factory()->make();

expect(app(OrderService::class)->process($order))->toBeNull();
});

// This tests behavior that actually matters.
it('prevents order confirmation when the item is out of stock', function () {
$product = Product::factory()->outOfStock()->create();
$order = Order::factory()->withProduct($product)->create();
$result = app(OrderService::class)->process($order);
expect($result->status)->toBe(OrderStatus::FAILED)
->and($result->failureReason)->toBe('item_unavailable');
assertDatabaseMissing('inventory_reservations', ['order_id' => $order->id]);
});

Both tests land in your coverage report. Only one of them has any chance of catching a real bug.

The Mocking Problem

Unit tests mock dependencies. That is the point. You isolate the unit, replace its collaborators with fakes and verify the unit’s behavior in isolation. It is a reasonable approach for pure logic: transformations, calculations, domain rules that do not depend on external state.

The problem starts when you apply the same pattern to code that is fundamentally about interaction. Persistence logic, payment processing, notification dispatch, external API calls. When you mock the database in a test for your repository class, you are no longer testing your repository class. You are testing that your repository class correctly calls the mock you set up. The mock always behaves exactly as specified, which means the test passes regardless of whether the actual database interaction works.

// Testing the mock, not the behavior
it('saves the user', function () {
mock(UserRepository::class)
->shouldReceive('save')
->once()
->andReturn(true);

$result = app(UserService::class)->createUser([
'name' => 'Hafiq',
'email' => 'hafiq@example.com',
]);
expect($result)->toBeTrue();
});

This test will pass even if your createUser method has a bug that corrupts the email field. It will pass even if the save call never actually runs because you changed the method signature last week. It will pass if the validation logic is completely broken. You have tested that when save is called it returns true. That is not the same as testing that users get saved correctly.

The coverage number goes up. Your confidence in user creation should not.

What Actually Causes Production Bugs

If you look at production incidents across backend systems, the failure mode is rarely “a method returned the wrong value in isolation.” The failure modes are almost always about what happens at boundaries.

The database query that works fine in your test environment times out in production because nobody seeded the test database with 800,000 rows. The API call that returns a 200 in tests returns a 202 with a completely different response body in production because the third-party docs were out of date and nobody tested against the real thing. The event listener that processes order completions works fine in tests but throws on a field that exists in production data but not in your factory.

These are integration failures. They happen at the joints between components, not inside individual methods. Unit tests with mocks are specifically designed to eliminate those joints from consideration. You get confidence in the parts. You get no information about whether the parts fit together.

Laravel’s own documentation takes a clear position on this. In the official testing guide, the framework states that “most of your tests should be feature tests.” Feature tests in Laravel make real HTTP requests, run against a real database (typically in-memory or transactional) and execute the full middleware and service provider stack. They are slower than unit tests and they are also the category of test most likely to catch what actually breaks in production.

Feature Tests Are Not Enough Either

Here is where engineers who have already moved toward feature tests sometimes get a false sense of security. Feature tests are better than unit tests with mocks for catching integration failures. They are still running against your test environment, your test database seeding and your factory-generated data. They will not catch the bug that only appears with a specific shape of real production data.

The class of bugs that feature tests miss tend to be:

Data migration regressions, where production data has a shape your tests never generated. A nullable column that was supposed to be non-nullable but got migrated inconsistently in the original production deployment. A JSON field that has been written in three different formats over the lifetime of the application because the schema evolved without a migration.

Third-party API contract violations. The integration test hits your mock server. The mock server was configured three months ago. The third-party API changed a field from a string to an array two months ago. Tests pass. Production fails.

Load-related state corruption. A race condition in your queue processing logic that only surfaces when two jobs for the same user run concurrently. Feature tests run sequentially. The test never surfaces it. A traffic spike on a Monday morning does.

// Feature test: better than unit tests with mocks
it('sends an invoice when a subscription is renewed', function () {
$user = User::factory()->withActiveSubscription()->create();

Mail::fake();
$this->post('/api/subscriptions/renew', ['user_id' => $user->id])
->assertStatus(200);
Mail::assertSent(InvoiceEmail::class, fn ($mail) => $mail->hasTo($user->email));
});


// But this test doesn't cover:
// - What happens if the payment gateway returns a 402 instead of 200
// - What happens if two renewal requests arrive for the same user simultaneously
// - What happens if the user's subscription record has a legacy format from 2022

The feature test is valuable. It is not a guarantee.

Building a Test Suite Worth Trusting

The goal of testing is not coverage. It is confidence calibrated to actual risk. Not every path through your codebase has the same consequence if it fails. A bug in your admin report generation is unpleasant. A bug in your billing logic is an incident with a postmortem.

A test suite worth trusting has a few properties.

It tests behavior, not implementation. When a function’s internals change but its contract stays the same, tests should keep passing. When the contract changes, tests should break. If your tests break every time you refactor without changing behavior, you have written tests that are coupled to implementation details rather than requirements.

// Coupled to implementation: breaks on every refactor
it('calls getUserTier on the repository', function () {
mock(UserRepository::class)
->shouldReceive('getUserTier')
->once()
->with($this->userId);
// ...
});

// Coupled to behavior: survives refactoring
it('gives premium users a 20 percent discount', function () {
$user = User::factory()->premiumTier()->create();
$discount = app(PricingService::class)->calculateDiscount($user, 100.00);
expect($discount)->toBe(20.00);
});

It exercises real boundaries where the cost of failure is high. Payments, external API calls, email dispatch, database writes on critical paths. These should run against real infrastructure in your test environment, not mocks. Slow tests that verify real behavior are worth more than fast tests that verify mocks.

It has a layer for the things that cannot be tested in isolation. A smoke test suite that runs against a staging environment with production-like data before every deployment. A set of contract tests that verify your assumptions about third-party APIs have not changed. These catch the category of bugs that local tests structurally cannot.

It turns every production bug into a permanent test. When something breaks in production, the first step after the fix is a test that would have caught it. That test stays in the suite forever. Over time this creates a test suite that is weighted toward the actual failure modes of your specific system rather than the theoretical failure modes of a generic system.

The Coverage Number to Actually Track

If you must track a coverage number, track something that corresponds to risk rather than volume. Branch coverage of your payment processing module. Statement coverage of your authentication and authorization paths. Mutation score on your core domain logic, which measures whether your tests are sensitive enough to catch small behavioral changes.

Mutation testing tools introduce deliberate small faults into your code (changing a > to a >=, flipping a boolean, removing a return statement) and check whether your tests catch them. A test suite with 90% line coverage and a 40% mutation score is telling you that more than half of the mutations in your codebase go undetected. Your tests execute the code, but they are not sensitive to whether the code is correct.

# Infection PHP: mutation testing for Laravel applications
composer require --dev infection/infection

vendor/bin/infection --threads=4 --min-msi=70 --min-covered-msi=80

A mutation score of 70% on your critical paths is a more meaningful signal than 90% line coverage across the whole application. It means your tests can actually tell the difference between correct and incorrect behavior in the places where being wrong has consequences.

The Uncomfortable Conclusion

The reason teams optimize for coverage instead of confidence is that coverage is easy to measure and confidence is not. You can not put “trust in the test suite” on a dashboard. You can put 89.3% coverage on a dashboard and argue about whether it should be 90%.

The discomfort is that building a test suite you can actually trust requires judgment. You have to decide which behaviors matter. You have to be honest about which tests are verifying real behavior versus giving you something to point at during code review. You have to accept that a smaller number of tests covering the right things is more valuable than a large number of tests covering everything lightly.

One engineering team that made this shift deliberately reduced their test count by over two-thirds while moving to behavior-focused integration testing. Deployment confidence went up, not down, because the remaining tests actually verified things that could break in production.

The test suite that gives you confidence is not the one with the highest coverage number. It is the one where, when everything goes green, you can say with a straight face that what you are about to deploy works.

Most test suites cannot say that. The coverage report will not tell you which kind you have.

Found this helpful?

If this article saved you time or solved a problem, consider supporting — it helps keep the writing going.

Originally published on Medium.

View on Medium
Your Test Suite Is Green. Your Bug Is Already in Production. — Hafiq Iqmal — Hafiq Iqmal