Unit tests for pure functions
The base layer of the testing unit: writing Vitest unit tests for the pure functions in your lib folder and reaching for the matcher that fits each value.
The pure functions in src/lib are where unit testing starts. mapDatabaseError maps a raw Postgres code to one of your application’s error codes, sitting next to mapError from the last chapter; money.ts exports formatMoney; redact.ts exports redact. Each is a pure function : same inputs, same output, every time, with no database, clock, or network in the way. That is what makes its unit test its contract. You pass inputs, assert on what comes back, and there is nothing else to arrange: no fake database, no mock, no setup block. This lesson turns the Arrange / Act / Assert shape from the last chapter into the daily craft of the base layer, so you can open any /lib file, write its test sibling in one sitting, and reach for the right matcher.
A /lib file and its test sit side by side
Section titled “A /lib file and its test sit side by side”The test file goes right next to the code. src/lib/error-mapping.ts gets a sibling named src/lib/error-mapping.test.ts in the same folder. This is colocation , the alternative to mirroring source into a separate tests/ tree on the other side of the repo.
Directorysrc/
Directorylib/
- error-mapping.ts
- error-mapping.test.ts
- money.ts
- money.test.ts
- redact.ts
- redact.test.ts
No extra configuration is needed. The unit project’s glob, src/lib/**/*.test.ts, picks up every .test.ts sibling the moment you save it.
Colocation beats a parallel tests/ tree on four counts:
- The import path stays short.
import { mapDatabaseError } from './error-mapping'is a sibling import, with no../../../climb out of atests/directory. - A rename moves both files together. Drag
error-mapping.tsinto a subfolder and its test rides along; there’s no cross-tree link to break. - Deleting the source deletes the test with it. A parallel tree quietly accumulates tests for code that no longer exists.
- The test documents the source where you’re working. Editing
error-mapping.ts, you glance one line down the file list to read what it should do.
A pure-function test in three lines
Section titled “A pure-function test in three lines”For a pure function, Arrange / Act / Assert collapses to almost nothing: one line of input, one line for the call, one or two for the check. Here is the first test for mapDatabaseError, in full:
import { describe, it, expect } from 'vitest';import { mapDatabaseError } from './error-mapping';
describe('mapDatabaseError', () => { it('maps a unique-violation code to conflict', () => { const dbError = { code: '23505' }; const result = mapDatabaseError(dbError); expect(result).toBe('conflict'); });});The body reads as Arrange, Act, Assert in order: build the input (dbError), call the unit once (mapDatabaseError), check the return (toBe('conflict')). Postgres raises SQLSTATE 23505 for a unique-constraint violation, and your mapper turns it into the conflict code the rest of the app understands.
The import line earns its place. Because you set globals: false in the last chapter, every file imports describe, it, and expect from 'vitest' rather than reading them as ambient globals. In return, the codebase stays grep-able (search for expect and you find real assertions) and your editor’s “go to definition” never loses the reference.
When the three-line shape breaks, it points at a problem in the code, not the test:
- If Arrange grows past about three lines, the input wants a factory. A test that hand-builds an object for ten lines has buried its one assertion under setup. The fix is a builder function that hands you a ready object, which the next lesson covers.
- If Act has more than one call, you’re testing two behaviors. A failure won’t tell you which one broke. Split it into two
itblocks, each with its own single Act.
One describe per export, one it per branch
Section titled “One describe per export, one it per branch”The file has a shape too: one describe block per exported function, and one it per observable branch. A /lib file that exports two functions has two top-level describe blocks, each named for its function, each holding the it blocks that walk through what it does.
import { describe, it, expect } from 'vitest';import { mapDatabaseError, isRetryable } from './error-mapping';
describe('mapDatabaseError', () => { it('maps a unique-violation code to conflict', () => {}); it('maps a serialization failure to internal', () => {}); it('maps an unrecognized code to internal', () => {});});
describe('isRetryable', () => { it('returns true for a serialization failure', () => {}); it('returns false for a unique violation', () => {});});Read that skeleton aloud and it becomes a specification: “mapDatabaseError maps a unique-violation code to conflict, maps a serialization failure to internal, maps an unrecognized code to internal.” Carry this model for the rest of the lesson: a finished /lib test file is a behavior catalog. Reading the list end to end tells you what the function promises, so aim every test name at completing the sentence “it ___”.
Nest a second describe inside the first only for a genuine sub-context. If mapDatabaseError handles both Postgres constraint codes and a separate family of connection errors, a nested describe('Postgres constraint codes', …) groups the related branches. Stop there: the reporter flattens deeper nesting into a breadcrumb nobody reads. Reaching for a third level is almost always an attempt to group tests that sharper it names would separate, so fix the names instead.
Choosing a matcher by value shape
Section titled “Choosing a matcher by value shape”The matcher follows the shape of the value you’re asserting on. The wrong one can turn green while proving nothing, or turn red for a reason that has nothing to do with your code. Two failure modes show up constantly:
toBechecks identity, the same notion as===. On a primitive like a string or a number that’s exactly right. On an object it compares references, so two objects with identical fields failtoBeanyway: they’re distinct objects in memory.toEqualwalks an object field by field. On a plain data object that’s exactly right. But on aTemporal.Instantit compares the object’s opaque internals, not the moment in time it represents, so the test can pass while asserting on the wrong thing.
A wrongly-green test is worse than a red one: a red test stops you, a green one ships. So learn the mapping from value shape to matcher.
Read down the figure. Primitives compare by value, so they get toBe, which uses SameValue : like ===, except it treats NaN as equal to NaN and keeps +0 distinct from -0. Whole objects and arrays compare by deep equality , so they get toEqual, which recurses field by field comparing values rather than references. When you care about only some fields of a large object, toMatchObject asserts the fields you name and ignores the rest, so the test survives an unrelated property being added. Floats with rounding error get toBeCloseTo, because 0.1 + 0.2 is famously not 0.3. A value you expect to be an instance of a class gets toBeInstanceOf.
The trap to watch is Temporal, which shows up all over a web app. These tabs assert on a Temporal.PlainDate, the kind of value the date codecs you built earlier hand back:
const due = parseDueDate('2026-03-15');
expect(due).toEqual(Temporal.PlainDate.from('2026-03-15'));Green by accident. toEqual on two Temporal.PlainDate values compares their opaque internal slots, not the calendar day. This pair happens to match, but a value with different internals representing the same day would fail. You’ve asserted on the implementation, not the day.
const due = parseDueDate('2026-03-15');
expect(due.year).toBe(2026);expect(due.month).toBe(3);expect(due.day).toBe(15);Says what you mean. Year, month, and day are primitives, so toBe fits, and the test reads as the contract: this string parses to the fifteenth of March 2026. For the same check in one line, use expect(due.toString()).toBe('2026-03-15').
So never toEqual a Temporal value. Reach past the object to its observable parts: for an Instant, assert on .epochMilliseconds; for a PlainDate, on .year / .month / .day, or compare the ISO string via .toString().
expect(scheduledFor.epochMilliseconds).toBe( Temporal.Instant.from('2026-03-15T00:00:00Z').epochMilliseconds,);expect(dueDate.toString()).toBe('2026-03-15');When this comparison repeats across files, the fix is a custom matcher, which is where we head in two sections.
Table-driven tests with it.each
Section titled “Table-driven tests with it.each”Many /lib functions share a shape: many inputs, one output each. mapDatabaseError maps a dozen Postgres codes to error codes, and writing a dozen near-identical it blocks for that is noise. it.each collapses the repetition into one table that reads as the spec.
Use the object form, a list of objects, one per case:
it.each([ { code: '23505', expected: 'conflict' }, { code: '23503', expected: 'conflict' }, { code: '40001', expected: 'internal' },])('maps Postgres $code to $expected', ({ code, expected }) => { expect(mapDatabaseError({ code })).toBe(expected);});Vitest interpolates each row’s fields into the test name wherever you write a $token, so the reporter shows three self-describing lines, “maps Postgres 23505 to conflict” and so on, instead of “maps Postgres” three times. When a row fails, its name tells you which case broke before you open the file.
Two judgment calls keep it.each honest:
- Don’t pack a row with seven columns. If a case needs that many inputs to describe it, the failure message becomes an unreadable wall, and those cases probably aren’t the same behavior anymore. Split them into separate
itblocks with real names. - Never generate the rows from a function call.
it.each(generateCases())feels DRY, but the data vanishes from the failure message, so a red row shows a computed value with no story behind it. List the cases inline and literal: the table is the documentation.
Now write one yourself. The exercise below has you implement a small pure mapper, and the provided it.each-style table exercises every branch. Get the function right and the whole table goes green.
Implement mapPlanToSeatLimit: it maps a subscription plan to its seat limit. 'free' allows 1 seat, 'pro' allows 10, 'enterprise' is unlimited (Infinity), and any unrecognized plan falls back to 1. Make the tests pass.
Notice the last case: the unrecognized-plan fallback gets its own test. That branch is the one a refactor is most likely to drop, and the one a real user on a grandfathered plan will hit. The catalog isn’t complete until the “everything else” branch is in it.
When to write a custom matcher
Section titled “When to write a custom matcher”When the same domain comparison recurs as a few lines poking at fields, that’s the signal for a custom matcher. Vitest lets you register one with expect.extend . The threshold is concrete: reach for one when the same comparison is written three or more times across files and its failure message can describe the divergence better than a raw object diff.
The clearest case in your codebase is the Result type. Every Server Action returns Result<T>: { ok: true, data } on success, { ok: false, error: { code, userMessage } } on failure. Asserting the failure shape by hand means writing toMatchObject({ ok: false, error: { code: 'not_found' } }) over and over, which buries the one thing you care about, the code, under structural noise. A matcher pair fixes that:
import { expect } from 'vitest';import type { ErrorCode, Result } from '@/lib/result';
expect.extend({ toBeErrResult(received: Result<unknown>, expectedCode?: ErrorCode) { if (received.ok !== false) { return { pass: false, message: () => 'expected an error result, got ok' }; }
const actualCode = received.error.code; const pass = expectedCode === undefined || actualCode === expectedCode;
return { pass, message: () => pass ? `expected result not to be an error with code ${expectedCode}` : `expected error code ${expectedCode}, got ${actualCode}`, }; },});The { pass, message } return is the whole contract. pass is whether the assertion held; message is a function returning the string shown on failure, and writing it well is the point of the matcher. Notice it distinguishes the two ways this can fail: “got ok” when the result wasn’t an error at all, and “expected error code not_found, got conflict” when it was the wrong error, a message a teammate can act on without opening the test. The call site then collapses to one readable line:
expect(result).toMatchObject({ ok: false, error: { code: 'not_found' } });expect(result).toBeErrResult('not_found');Matchers live in src/test/matchers/, one file per concern, like result.ts. The test setup file imports them so they register everywhere, or a test imports them directly when only a couple of files need them. That repeated Temporal comparison from earlier belongs here too, as a toBeMoneyEqualTo(...) or a date-equality matcher.
One guardrail, because this tool invites overuse. Five custom matchers across a codebase is healthy; fifty is a second test framework that you now own and that every newcomer must learn before reading a test. Keep each one specific: a matcher named toBeValid is a trap, because when it fails you can’t tell which axis was invalid, the email, the date, or the total. Name the precise contract it checks, so its failure points straight at what broke.
toBeOkResult and toBeErrResult are the home for every Result assertion from here on; the unhappy-path lesson later in the chapter leans on them heavily.
The purity diagnostic: no setup, no mocks, no app/ imports
Section titled “The purity diagnostic: no setup, no mocks, no app/ imports”Every test in this lesson skips setup: no beforeEach, no mocked dependency, nothing imported from app/. That follows from one fact. A pure function has no collaborators, no shared state, and lives below the framework. Three rules drop out of it, and together they give you the diagnostic: if a /lib test makes you want a mock, a beforeEach, or an app/ import, the unit is probably misclassified and belongs in the integration layer. The pull toward setup is the test telling you the function isn’t pure.
- No
beforeEach. A pure function needs no world to exist before you call it, so abeforeEachhas two likely causes. It’s building a shared fixture, in which case move it to a factory call inside eachitso no two tests share a mutable object (the next lesson). Or the unit reads the clock or the filesystem and isn’t pure after all. The one exception isvi.useFakeTimers()andvi.useRealTimers()for code that touches time, covered in a later lesson. - No mocks, almost ever. The function doesn’t call anything you’d fake. A
/libfunction reaching forfetch, the database, orDate.now()is a seam violation: extract that dependency to a parameter or a seam module instead ofvi.mock-ing it. The narrow exception is deterministic seams, the clock, ID generation, and randomness, which get pinned by injection or fake timers rather than mocked. - No
app/imports./libsits below the application: the app imports from/lib, never the reverse. A/libtest reaching up intoapp/has flipped the dependency direction. This one isn’t left to discipline: a lint rule,no-restricted-paths, catches the upward import structurally, so the build stops you before review does.
Each statement is about a test sitting in src/lib/. Decide whether it describes a healthy /lib unit test. Mark each statement True or False.
A /lib test that needs to call cookies() should mock it with vi.mock.
cookies() means the unit depends on the request — it isn’t pure. The fix isn’t to mock it; it’s to move the test to the integration layer (next chapter), where framework-mediated surfaces are tested for real.A function mapping Postgres error codes to your ErrorCode union is a good fit for a /lib unit test.
A beforeEach that builds a shared user object for several tests is good practice in a /lib test.
A /lib test importing from app/ reverses the dependency direction and a lint rule will flag it.
/lib sits below the app; the app imports from /lib, not the reverse. no-restricted-paths catches the upward import structurally.Reveal card-by-card review
What a finished /lib test file looks like
Section titled “What a finished /lib test file looks like”A healthy /lib test file runs about 60 to 80 lines: imports at the top, one describe per export, six to ten it blocks of three to six lines each, an it.each table where it earns its keep, maybe one custom matcher, and no beforeEach and no mocks. It runs in single-digit milliseconds and reads, top to bottom, as a catalog of what the function does. Here’s a trimmed error-mapping.test.ts with every move from this lesson in it:
import { describe, it, expect } from 'vitest';import { mapDatabaseError } from './error-mapping';
describe('mapDatabaseError', () => { it('maps a unique-violation code to conflict', () => { const result = mapDatabaseError({ code: '23505' });
expect(result).toBe('conflict'); });
it.each([ { code: '23505', expected: 'conflict' }, { code: '23503', expected: 'conflict' }, { code: '40001', expected: 'internal' }, ])('maps Postgres $code to $expected', ({ code, expected }) => { expect(mapDatabaseError({ code })).toBe(expected); });
it('falls back to internal for an unrecognized error', () => { const result = mapDatabaseError(new Error('boom'));
expect(result).toBe('internal'); });});The imports. describe/it/expect come from 'vitest' explicitly because globals are off; the second line pulls in the unit under test from its sibling file. Custom matchers join this block when a file needs them.
import { describe, it, expect } from 'vitest';import { mapDatabaseError } from './error-mapping';
describe('mapDatabaseError', () => { it('maps a unique-violation code to conflict', () => { const result = mapDatabaseError({ code: '23505' });
expect(result).toBe('conflict'); });
it.each([ { code: '23505', expected: 'conflict' }, { code: '23503', expected: 'conflict' }, { code: '40001', expected: 'internal' }, ])('maps Postgres $code to $expected', ({ code, expected }) => { expect(mapDatabaseError({ code })).toBe(expected); });
it('falls back to internal for an unrecognized error', () => { const result = mapDatabaseError(new Error('boom'));
expect(result).toBe('internal'); });});One describe, named for the export. A second export would get its own describe alongside this one.
import { describe, it, expect } from 'vitest';import { mapDatabaseError } from './error-mapping';
describe('mapDatabaseError', () => { it('maps a unique-violation code to conflict', () => { const result = mapDatabaseError({ code: '23505' });
expect(result).toBe('conflict'); });
it.each([ { code: '23505', expected: 'conflict' }, { code: '23503', expected: 'conflict' }, { code: '40001', expected: 'internal' }, ])('maps Postgres $code to $expected', ({ code, expected }) => { expect(mapDatabaseError({ code })).toBe(expected); });
it('falls back to internal for an unrecognized error', () => { const result = mapDatabaseError(new Error('boom'));
expect(result).toBe('internal'); });});One representative behavior in clean AAA: build the input, call once, assert the primitive result with toBe. Its name completes the sentence “it maps a unique-violation code to conflict.”
import { describe, it, expect } from 'vitest';import { mapDatabaseError } from './error-mapping';
describe('mapDatabaseError', () => { it('maps a unique-violation code to conflict', () => { const result = mapDatabaseError({ code: '23505' });
expect(result).toBe('conflict'); });
it.each([ { code: '23505', expected: 'conflict' }, { code: '23503', expected: 'conflict' }, { code: '40001', expected: 'internal' }, ])('maps Postgres $code to $expected', ({ code, expected }) => { expect(mapDatabaseError({ code })).toBe(expected); });
it('falls back to internal for an unrecognized error', () => { const result = mapDatabaseError(new Error('boom'));
expect(result).toBe('internal'); });});The it.each table covers the mapping branches without repetition. Each row’s $code and $expected interpolate into a distinct reporter line, so a failing row names itself.
import { describe, it, expect } from 'vitest';import { mapDatabaseError } from './error-mapping';
describe('mapDatabaseError', () => { it('maps a unique-violation code to conflict', () => { const result = mapDatabaseError({ code: '23505' });
expect(result).toBe('conflict'); });
it.each([ { code: '23505', expected: 'conflict' }, { code: '23503', expected: 'conflict' }, { code: '40001', expected: 'internal' }, ])('maps Postgres $code to $expected', ({ code, expected }) => { expect(mapDatabaseError({ code })).toBe(expected); });
it('falls back to internal for an unrecognized error', () => { const result = mapDatabaseError(new Error('boom'));
expect(result).toBe('internal'); });});The failure branch gets a test too. An unrecognized error falls back to internal, the path most likely to regress. Asserting both the mapped path and the fallback is the start of the two-path discipline.
Run vitest --project unit --watch while you write the file: it re-runs the unit project on every save, so the catalog goes green or red the instant you change error-mapping.ts. The coverage thresholds you set last chapter, 90% lines and 85% branches on /lib/**, ride this same glob, so the file you just wrote is also what keeps that number honest.
External resources
Section titled “External resources”The full matcher reference: toBe, toEqual, toMatchObject, toContainEqual, toBeCloseTo, toBeInstanceOf, toThrow, and the rest, each with a short example.
How expect.extend works and the { pass, message } return contract behind the custom Result matcher this lesson builds.
Why 0.1 + 0.2 isn't 0.3 across dozens of languages: the IEEE-754 reason toBeCloseTo exists, demonstrated live.
Reference for Temporal.PlainDate and Temporal.Instant, including the .year/.month/.day and .epochMilliseconds accessors you assert on instead of the whole object.