Ch. 22 · Software Testing

Testing Error Paths

Test failures and edge inputs deliberately, assert the error type and message, and stop the happy path from hiding bugs.

~2 min readintermediateupdated Oct 5, 2026

Most bugs live on the paths nobody tests: the empty input, the timeout, the failed dependency. Testing only the happy path leaves error handling unverified, and error handling is exactly where production incidents happen.

Before you start

You should be comfortable with assertions and exceptions. This article covers error-path testing.

Step-by-step walkthrough

Step 1: Enumerate the failure modes

For each function, list how it can fail: invalid input, missing data, a dependency error, a timeout, a partial write. Each is a case to test, and enumerating them makes the gaps visible rather than assumed.

Step 2: Assert the error type and message

A test for a failure should assert the specific error, not merely that something threw. Checking the type and a meaningful message confirms the code fails the intended way and gives a useful signal when it changes. Some frameworks offer expect(fn).toThrow(ErrorType).

Step 3: Test recovery, not just propagation

Where the code recovers — a retry, a fallback, a default — test that the recovery happens and produces the right result. Error handling that recovers incorrectly is as dangerous as handling that crashes.

Worked scenario

The test asserts the specific error and the fallback.

test('rejects a negative amount', () => {
  expect(() => withdraw(-5)).toThrow('amount must be positive');
});
test('falls back to cached value on failure', async () => {
  const value = await getWithFallback(failingLoader, 'cached');
  expect(value).toBe('cached');
});
JavaScript

Walk through the example

The first test checks that invalid input raises the expected error with a specific message, so a change to the message or the error type fails the test. The second verifies the recovery path returns the fallback, which the happy-path test never exercises.

Common mistake

Testing only valid input, so error branches are never run, or asserting only that a call throws without checking the type or message, which passes for the wrong error. Another is a try/catch that swallows the error, hiding the failure the test should catch.

Verify the behavior

Introduce a bug in an error branch and confirm a test fails. Assert the error type and message, then change the message and confirm the test catches it. Confirm recovery paths are covered and not just the throw.

Interview exercise

Why assert the error type rather than just that it threw?

Answer and reasoning

Because many things can throw, including a TypeError from a bug in the code itself, so “something threw” passes even when the code fails for the wrong reason. Asserting the specific type and message confirms the intended error path ran. It also makes the test fail when the error contract changes, which is a useful signal.

Continue learning

Compare negative tests in Security negative tests and boundaries in Boundary values. Read the xUnit patterns on error path tests and try the Testing interview questions.

More in Software Testing

read ✓Software Testing · hard

Testing Time with an Injected Clock

Inject a clock instead of calling the system time, freeze it in tests, and remove the sleeps that make tests flaky.

~2 min readread →
read ✓Software Testing · mid

Code Coverage and Its Limits

Read line and branch coverage as a signal, not a goal, and avoid the tests that chase the number without checking behavior.

~2 min readread →
read ✓Software Testing · hard

Scoping End-to-End Tests

Reserve end-to-end tests for critical user journeys, use stable selectors, and avoid duplicating coverage the lower tests already have.

~2 min readread →
esc