Skip to content
Chapter 87Lesson 6

Asserting the unhappy path

Writing Vitest failure-path tests that assert a lib function's errors by class and code, not by message.

A function’s contract has two halves. Take parseInvoiceInput, a /lib validator: it promises to return the parsed invoice on valid input, and to throw ValidationError on garbage input. Most suites assert the first half and leave the second blank, and that blank is where bugs collect, because no test ever made the runner check the failure path.

Here is a passing test for parseInvoiceInput that 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, or currency is 'xyz'? The function throws in both cases, but the test verifies neither.

This lesson gives you the discipline and the matchers to assert failure as deliberately as success. By the end you’ll 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 choose the matcher by the shape of the failure, and you assert only the part of the error that is a promise to callers, never the message. That puts the two-path rule from the previous chapter into your hands.

Every behavior earns at least two tests: one for success, one for the documented failure. You met this as the two-path rule in the previous chapter, and applying it is mechanical. A function with a documented failure mode gets two it blocks in the same describe, written to read top to bottom:

src/lib/parse-invoice-input.test.ts
describe('parseInvoiceInput', () => {
it('returns a parsed invoice on valid input', () => {
// ...
});
it('throws ValidationError on a negative amount', () => {
// ...
});
});

The two it titles state the contract from both sides, so anyone who opens the file reads what the function promises on success and on failure.

Do not test success and failure in a single it. Once one block exercises both paths, a red bar no longer tells you which half broke; you have to read the test to find out whether the parse or the throw regressed. Two behaviors, two blocks. Everything below fills in that second one.

When the failure is a thrown error, the matcher is toThrow. It has four forms, 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 ValidationError. The third looks like a full-string match but is not: a string argument passes when it is a substring of the message, so 'amount must be positive' matches 'Validation failed: amount must be positive'. The fourth runs a regex against the message. Note which form each line uses, because the next section returns to that choice.

Before the four forms, one mistake that’s easy to make. The argument to expect on every line is an arrow function, () => parseInvoiceInput(bad), not parseInvoiceInput(bad). Drop the arrow and the test silently stops testing anything.

expect(parseInvoiceInput(bad)).toThrow();

The assertion never runs. JavaScript evaluates parseInvoiceInput(bad) first, to produce the value handed to expect. It throws there, before expect is called, so the error propagates out of the test body. The test errors out for the wrong reason, and toThrow never runs.

The broken version is hard to spot because it can still turn the test red, just for the wrong reason at the wrong line. The rule: toThrow always takes a function.

Asserting the class and code, not the message

Section titled “Asserting the class and code, not the message”

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 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 is a behavior change, since the function still rejects the same input the same way, yet the test goes red on all three. You’ve coupled it to wording that exists for humans and is meant to change.

The rule: messages are user-facing and mutable; classes and codes are the contract. This is the same split the code conventions draw between an error’s two audiences: the user reads userMessage, the operator reads the full chain in the logs. A test is neither. It verifies the contract, so it asserts the machine-readable half and ignores the prose.

This one rule governs the rest of the lesson. When we assert a Result.err, 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.

Catching the error to assert several fields

Section titled “Catching the error to assert several fields”

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

Call the function with input it must reject. If it throws as expected, 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, easy to forget. expect.fail(...) throws immediately, so reaching this line means parseInvoiceInput didn’t throw, and the test fails with a clear message. Leave it out and a function that quietly stops throwing skips the catch, asserts nothing, and passes anyway: 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' });
}
});

Now you hold the thrown value and can check as many fields as the contract documents: the class via toBeInstanceOf, the stable code via toMatchObject. Assert the contract surface, never the message.

1 / 1

This is the synchronous shape; the async version is identical except you await the call inside the try. That mechanism and expect.assertions(n) belong to the previous lesson on async tests.

Throwing is for the impossible: programmer errors and unreachable paths. The expected failures in a SaaS /lib, a validation miss, a uniqueness conflict, a missing record, don’t throw. They come back as a value, the Result<T> you’ve used since the forms-and-validation chapter, whose failure branch is { ok: false, error: { code, userMessage, fieldErrors? } }. The code is a small set of fixed strings: 'validation', 'conflict', 'not_found'.

Since the failure is returned, not thrown, toThrow has nothing to catch and you assert against the object directly. Which object matcher you pick is the brittleness lesson from class-over-message in a new form:

expect(result).toEqual({
ok: false,
error: { code: 'not_found', userMessage: 'Invoice not found.' },
});

Brittle. toEqual is an exhaustive deep match: the object must have these fields and no others. The day someone adds an error.traceId for log correlation, every test written this way goes red, none because a behavior changed. It also forces you to spell out the userMessage wording you just learned not to pin.

A good failure test asserts a subset, the contract, and stays quiet about the incidental. toMatchObject lets you name only the promise.

Sometimes a field’s presence is the contract but its value can’t be pinned: fieldErrors will be populated, but the exact strings are wording. Vitest’s asymmetric matchers slot into the expected object for those:

expect(result).toMatchObject({
ok: false,
error: {
code: 'validation',
fieldErrors: expect.any(Object),
},
});

expect.any(Object) asserts the field exists and is an object without checking its contents; 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, and you’ll write it again in every /lib file that returns a Result. The daily test shape gave you the threshold for promoting an assertion into a custom matcher: the same assertion written three or more times across files earns one. The Result discriminator check is the most repeated assertion in a Result-based codebase, so it’s the textbook case. The Pure-function tests lesson named toBeOkResult and toBeErrResult; here is the implementation.

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 it worth writing. The implementation 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 the code, but only if a code was supplied. pass is the single boolean the matcher resolves to, and 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 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.

1 / 1

The message has two branches keyed on pass. Vitest needs both: the pass-true branch shows only when the matcher is negated with .not and the value passes anyway, while the pass-false branch is the one you’ll actually read on a failing test.

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, and the discriminator check, the narrowing, and the readable failure message all live behind the one call. One wiring step remains: TypeScript doesn’t know the 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.

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 found. That array is the contract, and you assert against it 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 the guard, .error is a type error, since the success branch has none.

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.

1 / 1

In Zod 4, a failed string-format check like email reports code: 'invalid_format' with a format: 'email' field telling you which format failed. Older code asserting code: 'invalid_string' with validation: 'email' is the gone Zod 3 surface. The Zod 4 issue codes you’ll match against, each a stable enum string, include invalid_type, too_small, too_big, invalid_format, unrecognized_keys, and invalid_value.

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: unique-violation '23505' becomes 'conflict', foreign-key violation '23503' becomes 'forbidden', serialization failure '40001' becomes 'internal', and anything unrecognized falls through to 'internal'. It’s a pure lookup, the exact shape it.each was made for: one row per documented mapping, one assertion.

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 last row matters most. 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, the difference between an unrecognized error degrading safely to 'internal' and crashing the request. Test only the known codes and the default branch never runs under a test, which is exactly the gap the audit at the end of this lesson catches. The default branch is a behavior, so give it a row.

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 }). This is the wrap-and-rethrow discipline from custom error classes: the user sees a clean domain error, the operator sees the full chain in the logs. A test guards that chain, because a broken cause is an observability regression 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: 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, flagging exactly the debug context an on-call engineer would otherwise lose mid-incident.

One mistake does the most damage here. The tempting non-test: you mock a collaborator to throw a NetworkError, run the unit, and assert it threw a NetworkError. That asserts nothing. 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 asserts that a call completes cleanly:

expect(() => parseInvoiceInput(valid)).not.toThrow();

It asserts an absence, which is a low bar: a function can avoid throwing and still return the wrong value. Pair it with a positive assertion on the return value.

it.fails inverts the verdict, so the test passes only when its assertions fail:

it.fails('returns conflict on a duplicate slug — see INV-482', () => {
expect(createInvoice(duplicateSlug)).toBeErrResult('conflict');
});

It has one legitimate use: recording a known-broken behavior that’s queued for a fix, so the regression sits in the suite without turning CI red. The test name links the tracking issue. When the bug is fixed the assertion passes, the inversion turns the test red, and that reminds you to delete it. Without a tracking issue and a removal plan, an it.fails rots into a permanently inverted test nobody trusts, quietly asserting that something stays broken. Keep it to that one use.

You now have a matcher for every failure shape a /lib export can produce. The last step turns the two-path rule into a habit: a review pass you run before opening a pull request.

The coverage 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 hide an untested failure path. Picture a happy-path test that runs the throw line on its way through: the line executed, so the report counts it covered, but no test ever asserted the throw produced the right error. Branch coverage is what surfaces the gap, the failing branch ran and no assertion about it ever existed.

So the audit doesn’t start from a coverage number. It starts from a question you ask of every exported function:

This lesson turns on a single judgment, what is contract and what is wording. Sort each item into its bucket:

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.

Stable contract Assert on this
Incidental detail Don't couple to it
The error class (NotFoundError)
The error.code value ('not_found')
The discriminator (ok: false)
The cause type (e.g. NetworkError)
The exact error message string
A localized userMessage
A stack-trace frame
A traceId field on the error

Now write one for real. parseQuantity returns the parsed number for a valid positive-integer string and throws ValidationError otherwise; both paths are implemented and the success test is written. Your job is the failure test: assert bad input throws, 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).