Nuno Silva

May 28, 2026 • 8 min read

The Happy Path Test Suite

Why 90% Coverage Can Still Miss the Bug

The Happy Path Test Suite

High coverage and a green CI pipeline are reassuring right up until an incident happens that none of the tests predicted. The team has unit tests, mock MVC tests, a coverage dashboard showing 90-something percent — and there's no test to point to.

The tests aren't missing so much as they're selective. They cover what the developer had in mind when writing the feature — the user exists, the payment clears, everything saves cleanly. What they skip is what happens when those assumptions don't hold, which is most of what actually breaks.


Testing What You Hope Will Happen

Happy path tests are easy to write and easy to justify. Take a checkout flow:

@Test
void checkoutCreatesOrderSuccessfully() {
 Cart cart = new Cart(List.of(new CartItem("book", 1)));
 User user = new User("user-123");

 when(inventoryService.reserve(cart)).thenReturn(true);
 when(paymentGateway.charge(user, cart.total())).thenReturn(PaymentResult.success());

 Order order = checkoutService.checkout(user, cart);

 assertEquals(OrderStatus.CONFIRMED, order.getStatus());
 verify(orderRepository).save(order);
}

This test isn't wrong — it's just incomplete.

But look at what it never asks. What happens if inventory reservation succeeds but payment fails? What if payment succeeds but saving the order throws? What if the payment gateway times out after charging the card — the payment succeeded on the gateway's side, but no order was created? What if the user clicks checkout twice? What if the cart total changed between validation and payment?

Those aren't edge cases. Those are the scenarios that determine whether the system is actually trustworthy, or just lucky when conditions are calm.


The Coverage Number Is Lying to You

Coverage is useful for one thing: spotting areas nobody's testing at all. Low coverage is a red flag worth acting on.

But high coverage doesn't mean the system is well tested — it means lines were executed. Happy path tests are very good at running nearly every line of code while completely avoiding the most important failure scenarios. Here's the service behind that checkout test:

public Order checkout(User user, Cart cart) {
 if (!inventoryService.reserve(cart)) {
 throw new InventoryException("Failed to reserve inventory for cart");
 }

 PaymentResult payment = paymentGateway.charge(user, cart.total());
 if (!payment.isSuccessful()) {
 throw new PaymentFailedException(payment.reason());
 }

 Order order = Order.confirmed(user, cart);
 return orderRepository.save(order);
}

One success test runs through almost every line. Coverage looks great. But the most important question the code raises hasn't been asked at all: if payment fails, does the inventory reservation get released?

It doesn't. And your 90% coverage number won't tell you that, because coverage tracks execution, not consequence. It can't tell you whether the system preserved an invariant or quietly left things in a broken state. A covered bug is still a bug.


Stop Testing Methods, Start Testing Failure Modes

The instinct is to think about tests in terms of methods — test checkout(), test cancelOrder(), test calculateDiscount(). Reasonable starting point, but it's not where you want to stop.

Production incidents don't usually happen because a method was never called. They happen because a specific condition wasn't accounted for: the dependency returned something unexpected, or threw an exception after a side effect had already occurred, or two requests arrived at the same moment and both passed a check that assumed they wouldn't. The entity was in a state the code assumed was impossible.

So a test list for checkout that looks like this:

checkout creates order
checkout saves order
checkout returns confirmation

...is telling you almost nothing useful. It should look more like:

checkout releases inventory when payment is declined
checkout does not charge twice when retried with same idempotency key
checkout marks order pending review when payment succeeds but confirmation is delayed
checkout fails fast when cart is empty
checkout rejects request when price changed between cart validation and payment

These tests aren't just exercising code paths — they're defending business rules. There's a real difference between a suite that confirms behaviour and one that actively protects the system from the failure modes you already know about.


Mocks Make It Easy to Avoid the Hard Questions

Mocking isn't the problem. Used well, it isolates behaviour and keeps tests fast. The issue is more subtle: mocks make the happy path so effortless to set up that the failure paths never get written.

when(paymentGateway.charge(any(), any())).thenReturn(PaymentResult.success());
when(inventoryService.reserve(any())).thenReturn(true);
when(orderRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0));

Three lines and every dependency is perfectly behaved. The test passes, coverage goes up, and it genuinely feels done — because for the happy path, it is.

The trouble is that real dependencies aren't this cooperative. They time out. They return malformed responses. They throw exceptions after partially completing work — which is the nastiest kind of failure, because now some state has changed and some hasn't, and you have to figure out which. When every mock in your suite returns the perfect answer, you're not modelling the system. You're modelling your hopes for it.

Making dependencies misbehave on purpose is where things get more interesting:

@Test
void checkoutReleasesInventoryWhenPaymentFails() {
 Cart cart = new Cart(List.of(new CartItem("book", 1)));
 User user = new User("user-123");

 when(inventoryService.reserve(cart)).thenReturn(true);
 when(paymentGateway.charge(user, cart.total()))
 .thenReturn(PaymentResult.declined("insufficient_funds"));

 assertThrows(PaymentFailedException.class,
 () -> checkoutService.checkout(user, cart));

 verify(inventoryService).release(cart);
 verify(orderRepository, never()).save(any());
}

Run this against the implementation shown earlier and it fails — release() is never called. Which is exactly what you want. The test isn't claiming the system already handles this; it's making sure the system can't ship without handling it.

There's a useful signal buried in how hard these tests are to write. If setting up a failure test requires untangling a mess of hidden dependencies or figuring out who actually owns the recovery logic — that pain is information. It's telling you something about the design that the happy path test was happy to ignore.


What Must Never Happen

The most useful tests I've seen in production codebases don't start from "what should this method return?" They start from a harder question: what must never happen?

Naming your invariants explicitly changes the way you think about what the tests are actually for. For a checkout system:

  • Never charge the customer twice for the same order

  • Never confirm an order without reserved inventory

  • Never leave inventory reserved if the order was not confirmed

  • Never expose one customer's order data in another's session

For an authentication system:

  • Never authenticate a locked account, regardless of correct credentials

  • Never reveal through timing or error message whether an email address exists in the system

  • Never allow a password reset token to be used more than once

For a billing system:

  • Never generate two invoices for the same billing period

  • Never apply a discount after tax if the business rule requires before tax

  • Never retry a non-idempotent payment call without an idempotency key

With the invariant named, the test mostly writes itself. Here charge() takes a third argument — an idempotency key added to the API specifically to enforce the "never charge twice" rule:

@Test
void checkoutDoesNotChargeTwiceForSameIdempotencyKey() {
 String idempotencyKey = "checkout-abc-123";

 checkoutService.checkout(user, cart, idempotencyKey);
 checkoutService.checkout(user, cart, idempotencyKey);

 verify(paymentGateway, times(1))
 .charge(eq(user), eq(cart.total()), eq(idempotencyKey));
 verify(orderRepository, times(1))
 .save(any());
}

This is the kind of test that prevents incidents rather than documenting that things went fine. It encodes a business promise directly into the suite, and it'll catch regressions that refactors and interface changes would otherwise introduce silently.

These tests also tend to age better than most. Implementations change, internal APIs get rewritten, the code looks completely different a year later — but the invariant stays. A test built around what must never happen is more durable than one built around how something currently works.


Write These Tests While the Context Is Fresh

The most common reason suites end up happy-path-heavy isn't that developers don't know better. It's that the failure tests always get deferred.

Someone implements the feature, writes the success case to prove it works, the build goes green, the PR goes up. At that point, adding failure tests feels like extra work on something that's already done. So it gets noted as a follow-up, or added to the backlog, and then quietly never happens.

What actually helps is writing failure tests while you're already in the code, right after the happy path passes. At that point you know the dependencies, you have the context, and asking "what could go wrong here?" takes minutes. Waiting until later means reconstructing all of that — and usually just not coming back to it.

Most features have two or three scenarios that would genuinely hurt if unhandled. Write for those while you're already there.


What to Look For When Reviewing

When reviewing someone else's tests, the coverage number is the least useful thing to look at. More useful:

  • What happens when each dependency fails?

  • What happens if the same request comes in twice?

  • What happens if the entity is in the wrong state?

  • What needs to be rolled back or released if this operation fails partway through?

  • What business rule, if violated, would cause the most damage?

If the tests can't answer most of those, the suite needs some failure path coverage. That's not a knock on the team — it's just where tests tend to stop when there's deadline pressure or the feature feels "done" after the happy path passes. You don't need hundreds of new tests. You need the right few.

Test the blast radius, not every raindrop.


Write for Reality, Not the Demo

A green build is reassuring, but a suite that only proves the system works in good conditions is only doing half the job. The other half is proving it behaves honestly when conditions aren't good — that it cleans up after itself, keeps its promises under pressure, and doesn't leave customers charged without orders or inventory locked for no reason.

Coverage tells you which lines ran. Failure tests tell you whether the system can survive reality.

So the next time you finish a test, before moving on — ask the question production will eventually ask for you: what happens when it doesn't?

Join Nuno on Peerlist!

Join amazing folks like Nuno and thousands of other builders on Peerlist.

peerlist.io/

It’s available... this username is available! 😃

Claim your username before it's too late!

This username is already taken, you’re a little late.😐

0

0

0