← Recipes

阅读中文版本

Write Unit Tests for Error Paths First

Inventory failure modes per dependency, force each one in a test, and assert the observable behavior users see when things go wrong.

~30 mintestingunit-testingresilience

Task

Cover the failure branches of a feature, forcing dependency errors and asserting the resulting user-visible behavior.

When to use this

Coverage reports show 90 percent on the payment module, yet a provider timeout in production rendered a blank screen: the happy-path tests never failed a dependency.

Prerequisites

  • A unit test suite with mocking capability
  • The list of external dependencies of the target module
  • A definition of correct degraded behavior per failure

Steps

1. List every failure mode of every dependency.

// payment.ts depends on:
// gateway.charge: network error, 5xx, timeout, 429, malformed body
// repo.save: constraint violation, connection lost
// cache.get: miss, eviction, down

Expected output: A concrete failure-mode inventory replaces vague error handling with a testable checklist.

2. Force each failure in an isolated test.

it("renders retry-able state when the gateway times out", async () => {
  gateway.charge.mockRejectedValue(new TimeoutError("gateway"));
  const state = await checkout(cart);
  expect(state.status).toBe("retryable");
  expect(state.message).toMatch(/try again/i);
});

Expected output: The timeout branch executes with a deterministic trigger, and the assertion pins the user-visible contract.

3. Assert observable behavior, not exception types.

it("keeps the cart when the save fails", async () => {
  repo.save.mockRejectedValue(new ConnectionError());
  const state = await checkout(cart);
  expect(state.cart).toEqual(cart);          // nothing lost
  expect(state.enqueued).toBe(true);         // work preserved
});

Expected output: The test describes what the user keeps and what the system does next, which is the actual product behavior.

4. Cover the failure-of-failure path.

it("surfaces a failure id when logging also fails", async () => {
  gateway.charge.mockRejectedValue(new Error("boom"));
  logger.log.mockRejectedValue(new Error("log-down"));
  const state = await checkout(cart);
  expect(state.traceId).toBeTruthy();   // correlation survives
});

Expected output: Even when reporting itself is broken, the user receives an id support can correlate.

5. Track error-path coverage explicitly.

npx vitest run --coverage 2>/dev/null | grep -E "File|payment" 

Expected output: Branch coverage on the module climbs, and the uncovered list shrinks to genuinely unreachable defensive code.

Troubleshooting

Problem

Mocks drift from real error shapes

Solution

Contract-test the dependency or capture real error objects in a recording pass, and replay those shapes in the mocks.

Problem

Error branches are unreachable by design

Solution

The code swallows errors internally; refactor toward returned states or rethrown typed errors so behavior is assertable at all.

Problem

Team writes only happy paths by habit

Solution

Make the failure inventory a review checklist item: a PR adding a dependency without its failure tests does not merge.

Limitations

  • Unit-level forcing verifies handling logic; integration-level failure injection is still needed for real error shapes.
  • Error-path tests encode intended degradation; if no one decided what timeout means for UX, the tests expose that gap loudly.

The branches nobody tests are the ones users hit

Happy paths get tested because they are easy to think about; failure branches ship untested because they are easy to forget. The fix is mechanical: inventory every dependency, enumerate its failure modes, force each one in a test, and assert what the user observes. Blank screens happen when nobody ever decided what a timeout should render, and the test forces that decision into code.

it("renders retryable state when the gateway times out", async () => {
  gateway.charge.mockRejectedValue(new TimeoutError("gateway"));
  const state = await checkout(cart);
  expect(state.status).toBe("retryable");
  expect(state.message).toMatch(/try again/i);
});

Assert the contract, not the plumbing

Asserting that a TimeoutError was thrown tests the implementation, not the product. Asserting status is retryable and the message says try again pins the user contract, and it survives refactors that change how errors flow internally. The question each test answers is: what does the user see, keep, and lose.

The inventory is the deliverable

The failure-mode list outlives the tests: it is the review artifact for every future dependency addition, and the checklist auditors actually want. A PR that adds a third-party SDK without appending its failure modes to the inventory and tests for each is visibly incomplete, which is how the habit becomes systemic.