Asserting the unhappy path
Writing Vitest failure-path tests that assert a lib function's errors by class and code rather than by message.
A function’s contract has two halves. Take parseInvoiceInput, a /lib validator: “returns the parsed invoice on valid input” is half of what it promises, and “throws ValidationError on garbage input” is the other half. Most test suites assert the first half and leave the second blank, and that blank is exactly where bugs accumulate, because the failure path is the half nobody made the runner check.
Here is a test for parseInvoiceInput. It passes, but it covers only half the contract:
it('returns a parsed invoice on valid input', () => { const result = parseInvoiceInput({ amount: 4200, currency: 'eur' }); expect(result).toEqual({ amount: 4200, currency: 'EUR' });});What happens when amount is negative? When currency is 'xyz'? The function has documented answers, since it throws in both cases, but the test verifies none of them. The suite checks the success path and lets the failure path through unread.
This lesson gives you the discipline and the matchers to assert failure as deliberately as success. By the end you’ll be able to write the failure test for any /lib export, whether the failure is a thrown error, a returned Result.err, a Zod issue, or a re-wrapped cause. You’ll choose the matcher by the shape of the failure and assert only the part of the error that is a promise to callers. This builds directly on the two-path rule from the testing-strategy lessons of the previous chapter; here it stops being a slogan and becomes something you can apply by hand.
Every behavior gets two tests
Section titled “Every behavior gets two tests”One rule organizes the whole lesson, and every technique below follows from it: every behavior earns at least two tests, one for success and one for the documented failure. You met this as the two-path rule when you learned the shape of a test suite in the previous chapter. Applying it is mechanical. A function with a documented failure mode gets two it blocks in the same describe, written to be read top to bottom:
describe('parseInvoiceInput', () => { it('returns a parsed invoice on valid input', () => { // ... });
it('throws ValidationError on a negative amount', () => { // ... });});Read those two it titles aloud and you hear the contract from both sides: on success it returns the parsed invoice, and on failure it throws ValidationError. The file now documents what the function promises in both directions, to anyone who opens it.
One anti-pattern is worth naming now, because it is tempting and wrong: do not test success and failure in a single it. The moment one it exercises both paths, a red bar no longer tells you which half broke, and you have to read through the test to find out whether the parse or the throw regressed. Two behaviors, two blocks. Everything below is a technique for filling in that second block.
Asserting thrown errors with toThrow
Section titled “Asserting thrown errors with toThrow”When the failure is a thrown error, the matcher is toThrow. It has four forms that trade looseness for precision, ordered here from loosest to tightest:
expect(() => parseInvoiceInput(bad)).toThrow();expect(() => parseInvoiceInput(bad)).toThrow(ValidationError);expect(() => parseInvoiceInput(bad)).toThrow('amount must be positive');expect(() => parseInvoiceInput(bad)).toThrow(/amount/i);The first asserts only that something threw. The second asserts the thrown value is an instance of the ValidationError class. The third looks like a full-string match but is not: a string argument matches if it is a substring of the error’s message, so 'amount must be positive' passes against 'Validation failed: amount must be positive'. The fourth runs a regex against the message. Keep track of which form you reach for and why, because the next section returns to that choice: two of these four pin the message, and the message is the wrong thing to pin.
First, a mistake that’s easy to make. Look at the argument to expect in every line above. It’s an arrow function, () => parseInvoiceInput(bad), not parseInvoiceInput(bad). That arrow is not decoration. It is the single most common toThrow mistake, and getting it wrong doesn’t fail loudly; it makes the test stop being a test at all.
expect(parseInvoiceInput(bad)).toThrow();The assertion does nothing. parseInvoiceInput(bad) is evaluated first, to produce the value handed to expect. It throws synchronously at that point, before expect is ever called, so the error propagates straight out of the test body. The test errors out, or depending on the runner records a failure for the wrong reason, and toThrow never gets to run.
expect(() => parseInvoiceInput(bad)).toThrow();The call happens where the assertion can see it. The arrow wraps the call without running it. expect receives a function, and toThrow invokes it inside its own try/catch, catches the throw, and checks it. Any time you write toThrow, write () => in the same motion.
What makes this mistake hard to spot is that the broken version doesn’t read as obviously wrong, and on a good day the test even goes red, just for the wrong reason and at the wrong line. The rule to remember is simple: toThrow always takes a function.
Class and code over message
Section titled “Class and code over message”Here is the central idea of the lesson. Two of the four toThrow forms above pin the message, and the message is the worst thing to pin.
Look at the same failure tested two ways:
expect(() => parseInvoiceInput(bad)).toThrow( 'Email must be a valid RFC 5322 address',);Brittle. This test breaks the day a product manager redrafts the copy, the day the string is templated per locale, or the day someone fixes a typo. None of those is a behavior change, since the function still rejects the same input the same way, yet the test goes red on all of them. You’ve coupled the test to wording that exists for humans and is supposed to change.
expect(() => parseInvoiceInput(bad)).toThrow(ValidationError);Stable. The class is the part of the error that is a promise to callers: the discriminant a catch block branches on. It changes only when the behavior changes. The message can be reworded into ten languages and this test never notices. When the contract also includes a structured code, you assert that too, and the next section shows how to reach the thrown value to check it.
The rule worth keeping is this: messages are user-facing and mutable; classes and codes are the contract. This is the same split the code conventions draw between the two audiences of an error: the user reads userMessage, and the operator reads the full chain in the logs. A test is neither audience. A test verifies the contract, so it asserts the machine-readable half and ignores the prose.
This one rule governs almost everything left in the lesson. When we assert a Result.err in a moment, we’ll match its code, not its userMessage. When we assert a Zod failure, we’ll match the issue’s code and path, not its message. Same rule, three shapes.
Inspecting the thrown error directly
Section titled “Inspecting the thrown error directly”toThrow(ValidationError) is enough when the class is the whole story. But a well-built domain error carries more: a code, a fieldErrors map, a cause. To assert several of those at once you need the error value itself, and toThrow doesn’t hand it to you. A deliberate try/catch does:
it('throws ValidationError with a validation code on a bad amount', () => { try { parseInvoiceInput({ amount: -1, currency: 'eur' }); expect.fail('expected parseInvoiceInput to throw'); } catch (error) { expect(error).toBeInstanceOf(ValidationError); expect(error).toMatchObject({ code: 'validation' }); }});The action: call the function with input it must reject. If the function behaves and throws, the next line never runs and control jumps to the catch.
it('throws ValidationError with a validation code on a bad amount', () => { try { parseInvoiceInput({ amount: -1, currency: 'eur' }); expect.fail('expected parseInvoiceInput to throw'); } catch (error) { expect(error).toBeInstanceOf(ValidationError); expect(error).toMatchObject({ code: 'validation' }); }});The guard, and the part that’s easy to miss. expect.fail(...) throws immediately, so if parseInvoiceInput didn’t throw, control reaches this line and the test fails with a clear message. Without it, a function that quietly stops throwing would skip the catch entirely and the test would pass having asserted nothing, a silent false negative.
it('throws ValidationError with a validation code on a bad amount', () => { try { parseInvoiceInput({ amount: -1, currency: 'eur' }); expect.fail('expected parseInvoiceInput to throw'); } catch (error) { expect(error).toBeInstanceOf(ValidationError); expect(error).toMatchObject({ code: 'validation' }); }});The catch: now you hold the thrown value and can check as many fields as the contract documents, the class via toBeInstanceOf and the stable code via toMatchObject. Assert the contract surface, never the message.
The expect.fail('...') on the line after the call is the piece beginners leave out, and leaving it out is dangerous precisely because nothing complains. Picture the regression: someone changes parseInvoiceInput so it no longer throws on a negative amount. Your test calls it, no error is thrown, the catch block never runs, and the it reaches the end without a failed assertion, so it passes. The bug shipped, and the test that should have caught it now vouches for the broken code. expect.fail closes that hole: if the throw doesn’t happen, the test says so.
This is the synchronous shape. The async version is identical except you await the call inside the try. That mechanism, along with expect.assertions(n) for tests with branches, belongs to the previous lesson on async tests, so reach for them there rather than re-deriving them here.
Asserting Result.err shape
Section titled “Asserting Result.err shape”Throwing is for the impossible: programmer errors and unreachable paths. The expected failures in a SaaS /lib, such as a validation miss, a uniqueness conflict, or a missing record, don’t throw. They come back as a value, the Result<T> you’ve been using since the forms-and-validation chapter. As a reminder, its failure branch is { ok: false, error: { code, userMessage, fieldErrors? } }, where code is one of a small set of lowercase-snake strings: 'validation', 'conflict', 'not_found', and so on.
Because the failure is a returned object rather than a thrown one, the matcher changes. toThrow has nothing to catch, so you assert against the object directly. The choice of which object matcher is the same brittleness lesson as message versus code, in a new form:
expect(result).toEqual({ ok: false, error: { code: 'not_found', userMessage: 'Invoice not found.' },});Brittle. toEqual is an exhaustive deep match: it asserts the object has these fields and no others. The day someone adds an error.traceId for log correlation, every error test written this way goes red at once, none of them because a behavior changed. It also forces you to spell out userMessage, the wording you just learned not to pin, only to satisfy the matcher.
expect(result).toMatchObject({ ok: false, error: { code: 'not_found' },});Stable. toMatchObject is a partial deep match: it asserts the fields you name and ignores the rest. You pin the discriminator (ok: false) and the code, the two things that are the contract, and stay silent about userMessage, traceId, and whatever else the error grows later. The test verifies what it cares about and nothing more.
This is the same idea as the class-over-message section, drawn one level out: a good failure test asserts a subset, the contract, and stays quiet about the incidental. toEqual forces you to know and restate every field, which couples the test to fields it has no opinion about. toMatchObject lets you name only the promise.
Sometimes a field’s presence matters but its value can’t be pinned: fieldErrors will be populated, but the exact strings are wording. For those, Vitest’s asymmetric matchers slot into the expected object:
expect(result).toMatchObject({ ok: false, error: { code: 'validation', fieldErrors: expect.any(Object), },});expect.any(Object) asserts the field exists and is an object without caring what’s in it, and expect.objectContaining({ ... }) does the same for a nested subset. They’re the escape hatch for “this must be here, but its contents are not the contract.”
Custom matchers for Result: toBeOkResult and toBeErrResult
Section titled “Custom matchers for Result: toBeOkResult and toBeErrResult”You wrote toMatchObject({ ok: false, error: { code } }) once in the last section. You’ll write it again in the next test, and the one after that, and in every /lib file that returns a Result. When you learned the daily test shape you met the threshold for promoting a comparison into a custom matcher: the same assertion written three or more times across files earns one. The Result discriminator check is the textbook case, since it’s the single most repeated assertion in a Result-based codebase. That lesson named toBeOkResult and toBeErrResult and promised the implementation later. Here it is.
A custom matcher is a function you register with expect.extend. It receives the value under test, decides whether it passes, and supplies the message shown on failure. That last part is what makes the matcher worth writing. Let’s walk the implementation that lives at src/test/matchers/result.ts:
import { expect } from 'vitest';import type { Result } from '@/lib/result';
expect.extend({ toBeErrResult(received: Result<unknown>, expectedCode?: string) { const isErr = received.ok === false; const codeMatches = expectedCode === undefined || received.error?.code === expectedCode; const pass = isErr && codeMatches;
return { pass, message: () => pass ? `expected result not to be an err${ expectedCode ? ` with code '${expectedCode}'` : '' }` : `expected an err result${ expectedCode ? ` with code '${expectedCode}'` : '' }, but got ${JSON.stringify(received)}`, }; },});Register the matcher with expect.extend. The key becomes the matcher name, received is the value expect(...) wrapped, and expectedCode is the optional argument the call site can pass.
import { expect } from 'vitest';import type { Result } from '@/lib/result';
expect.extend({ toBeErrResult(received: Result<unknown>, expectedCode?: string) { const isErr = received.ok === false; const codeMatches = expectedCode === undefined || received.error?.code === expectedCode; const pass = isErr && codeMatches;
return { pass, message: () => pass ? `expected result not to be an err${ expectedCode ? ` with code '${expectedCode}'` : '' }` : `expected an err result${ expectedCode ? ` with code '${expectedCode}'` : '' }, but got ${JSON.stringify(received)}`, }; },});The verdict. Check the ok: false discriminator first, then check the code matches, but only if a code was supplied. pass is the single boolean the matcher resolves to. Narrowing on received.ok === false is what lets received.error be read safely.
import { expect } from 'vitest';import type { Result } from '@/lib/result';
expect.extend({ toBeErrResult(received: Result<unknown>, expectedCode?: string) { const isErr = received.ok === false; const codeMatches = expectedCode === undefined || received.error?.code === expectedCode; const pass = isErr && codeMatches;
return { pass, message: () => pass ? `expected result not to be an err${ expectedCode ? ` with code '${expectedCode}'` : '' }` : `expected an err result${ expectedCode ? ` with code '${expectedCode}'` : '' }, but got ${JSON.stringify(received)}`, }; },});The payoff over inline toMatchObject. message is a closure the runner calls only on failure. The failing branch interpolates the actual received value, so a red test reads “expected an err result with code ‘conflict’, but got { ok: true, data: ... }”. It names the divergence instead of dumping a generic object diff, and that readable failure is the entire reason to promote the assertion.
Note that the message has two branches keyed on pass. The pass-true branch is the message shown when the matcher is negated with .not and the value passed anyway, and Vitest needs both directions. The pass-false branch is the one you’ll actually read, and it’s worth writing well: a matcher whose failure message says “expected err, got ok with {...}” saves you the trip back into the code to find out what went wrong.
toBeOkResult is the mirror image: it checks ok === true and, optionally, that data matches a partial. With both registered, the call sites collapse to exactly what they mean:
expect(createInvoice(valid)).toBeOkResult({ id: expect.any(String) });expect(createInvoice(duplicate)).toBeErrResult('conflict');expect(result).toBeErrResult('conflict') reads as a sentence, expect an error result with the conflict code, and the discriminator check, the narrowing, and the readable failure message all live behind the one call. One last wiring step: TypeScript doesn’t know your new matcher exists until you augment Vitest’s Assertion interface, a few lines of declaration merging alongside the implementation, so that toBeErrResult type-checks at every call site. That’s a one-time registration detail; set it up once and forget it.
Matching Zod validation failures
Section titled “Matching Zod validation failures”Validation is the most common documented failure in a SaaS /lib, and Zod is how you express it. A schema’s safeParse doesn’t throw on bad input; it returns { success: false, error: ZodError }, where the ZodError carries an issues array, one entry per problem it found. That array is where the contract lives, and you assert against it the same way you assert everything else, by code and path, never by message:
it('reports an invalid_format issue on a malformed email', () => { const parsed = invoiceSchema.safeParse({ email: 'not-an-email' });
expect(parsed.success).toBe(false); if (!parsed.success) { expect(parsed.error.issues).toContainEqual( expect.objectContaining({ path: ['email'], code: 'invalid_format', format: 'email', }), ); }});Parse, then assert the verdict. safeParse returns the discriminated result, and parsed.success being false is the first thing to pin.
it('reports an invalid_format issue on a malformed email', () => { const parsed = invoiceSchema.safeParse({ email: 'not-an-email' });
expect(parsed.success).toBe(false); if (!parsed.success) { expect(parsed.error.issues).toContainEqual( expect.objectContaining({ path: ['email'], code: 'invalid_format', format: 'email', }), ); }});The TypeScript narrow. Inside if (!parsed.success), the compiler knows parsed.error exists; without this guard, .error is a type error because the success branch has no error. It’s a small but real detail of working with a discriminated safeParse result.
it('reports an invalid_format issue on a malformed email', () => { const parsed = invoiceSchema.safeParse({ email: 'not-an-email' });
expect(parsed.success).toBe(false); if (!parsed.success) { expect(parsed.error.issues).toContainEqual( expect.objectContaining({ path: ['email'], code: 'invalid_format', format: 'email', }), ); }});Assert that an issue matching this shape exists. toContainEqual checks array membership by deep equality, and expect.objectContaining matches a subset of one issue. Together they say “somewhere in issues there’s an entry for the email path with this code” without pinning the array’s length or order. Match path and the stable code enum; the issue’s message is localizable and reworded between versions, so it stays out.
Three things to carry from that. First, toContainEqual plus objectContaining is the right tool for “this issue is present”: it survives Zod adding other issues or reordering them, which a positional issues[0] assertion would not. Second, the if (!parsed.success) narrow isn’t optional ceremony; it’s what gives you typed access to .error. Third, and you’ve heard it twice already, match path and code, never message.
A note on the codes themselves: in Zod 4, a failed string-format check like email reports code: 'invalid_format' with a format: 'email' field telling you which format failed. If you’ve seen older Zod code asserting code: 'invalid_string' with validation: 'email', that shape is gone; it was the Zod 3 surface. The Zod 4 issue codes you’ll match against include invalid_type, too_small, too_big, invalid_format, unrecognized_keys, and invalid_value, each a stable enum string, which is exactly why it’s the thing worth asserting.
Mapping Postgres errors to codes
Section titled “Mapping Postgres errors to codes”Some /lib files exist to translate one error vocabulary into another. mapDatabaseError in lib/error-mapping.ts takes a raw Postgres driver error and returns the domain Result code it corresponds to: a unique-violation '23505' becomes 'conflict', a foreign-key violation '23503' becomes 'forbidden', a serialization failure '40001' becomes 'internal', and anything unrecognized falls through to 'internal'. It’s a pure lookup, exactly the function a table-driven test was made for.
When you learned the daily test shape you met it.each for exactly this: many input/output pairs, one assertion. The mapper is the canonical thing to drive that way, with one row per documented mapping, inline object literals, and readable interpolated names:
it.each([ { pgCode: '23505', expected: 'conflict' }, { pgCode: '23503', expected: 'forbidden' }, { pgCode: '40001', expected: 'internal' }, { pgCode: 'unknown', expected: 'internal' },])('maps Postgres $pgCode to $expected', ({ pgCode, expected }) => { expect(mapDatabaseError({ code: pgCode })).toBe(expected);});The row that matters most is the last one. The 'unknown' to 'internal' case tests the default branch, what the mapper does with a code it has never seen. That fallback is part of the contract too: it’s the difference between an unrecognized database error degrading into a safe 'internal' and crashing the request. A mapper tested only on its known codes has a default branch that has never run under a test, which is precisely the kind of gap the audit at the end of this lesson is built to catch. The default branch is a behavior, so give it a row.
Asserting the Error.cause chain
Section titled “Asserting the Error.cause chain”When a /lib function catches a low-level error and re-throws a domain error, it should preserve the original via Error.cause: throw new InvoiceSyncError('sync failed', { cause: originalError }). You met this wrap-and-rethrow discipline when you learned custom error classes, and the code conventions make it a rule: the user sees a clean domain error, and the operator sees the full chain in the logs. A test verifies that chain is intact, because a broken cause is an observability regression that nothing else will catch:
it('wraps the network error as InvoiceSyncError, preserving the cause', () => { try { syncInvoice(unreachableEndpoint); expect.fail('expected syncInvoice to throw'); } catch (error) { expect(error).toBeInstanceOf(InvoiceSyncError); expect((error as Error).cause).toBeInstanceOf(NetworkError); }});This is the same try/catch plus expect.fail shape from earlier, now checking two links of the chain: the outer error is the domain InvoiceSyncError, and its cause is the original NetworkError. If a refactor drops the { cause } option, the class assertion still passes and only the cause assertion goes red. That’s the whole point, because the dropped cause is exactly the debug context an on-call engineer would be missing in the middle of an incident.
This section exists to name one mistake, because cause-chain tests are where it does the most damage. There’s a tempting non-test that looks like this: you mock a collaborator to throw a NetworkError, run the unit, and assert it threw a NetworkError. That asserts nothing. The mock and the assertion are the same statement, since you stubbed the throw and then checked the throw, so the test is true by construction and stays green no matter what the unit does in between.
not.toThrow and it.fails
Section titled “not.toThrow and it.fails”Two smaller tools round out the vocabulary. Both are about stating the expected outcome precisely.
The first is not.toThrow, for the path that’s supposed to complete cleanly:
expect(() => parseInvoiceInput(valid)).not.toThrow();It’s useful but weak on its own, because it asserts an absence, which is a low bar. A function can fail to throw and still return the wrong thing. So pair it with a positive assertion on the return value; on its own, “didn’t throw” is rarely the whole contract.
The second is it.fails, which inverts the verdict so the test passes when its assertions fail:
it.fails('returns conflict on a duplicate slug — see INV-482', () => { expect(createInvoice(duplicateSlug)).toBeErrResult('conflict');});There’s exactly one legitimate use: documenting a known-broken behavior that’s queued for a fix, so the regression is recorded in the suite without turning CI red. The test name carries a tracking-issue reference, and when the bug is fixed the it.fails gets deleted. At that point the assertion is true and the inversion would make it fail, which reminds you to remove it. Used that way it’s a labeled placeholder. Used carelessly it rots: an it.fails committed without a tracking issue and a removal plan becomes a permanently inverted test nobody trusts, quietly asserting that something stays broken. Keep it to that one use, and don’t lean on it.
Auditing for the missing failure test
Section titled “Auditing for the missing failure test”You now have a matcher for every failure shape a /lib export can produce. The last step is to turn the two-path rule into a review habit, the pass an experienced engineer makes before opening a pull request.
The coverage-thresholds work in the previous chapter drew a distinction that matters here: line coverage and branch coverage are not the same promise. A /lib file can sit at 100% line coverage and still have an untested failure path. Picture a function whose happy-path test happens to run the line that throws on its way through. The line is “covered,” it executed, and the report is green, but no test ever asserted that the throw produced the right error. Branch coverage is what surfaces the gap: the failing branch ran, and the assertion about it never existed.
So the audit doesn’t start from a coverage number. It starts from a question you ask of every exported function:
Run that pass and you’ve stopped being the author of the suite and started being its reviewer, which is the goal. A test suite documents a contract, and a failure test documents the half of the contract where bugs live. Asking “where’s the failure test?” of every function you review is the habit that closes the gap this lesson opened on.
Before the synthesis exercise, one calibration drill. The single judgment this lesson turns on is what is contract and what is wording, so sort these into the two buckets:
Sort each part of a thrown or returned error into whether a good failure test should assert on it. Drag each item into the bucket it belongs to, then press Check.
NotFoundError)error.code value ('not_found')ok: false)cause type (e.g. NetworkError)userMessagetraceId field on the errorNow write one for real. The exercise below gives you a /lib function with both paths already implemented: parseQuantity, which returns the parsed number on a valid positive-integer string and throws ValidationError otherwise. The success test is written for you. Your job is the failure test: assert that bad input throws, and assert it on the class, not the message.
The success test for parseQuantity is written and green. Write the failure test in the empty block below it: assert that a non-positive input throws ValidationError — on the class, not the message — and add one line proving a valid input does not throw. Remember toThrow takes a function: expect(() => parseQuantity('-3')).toThrow(ValidationError).
That habit, writing the second test and reaching for the class over the message, is the one that pays off on every /lib file you’ll ever ship.
External resources
Section titled “External resources”The full matcher list, including toThrow, toMatchObject, and the asymmetric matchers (expect.any, expect.objectContaining).
How expect.extend works end to end: the pass/message return shape and the TypeScript declaration merging that types your toBeErrResult call sites.
The ZodError issues structure and the issue code enum you assert against, never the localizable message.
A focused argument for the two-path rule: testing the happy path is no better than testing failures.