Matthew Boston

Test the Sad Path

August 13, 2024

By the time you finish a feature, you’ve already tested the happy path. You ran it in the console or clicked through it in the browser and watched it work. The paths you didn’t run are the ones that need tests: the else branch, the other side of a ternary, the default case of a case statement, and the error handler. Nobody has ever executed that code, which is why the bugs are there.

Line coverage hides half the branch

ruby def shipping_cost_cents(order) order.total_cents >= 5_000 ? 0 : 799 end

One test with a $100 order runs this line, and a line coverage report marks it green. The $7.99 side has never executed. It could hold the wrong constant, or a dollar amount where the caller expects cents, and the report would still say 100%.

Branch coverage counts each side of a conditional separately. Most tools support it. SimpleCov turns it on with enable_coverage :branch, and Istanbul (which powers Jest’s coverage) reports a branch column by default. Turn it on, and the gaps in a codebase you thought was well covered tend to show up fast.

Then test the boundary. An order of exactly $50.00 is the one that tells you whether the code says >= or >, and that’s the mistake you’re most likely to make.

Read a coverage report for its list of lines and branches that never ran. The percentage at the top matters much less than what’s on that list.

The default case that can’t happen

ruby case payment.status when "succeeded" then fulfill(order) when "failed" then notify_customer(order) end

Two statuses, two branches, two tests. Then the payment provider adds "disputed". This case returns nil, nothing gets fulfilled, nobody gets notified, and no error shows up anywhere. The order sits there until a customer emails support.

Write the default case even when you think it’s unreachable, and make it loud:

ruby case payment.status when "succeeded" then fulfill(order) when "failed" then notify_customer(order) else raise ArgumentError, "unknown payment status: #{payment.status}" end

Then test it:

```ruby it “raises on a payment status it doesn’t recognize” do order = Order.new payment = Payment.new(status: “disputed”)

expect { handle_payment(order, payment) } .to raise_error(ArgumentError, /disputed/) end ```

That test records an assumption (these are the only two statuses) and proves the code fails fast when the assumption breaks. An exception in your error tracker on the first disputed payment beats a silent nil you find out about weeks later.

Error handling runs at the worst time

The rescue block is some of the least-executed code in any system, and it only runs when something else has already gone wrong. That’s a bad moment to discover it references a variable that’s nil on this path, or swallows the exception it was supposed to report.

You don’t have to wait for production to break to exercise it. Make the dependency fail on purpose:

```ruby it “queues the charge for retry when the gateway times out” do allow(gateway).to receive(:charge).and_raise(Timeout::Error)

checkout.complete(order)

expect(order.reload.status).to eq(“payment_pending”) expect(RetryChargeJob).to have_been_enqueued.with(order.id) end ```

Do the same for the other ways a dependency lets you down: a 500, an empty response body, a malformed payload, a record deleted between the read and the write. Each of those is a branch in your code whether you wrote it as one or not. If you didn’t write handling for it, the branch still exists, and the language’s default behavior (usually an unhandled exception) is what you shipped.

Fewer branches, fewer tests

Every branch you write is a test you owe. Nested conditionals multiply the debt. Three independent conditions can produce eight distinct paths through a function, and reaching the innermost one means setting up every condition above it. That’s one of the practical arguments for flat code. Guard clauses turn a pyramid of nested paths into a straight line of early returns, and each return gets its own small test.

If a branch is awkward to test, ask whether anything ever reaches it. Code written for a case nobody has yet is future-proofing you still have to test today, and deleting it is often cheaper than covering it.

Before you call a feature done, list every way through it. Each path needs a test, or a reason to delete it.