Skip to content

Happy Path vs Sad Path

The adversarial mindset taught you to stop asking “does it work?” and start asking “how does it break?” This page takes that instinct and gives it a shape. Almost every operation your software performs has one way to succeed and many ways to fail — and the tragedy of most codebases is that the one success case is lovingly tested while the many failures are never exercised at all.

That imbalance has a name. The single route where the inputs are valid, the resources are present, and nothing goes wrong is the happy path. Every other route — the ones where something is missing, denied, malformed, or too slow — is a sad path. Earning justified confidence means testing both, and it turns out the sad paths are where the confidence is hardest to earn and most often faked.

Concretely, for any operation:

  • The happy path is the execution where the caller gave valid input, every resource the operation needs is available, every permission is granted, and every downstream call returns in time. The operation does its job and returns success.
  • A sad path is any execution that deviates from that: the input was garbage, a file was missing, a lock was held, a network call timed out, the disk filled up halfway through. The operation cannot do its job, and now the only question is how gracefully it declines.

That phrase — how gracefully it declines — is the whole reason sad paths deserve tests as much as the happy one. A sad path is not the absence of behaviour; it is behaviour. Returning a clear error, leaving the system unchanged, logging enough to diagnose the problem, and telling the caller in a way they can act on are all outputs of the operation, just as much as the success value is. An untested sad path is an untested output, and “the code compiled” tells you nothing about whether that output is correct.

Consider a two-line operation: “read a config file and parse it as JSON.” The happy path is one sentence. The sad paths are a list:

Operation: load_config(path) -> Config
HAPPY PATH (1)
file exists, is readable, contains valid JSON matching the schema -> Config
SAD PATHS (many)
path does not exist -> file-not-found error
path exists but is a directory -> is-a-directory error
no read permission -> permission-denied error
file is empty -> parse error
file is valid JSON, wrong shape-> schema error (missing field)
file is 4 GB of JSON -> out-of-memory / must stream
file changed between stat/open -> race, stale handle
disk I/O error mid-read -> partial read

One happy path. Eight sad paths — and that list is not exhaustive. This asymmetry is the central fact of this page: success is a point, failure is a space. A test suite that covers only the happy path has covered one point in a space of dozens.

The word “path” is doing real work here. Think of the operation as a control-flow graph: it enters at the top, and every if, every ?, every try/catch, every early return is a fork. The happy path is one specific route from entrance to a successful exit. Every fork you didn’t take on that route is a branch you also have to reach — and most of those branches exist precisely to handle a failure. So “test the sad paths” is, mechanically, the same instruction as “cover the branches your happy-path test skipped.” The two ideas are the same idea seen from two angles: one from the caller’s intent, one from the code’s structure.

Why does the count come out so lopsided? Because success requires everything to go right simultaneously, while failure requires only one thing to go wrong. Success is a conjunction (A and B and C and ...); each sad path is the negation of one term. If an operation depends on five preconditions, you get one happy path and at least five distinct sad paths — usually more, because a single precondition (“the input is valid”) can fail in many different ways, each deserving its own error and its own test.

This is why “it works” is such a weak claim. It means “the one conjunction I tried came out true.” It says nothing about the far larger space where one term is false.

There is a demo-day version of this failure that every engineer has lived through. The feature is shown off on a clean machine, with prepared inputs, over a fast connection, by the person who built it — the platonic happy path. Everyone nods. Then a real user, on a flaky network, pastes an emoji into a field expecting an integer, and the whole thing falls over. Nothing was lied about; the demo did work. It just proved a single point in a space the developer never explored. Justified confidence is confidence about the space, not the point.

You do not have to imagine failures from a blank page. Relying on inspiration is exactly how sad paths get missed: you remember the failures you have personally been burned by and forget the rest. Failures instead cluster into a small number of recurring sources, and walking the list turns sad-path discovery from inspiration into a checklist — the same reason pilots use a checklist rather than trusting memory before takeoff. For any operation, ask which of these apply:

The caller handed you something the operation cannot accept: wrong type, wrong shape, out of range, malformed encoding, an injection payload, a value that is syntactically fine but semantically impossible (a birth date in the future, a negative quantity). This is the source you can provoke most cheaply, so there is no excuse for skipping it. It is also the largest source by count — a single “the input is valid” precondition shatters into dozens of distinct ways to be invalid, which is why the next three pages in this Part exist to map it in detail.

Something the operation needs is not there: a file, a database row, a config key, an environment variable, a network endpoint. The resource may never have existed, or it may have vanished between the moment you checked and the moment you used it.

The resource exists but you are not allowed to touch it: a file you cannot read, an API that returns 403, a database user without INSERT, an OS call that needs privileges you dropped. Permission failures are especially easy to miss because they usually pass in a developer’s all-powerful local environment and only appear in the locked-down one.

A dependency is present and willing but too slow: a network call that hangs, a lock held by another thread, a query that never returns. Timeouts are the failures developers forget most, because on a fast local machine everything returns instantly and the timeout branch is never taken. To test them you usually have to simulate slowness — a stub that sleeps, a lock deliberately held — because you cannot rely on the real world being slow on cue. The test that matters here asserts not just that the operation eventually fails, but that it fails within a bounded time: a happy path that would have succeeded in 50 ms must not become an operation that hangs for 30 seconds when its dependency stalls.

The worst category, and the most important. The operation started, did some of its work, and then failed — leaving the system in a half-changed state. Money debited but not credited. Row written but index not updated. Three of five files copied. Partial failure is where “how gracefully it declines” becomes “does it corrupt data,” and it is where the concepts of atomicity and cleanup earn their keep.

Partial failure is also the hardest sad path to provoke in a test, because you have to make the operation fail at a precise midpoint rather than at the door. That difficulty is exactly why it is the most valuable one to write: the tests that are hard to set up are usually guarding the failures that are hard to recover from. The usual technique is fault injection — hand the operation a dependency that succeeds for the first N calls and then fails on purpose — so you can assert what state the system is left in when the failure lands between two writes. If your answer to “what happens if it fails after step 3 of 5?” is a shrug, you have found the sad path most likely to cost you real data.

bad input missing resource denied permission
\ | /
\ | /
+------> the operation you test <----+
/ | \
/ | \
timeout partial failure (combinations)

Run any operation through those six lenses and the sad-path tests almost write themselves. Most of the “boundary” and “invalid input” techniques in the rest of this Part — zero/one/many, empty/null/boundaries, extremes and overflow — are really just detailed maps of the “bad input” source above. And ordering and duplicates is largely a map of the partial failure and timeout sources, because those are where the order events arrive in starts to matter. When you graduate to exploratory testing, this same six-source list is the systematic scaffolding underneath what would otherwise look like unstructured poking.

Under the hood — where each source lives in the call

Section titled “Under the hood — where each source lives in the call”

The six sources are not arbitrary; they line up with the distinct stages an operation passes through, which is why the checklist is exhaustive rather than a grab-bag. An operation, at the level of the machine, does roughly this:

1. receive arguments <- BAD INPUT enters here
2. authorize the caller <- DENIED PERMISSION shows up here
3. locate its resources <- MISSING RESOURCE shows up here
4. call out to dependencies <- TIMEOUT / SLOWNESS happens here
5. mutate state (N steps) <- PARTIAL FAILURE happens between step k and k+1
6. return a result

Read top to bottom, the sources are simply “what can go wrong at each stage.” Bad input is a stage-1 problem. Permission and existence are stage-2 and stage-3 gates. Timeouts are a stage-4 property of anything you don’t control. And partial failure is the unique hazard of stage 5, where the operation has already changed something before it fails — which is why it is the only source that can leave durable damage behind. Mapping your operation onto these six stages tells you not just that it can fail but where, and “where” usually tells you what the correct handling is: reject early (stages 1–3), retry or fall back (stage 4), or roll back / make idempotent (stage 5).

Here is the technique that changes how you build, not just how you verify: write the sad-path test before you write the code.

If you write the happy-path test first, you will implement the happy path, watch it go green, and feel finished. The error handling gets bolted on afterward — if at all — under time pressure, with no test forcing its shape. But if you write the failing case first, you cannot make it pass without designing the error into the interface. The test asks “what does this operation do when the file is missing?” and now you must answer: does it return a Result::Err, throw a typed exception, return null? The test forces the error into the design instead of leaving it as an afterthought.

This is why sad-path-first is a design technique disguised as a testing technique. The moment you write assert Err(NotFound), you have made three decisions that would otherwise have been made accidentally: that missing-file is a recoverable error and not a crash, that it is distinct from a parse error, and that the caller learns about it through the return type rather than a side channel like a global or a log line. Those are interface decisions, and the sad-path test is what forced you to make them deliberately, at the best possible time — before any caller depends on the wrong answer.

Look at how the repo’s own key-value store test pins down the sad path right next to the happy one — the “not found” reply is asserted as deliberately as the success:

assert_eq!(round_trip(&mut reader, &mut writer, "GET name"), "NIL"); // missing key: sad path
assert_eq!(round_trip(&mut reader, &mut writer, "SET name ada"), "OK"); // happy path
assert_eq!(round_trip(&mut reader, &mut writer, "DEL name"), "DELETED"); // happy path
assert_eq!(round_trip(&mut reader, &mut writer, "DEL name"), "NOT_FOUND"); // delete twice: sad path

GET on a missing key and a second DEL on an already-deleted key are sad paths, and they are asserted with the same precision as SET ... OK. The behaviour of failure is specified, not left to chance. That is what writing the sad-path test buys you: the error becomes part of the contract.

Notice a subtle thing in that snippet: the sad paths there return distinct, meaningful replies — NIL for a read miss, NOT_FOUND for a delete miss — not a single generic ERROR. A design that collapses every failure into one opaque error is a design that never had to think about its sad paths individually. Writing each sad-path test first pressures you toward distinguishable failures, because a test that asserts a specific error message cannot pass against code that throws the same thing for everything.

A useful rule of thumb: for every happy-path test you write, write at least one sad-path test for each precondition the operation depends on. If that feels like a lot of tests, that is the asymmetry telling you the truth about how your code will actually be used.

Concretely, the loop looks like this:

  • Name the failure — pick one source (bad input, missing resource, …) and state the specific bad condition.
  • Write the assertion — what error, what state, what timing should the operation produce? Write it as a failing test.
  • Watch it fail correctly — the test must fail because the behaviour is missing, not because of a typo. A test that passes before you write the code is testing nothing.
  • Implement the decline — add exactly enough handling to make the assertion true.
  • Move to the next source — repeat until every applicable source has a green sad-path test.

A worked example — turning the checklist into tests

Section titled “A worked example — turning the checklist into tests”

Take the load_config operation from the top of the page and walk the six sources. Each row is a test you can write before you write a single line of the loader:

source provoke it by... assert...
------------------ --------------------------------- --------------------------
(happy) valid file, valid JSON returns Ok(Config)
bad input file contains `{ not json` returns Err(Parse)
bad input valid JSON, missing `port` field returns Err(Schema)
missing resource path points at nothing returns Err(NotFound)
missing resource path is a directory returns Err(IsADir)
denied permission chmod 000 the file, then load returns Err(Permission)
timeout / slowness config is a slow network mount returns Err(Timeout) < 5s
partial failure truncate file mid-read (inject) returns Err(Io), no panic

Eight rows, one of them the happy path. You wrote the failure design before the implementation existed, which means the implementation now has no choice but to satisfy it. This is the whole discipline in one table: enumerate the sources, provoke each one, assert the decline.

Spotting happy-path bias in an existing suite

Section titled “Spotting happy-path bias in an existing suite”

You can audit a suite you inherited for this bias in about a minute. Scan the test names. If almost every test reads like test_creates_user, test_returns_total, test_saves_file — verbs of success — and hardly any read like test_rejects_duplicate_email, test_errors_on_missing_file, test_times_out — verbs of decline — the suite is a happy-path suite wearing a coverage badge. A healthy suite has more sad-path names than happy-path names for any operation with real preconditions, because the sad paths outnumber the happy one. The ratio of “declines gracefully” tests to “does its job” tests is a fast, honest proxy for how much of the real behaviour space the suite actually pins down.

Why untested error handling is the buggiest code

Section titled “Why untested error handling is the buggiest code”

There is a vicious feedback loop hiding here. Error-handling code is, by definition, the code that runs least often in normal operation. Code that runs rarely gets exercised rarely, so bugs in it are discovered rarely — until the rare condition finally occurs in production, usually during an incident, which is precisely the worst moment for your recovery code to itself be broken.

So the sad path is doubly cursed: it is the code most likely to be under-tested, and it is the code that runs exactly when everything else is already on fire. A bug in the happy path shows up on day one when someone uses the feature. A bug in the catch block or the retry logic or the rollback can lie dormant for a year and then detonate under load. Untested error handling is not merely incomplete — it is a liability that looks like an asset, because it appears to handle errors while having never once been proven to.

The failure mode has a recognizable signature. It is the catch block that swallows the exception and logs nothing. It is the retry loop that retries a non-idempotent write and double-charges the customer. It is the cleanup code in a finally that itself throws and masks the original error. It is the fallback that returns stale data forever because nobody tested that the primary ever comes back. Every one of these is code that reads as if the author considered failure — and none of it was ever executed by a test, so “considered” is the most you can say. Coverage tools make this visible in the cruelest way: the branches with the lowest coverage in almost any codebase are the error branches, the exact ones you most need to trust.

  • Why does it exist? Because software’s behaviour is dominated by failure modes, not success modes — and a mindset that only names the success case leaves most of the real behaviour undesigned and untested.
  • What problem does it solve? It converts “handle errors somehow” — a vague, skippable intention — into a concrete, enumerable checklist of sad paths, each of which becomes a specific test.
  • What are the trade-offs? Sad-path testing multiplies your test count and forces error design up front, which feels slower early on; you trade speed-of-first-draft for confidence that the code behaves under the conditions it will actually meet.
  • When should I avoid it? Never wholesale, but you can prioritize: for a throwaway script whose only failure mode is “it didn’t run,” exhaustively enumerating partial-failure paths is wasted effort. Match the depth of sad-path coverage to the cost of the failure.
  • What breaks if I remove it? You ship code whose error handling has never executed. It looks robust and is not — and it fails first, silently, at the worst possible moment, exactly as in the Ariane 5 case.
  1. In one sentence each, define the happy path and a sad path for a generic operation.
  2. Why does a single operation almost always have one happy path but many sad paths? Frame your answer in terms of conjunctions and negations.
  3. List the sad-path sources from this page and give a concrete example failure for each.
  4. What is the practical benefit of writing the sad-path test before the implementation, rather than after?
  5. Explain the feedback loop that makes untested error-handling code the buggiest code in a system, using the Ariane 5 incident as an illustration.
Show answers
  1. The happy path is the execution where input is valid, every needed resource is available, every permission is granted, and no dependency times out, so the operation succeeds. A sad path is any execution that deviates from that — bad input, a missing or denied resource, a timeout, or a partial failure — where the operation cannot do its job and must decline.
  2. Success is a conjunction: every precondition must hold simultaneously (A and B and C). Each sad path is the negation of one term, so a five-precondition operation yields one success but at least five distinct failures — more, since one precondition (e.g. “input is valid”) can fail in many different ways.
  3. Bad input (negative quantity, malformed JSON); missing resources (config file that doesn’t exist); denied permissions (a 403 from an API, unreadable file); timeouts/slowness (a hung network call, a held lock); partial failure (money debited but not credited). Any concrete example per source is fine.
  4. Writing the sad path first makes the test fail until you design the error into the interface — it forces you to decide the return type / exception / contract for failure up front, instead of bolting error handling on afterward (or not at all) once the happy path is already green.
  5. Error-handling code runs least often, so it is exercised and debugged least, so its bugs stay hidden — until the rare condition finally occurs, which is usually during an incident when everything else is already failing. Ariane 5’s overflow-handling path was unreachable on Ariane 4 and never provoked; when a precondition changed on Ariane 5, that never-tested sad path executed for the first time in flight and destroyed the vehicle.