Should I test that?

Should I test edge cases that can't happen?

Verdict

No

Do not write tests for an input that no caller can produce; make the code reject that input loudly through a type or a guard that throws, and test the check that rules the input out.

Why

Do not test an input that no caller can produce; make the code reject it loudly and test the check that rules it out. The typical case is a defensive branch, such as a null check on a NOT NULL column or an empty list that every caller filters out. Blast radius is users, and Change frequency is rarely, because the input arrives only after someone changes the code that rules it out. Detectability is same-day, because the input usually ends in an exception that the error tracker reports. Reversibility is trivial when the crash stores nothing, and Test cost is moderate, because the test must fake a state the real code never produces, so rule R13 gives Do not test.

When the decision changes
WhenDecisionWhy
The input comes from outside your code: a request body, an uploaded file, or another team's serviceTest: one test per malformed input, asserting that nothing is storedChange frequency rises to regularly, Detectability to eventually and Reversibility to with-effort: any client can send the input, and a stored bad value raises no error
The impossible case is a refund larger than its paymentTest mandatory: assert that a refund above the payment amount is refusedBlast radius rises to money and Reversibility to costly: an overpaid refund needs a manual claw-back
The impossible case is a query that returns another tenant's recordTest mandatory: request another tenant's record and assert a denialBlast radius rises to safety-or-legal and Detectability to never: a leaked record raises no error
The branch returns a fallback such as 0 instead of failingTest minimally: one test that asserts the fallback valueDetectability rises to eventually: a review count of 0 looks like a real value
A type rules the case out, such as a non-null type or a non-empty list typeDo not test the case; the build rejects any caller that could produce itDetectability falls to immediately with Reversibility trivial, so rule R8 keeps Do not test
The case is a full disk halfway through a nightly export that customers downloadTest the full-disk case differently: alert when the row count of an export differs from the sourceTest cost rises to heavy and Detectability to eventually: faking a full disk takes days, and a truncated file raises no error

What breaks if you don't test

Nothing breaks until someone adds a caller that breaks the assumption. A new admin screen loads orders without a customer, the code throws a null reference error on the first request, and you fix it the same day.

What you lose if you over-test

The test has to build the impossible state by hand, such as a mock repository that returns null for a NOT NULL column. It then pins behaviour that nobody decided: someone picks what the average of zero ratings is, and the test turns the guess into a spec. The mocks tie the test to internals, so refactors that change no behaviour break it. When a coverage target demands the branch, mark it with coverage.py's # pragma: no cover instead.

What to do instead

  1. Put the assumption in a type: a non-null type, a non-empty list type, or an exhaustive switch that ends in never (TypeScript, Python's assert_never). A caller that breaks the assumption then fails the build.
  2. Where no type can hold the assumption, add a guard that throws an error naming it. The guard needs no test.
  3. Test the check that makes the case impossible: the request validation, the database constraint, or the constructor.

When the answer changes

  • The input starts to arrive from outside your code, such as an API or an old app version.
  • The impossible case would move money or show one customer's data to another.
  • The code handles the case with a silent fallback instead of an error.

Real incident + Code example

The average of no ratings

On an online shop I worked on, averageRating(reviews) divided the sum of stars by reviews.length, and every caller showed it only for products with reviews. I wrote no test for an empty list, because it could only have pinned what the function returned, NaN. A year later a colleague added a "top rated" sort that called it for every product. In JavaScript 0 / 0 is NaN, and a sort comparator that returns NaN counts two items as equal, so unrated products landed at random places. A merchandiser asked about it three weeks later. The fix moved the assumption into the type:

type NonEmpty<T> = [T, ...T[]];

function isNonEmpty<T>(items: T[]): items is NonEmpty<T> {
  return items.length > 0;
}

function averageRating(reviews: NonEmpty<Review>): number {
  const total = reviews.reduce((sum, r) => sum + r.stars, 0);
  return total / reviews.length;
}

// A plain Review[] no longer compiles here, so the sort
// has to decide what an unrated product scores.
const score = isNonEmpty(product.reviews) ? averageRating(product.reviews) : 0;

No test covers the empty list, because no code that compiles can pass one.

FAQ

Should I unit test functions for edge cases that won't occur?

No, skip unit tests for an edge case that no caller in your application can produce, and put the assumption in a type or a guard that throws. Test the edge case when the function is public in a library, because then you do not know every caller.

Should tests test for impossible situations?

No, a test for an impossible situation fakes a state the real code never produces and pins a result that nobody decided. Make the situation fail the build through a type, and test the validation or constraint that makes it impossible.

Is a unit test for an unlikely scenario worth the trouble?

Yes, test an unlikely scenario when a unit test can produce it cheaply, because a one-in-a-million input arrives about once a day at a million requests a day. When producing it takes days, as with a full disk mid-write, alert on its trace in production.