Skip to content
Chapter 87Lesson 1

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.

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 a tests/ directory.
  • A rename moves both files together. Drag error-mapping.ts into 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.

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:

src/lib/error-mapping.test.ts
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 it blocks, 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.

src/lib/error-mapping.test.ts
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.

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:

  • toBe checks 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 fail toBe anyway: they’re distinct objects in memory.
  • toEqual walks an object field by field. On a plain data object that’s exactly right. But on a Temporal.Instant it 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.

Shape of the value
Matcher to reach for
Primitive string · number · boolean · bigint
toBe SameValue identity
Object or array, comparing the whole thing
toEqual deep, field by field
Object, asserting only some fields
toMatchObject partial match
Array contains an element
toContainEqual deep
toContain primitive or same reference
Float with rounding error
toBeCloseTo within a tolerance
Instance of a class
toBeInstanceOf constructor check
Function throws
toThrow message or class later
Reach for the matcher whose row matches the shape of the value under test. The throw row gets its own lesson later in the chapter.

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.

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.

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 it blocks 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 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:

    src/test/matchers/result.ts
    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 a beforeEach has two likely causes. It’s building a shared fixture, in which case move it to a factory call inside each it so 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 is vi.useFakeTimers() and vi.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 /lib function reaching for fetch, the database, or Date.now() is a seam violation: extract that dependency to a parameter or a seam module instead of vi.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. /lib sits below the application: the app imports from /lib, never the reverse. A /lib test reaching up into app/ 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.

    Reading 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.

    Same input, same output, no I/O — a textbook pure function. Its test is its contract written as assertions, no setup required.

    A beforeEach that builds a shared user object for several tests is good practice in a /lib test.

    A shared mutable object couples tests by run order — one test mutating it can break another. Build a fresh instance inside each test (a factory call), which is the next lesson’s pattern.

    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.

    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.

    1 / 1

    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.