← Back to writing

2026-09-06

The Bug That Only Shows Up When Things Go Wrong

Early on, I tested APIs mostly the way most people do: send a valid request, check that the response looks right. Valid login, valid order, valid form submission. If those passed, I called the endpoint "covered."

The bug that changed my mind had nothing to do with valid requests. A payment API was rejecting expired cards correctly, but instead of returning a clear error, it returned a generic 500 with no useful message. The app caught that 500 and showed users "Something went wrong, please try again," so people with expired cards kept retrying the exact same failing card, assuming it was a glitch on our end instead of their card.

Nothing in the test suite caught it, because nothing in the test suite was testing for it. Every test sent a valid card and checked for success.

The happy path is the easy 20%

Sending a good request and confirming a good response is the simplest kind of test to write, which is exactly why it gets written first and often gets written only. But a real user doesn't only send valid requests. They submit forms with a missing field, hit an endpoint with an expired token, send a request twice by double-clicking, or run into a downstream service that's temporarily down. How an API behaves in those moments is usually more consequential to the user's experience than how it behaves when everything goes right.

So now, for any endpoint, I ask a short list of questions before I consider it tested: what does this return for a missing or malformed field? What does it return for an expired or invalid token? What does it return if the same request is sent twice? And critically, is the error message something a user or a developer could actually act on, or is it a generic failure that hides what really happened?

What actually changed

For that payment endpoint, the fix was simple once someone actually looked: return a specific 402 with a clear "card expired" message instead of a bare 500. The real lesson wasn't about that one endpoint, though. It was that "the tests are passing" and "the important scenarios are tested" are not the same claim, and it's easy to mistake one for the other when every test in the suite is shaped the same way.

Now, for every new endpoint, I write the failure cases before I write the happy path, not after, when it's tempting to skip them because the demo already works. It's a small change in order, but it's the difference between finding that kind of bug in a code review and finding it in a support queue full of confused customers.