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');
});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.