Skip to content
Chapter 87Lesson 1

Pure-function tests, the daily shape

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.

Open src/lib/error-mapping.ts. Alongside mapError, the dispatch from the last chapter that turns any thrown error into a Result, it exports mapDatabaseError(err: unknown): ErrorCode, the narrower helper that maps a single raw Postgres code to one of your application’s error codes. Next to it sit two more files you’ve already written: money.ts with formatMoney(cents: number): string, and redact.ts with redact(payload: unknown): unknown. Every one of these is a pure function : feed it the same inputs and it returns the same output every time, with no database, no clock, and no network in the way. That property is why this chapter starts here, because when a function is pure its unit test is its contract. You hand it inputs, you assert on what comes back, and there is nothing else to arrange: no fake database, no mock, no setup block. You arrive with the runner wired and the Arrange / Act / Assert shape from the last chapter, and this lesson turns that shape into the daily craft of the base layer. By the end you can open any /lib file, write its test sibling in one sitting, and reach for the right matcher every time.

The first decision is where the test file goes, and the answer is 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 : source and its test living in the same directory rather than mirrored 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

You don’t have to configure anything for the runner to find these. Back in the last chapter you pointed the unit project at the glob src/lib/**/*.test.ts, and that glob already sweeps up every .test.ts sibling you drop into /lib, so a new test file is discovered the moment you save it. The wiring is done; colocation is just where you choose to put the file.

It’s worth saying why this is the default, because the alternative, a parallel tests/ tree, is what a lot of older codebases reach for. The reasons it loses are ones an experienced engineer can name:

  • The import path stays short. import { mapDatabaseError } from './error-mapping' is a sibling import, with no ../../../ climb back up out of a tests/ directory to find the code.
  • A rename moves both files together. Drag error-mapping.ts into a subfolder and its test rides along in the same folder. Nothing breaks the link, because there’s no link to break.
  • Deleting the source can’t orphan the test. When you delete a file, its test goes with it. A parallel tree quietly accumulates tests for code that no longer exists.
  • The test reads as documentation right where you’re working. When you edit error-mapping.ts, you glance one line down the file list and read exactly what it’s supposed to do, with no context switch to a distant folder.

You learned Arrange / Act / Assert as the universal shape of a test. For a pure function it collapses to almost nothing: the input is one line, the call is one line, and the check is one or two. The whole test fits on screen without scrolling. 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');
});
});

Read the body of that it block top to bottom and you read 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 test says exactly that, and nothing more.

The import line at the top is not optional boilerplate, and it’s worth understanding why it’s there. In the last chapter you set globals: false in the Vitest config, which means describe, it, and expect are not injected as ambient globals. Every file imports them explicitly from 'vitest'. That costs three extra characters per file, and you get two things in return. The codebase stays grep-able: search for expect and you find real assertions, not a global someone might have shadowed. And your editor’s “go to definition” and refactor tools never lose the reference. As the chapter goes on you’ll add vi and expectTypeOf to that same import line; the rule stays the same, which is to name what you use.

Two things about the three-line shape are worth watching for, because when they break, the test is pointing at a problem in the code, not just in the test:

  • If Arrange grows past about three lines, the input wants a factory. A test that spends ten lines hand-building an object before it can call anything has buried its one assertion under setup. The fix isn’t a longer test; it’s a builder function that hands you a ready object, which is what the next lesson is entirely about.
  • If Act has more than one call, you’re testing two behaviors. The moment the middle section calls the unit twice, you have two things to verify in one test, and 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”

That single test is the smallest unit. The file is the next level up, and it has a shape too: one describe block per exported function, and one it per observable branch of that function. A /lib file that exports two functions has two top-level describe blocks, each named for its function, each holding the handful of it blocks that walk through what that function 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 mental model for the rest of the lesson: a finished /lib test file is a behavior catalog. The describe names the unit, each it names one thing the unit does on one kind of input, and reading the list end to end tells you what the function promises. Aim every test name at completing the sentence “it ___” so the catalog reads cleanly.

You can nest a second describe inside the first, and occasionally you should, but only when a genuine sub-context exists. Mapping is a good case: if mapDatabaseError handles both Postgres constraint codes and a separate family of connection errors, a nested describe('Postgres constraint codes', …) groups the related branches and reads well. Resist reaching for a third level. The test reporter flattens deep nesting into a breadcrumb anyway, so three or more levels buy you a hierarchy nobody reads and a lot of indentation. One or two levels per file is the ceiling. Deep nesting is almost always an attempt to group tests that would be clearer with sharper it names, so fix the names rather than the tree.

Picking the matcher that matches the shape

Section titled “Picking the matcher that matches the shape”

This is the part that separates a test that catches bugs from a test that just turns green, so slow down here. The rule is simple to state and easy to get wrong: the matcher follows the shape of the value you’re asserting on, and the wrong matcher quietly loses signal.

Here’s why that matters more than it sounds. A green test feels like proof, but the wrong matcher can produce a green test that proves nothing, or worse, a red test that fails 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 because they’re different objects in memory. You wrote a correct function and the test goes red anyway.
  • toEqual walks an object field by field. On a plain data object that’s exactly right. But on something like a Temporal.Instant it compares the object’s opaque internal structure, not the moment in time it represents, so the test can pass while asserting on the wrong thing entirely.

A green test with the wrong matcher is worse than a red one, because a red test stops you while a wrongly-green test ships. So learn the mapping from value shape to matcher, rather than a flat list of every matcher Vitest has.

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.

Walk down that figure once and the logic is consistent. Primitives compare by value, so they get toBe, which uses SameValue , the same algorithm as === except that it treats NaN as equal to NaN and keeps +0 distinct from -0, which is what you want. Whole objects and arrays compare by deep equality , so they get toEqual, which recurses field by field into nested objects and arrays, comparing values rather than references. When you only care about some fields of a large object, toMatchObject asserts the fields you name and ignores the rest, which keeps the test from breaking every time someone adds an unrelated property. Floats that accumulate rounding error get toBeCloseTo rather than toBe, because 0.1 + 0.2 is famously not 0.3. And a value you expect to be an instance of a class gets toBeInstanceOf.

The clearest way to feel the difference is to see the same assertion done wrong and then right. The following two 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 particular pair happens to match, so the test passes, but a Temporal value whose internals differ while representing the same day would fail. You’ve asserted on the implementation, not the day.

Here is the Temporal caveat in full, and it’s worth pinning because Temporal values show up all over a SaaS codebase. toEqual on a Temporal.Instant or a Temporal.PlainDate does not compare the moment or the day. It compares the object’s internal representation, which can both mislead you into a false green and trip you into a false red. So when you’re asserting on a Temporal value, reach past the object to the observable parts: for an Instant, assert on .epochMilliseconds; for a PlainDate, assert 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 gets repeated across files, that’s a signal, and the fix is a custom matcher, which is exactly where we’re heading in two sections.

A lot of /lib functions are shaped the same way: many inputs, one output each. mapDatabaseError is the textbook case, mapping a dozen or so Postgres codes to error codes. Writing twelve near-identical it blocks for that is noise, because each one is the same three lines with two values swapped, and the file stops reading as a catalog and starts reading as a copy-paste field. it.each collapses the repetition into one table, and that table reads as the spec the file always wanted.

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

The $code and $expected tokens in the test name are the reason the object form beats the older positional array form. Vitest interpolates each row’s fields into the name, so the reporter shows three distinct, self-describing lines, “maps Postgres 23505 to conflict”, “maps Postgres 23503 to conflict”, and “maps Postgres 40001 to internal”, instead of one opaque “maps Postgres” repeated three times. When a row fails, its name tells you which case broke before you even open the file.

Two judgment calls keep it.each honest:

  • Don’t pack a row with seven columns. If a single case needs that many inputs to describe it, the failure message becomes an unreadable wall. More to the point, those cases probably aren’t the same behavior anymore: they’re different behaviors wearing a shared table. Split them into separate it blocks with real names.
  • Never generate the rows from a function call. Writing it.each(generateCases()) feels DRY, but the actual data vanishes from the failure message, so a failing row shows you a computed value with no story behind it. List the cases inline, literally, so a red row is legible at a glance. The table is the documentation, so don’t hide it behind a function.

Now write one yourself. In the exercise below you’ll implement a small pure mapper, and the provided tests, written as an it.each-style table, will exercise 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 it’s the one a real user on a grandfathered plan will hit. The catalog isn’t complete until the “everything else” branch is in it.

    Custom matchers, when they earn their weight

    Section titled “Custom matchers, when they earn their weight”

    Sometimes the same domain comparison shows up again and again, and each time it’s a few lines of poking at fields. That’s the signal for a custom matcher. Vitest lets you register your own via expect.extend , and a good one reads like a sentence and fails with a message that names exactly what diverged. The threshold is concrete: reach for a custom matcher when the same comparison is written three or more times across files, and when its failure message can describe the divergence better than a raw object diff.

    The clearest example in your codebase is the Result type. You return Result<T> from every Server Action: { ok: true, data } on success, { ok: false, error: { code, userMessage } } on failure. Asserting on 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, and message is a function returning the string shown on failure. Writing that message well is the entire point of a custom 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 an error but the wrong one. That’s a message a teammate can act on without opening the test. With this registered, the assertion at the call site collapses to one readable line:

    expect(result).toMatchObject({ ok: false, error: { code: 'not_found' } });
    expect(result).toBeErrResult('not_found');

    Custom matchers live together in src/test/matchers/, one file per concern, like src/test/matchers/result.ts. They get imported by the test setup file so they’re registered everywhere, or per-test when only a couple of files need them. The same pattern is the right home for that repeated Temporal comparison from earlier: a toBeMoneyEqualTo(...) or a date-equality matcher absorbs the field-poking once and reads cleanly everywhere after.

    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 maintain, and that every newcomer has to learn before they can read a test. Keep each one specific: a matcher named toBeValid is a trap, because when it fails you have no idea which axis was invalid, whether the email, the date, or the total. A custom matcher must name the precise contract it checks, so its failure points straight at what broke.

    Two of these matchers, toBeOkResult and toBeErrResult, are the home for every Result assertion in the suite from here on. You’ll meet them again later in the chapter, where the lesson on the unhappy path leans on them hard. Introducing them here just gives that repeated assertion somewhere clean to live.

    Pure functions need no mocks and no beforeEach

    Section titled “Pure functions need no mocks and no beforeEach”

    You may have noticed something about every test in this lesson: there’s no setup. No beforeEach, no mocked dependency, nothing imported from app/. That’s not an omission. It’s the defining property of a /lib test, and it follows from a single fact about the code under test. A pure function has no collaborators, no shared state, and lives below the framework. Hold that one fact and three separate rules drop out of it.

    So here is the diagnostic to internalize, the most useful reflex this lesson installs: if a /lib test makes you want a mock, a beforeEach, or an app/ import, the unit is probably misclassified, and it belongs in the integration layer rather than here. The pull toward setup is information; it’s the test telling you the function isn’t actually pure. Take each pull in turn:

    • No beforeEach. A pure function needs no world to exist before you call it, so a beforeEach in a /lib test is a smell with two likely causes. Either it’s building a shared fixture several tests reuse, in which case move it to a factory call inside each it, so no two tests share a mutable object (the next lesson is about exactly this). Or the unit reads the clock or the filesystem and isn’t pure after all. There’s one legitimate exception, so you’re not surprised later: vi.useFakeTimers() and vi.useRealTimers() for code that touches time, which the lesson on pinning time owns entirely.
    • No mocks, almost ever. Purity means there’s no collaborator worth mocking, because the function doesn’t call anything you’d want to fake. So a /lib function reaching for fetch, the database, or Date.now() is a seam violation, and the fix is to extract that dependency to a parameter or a dedicated seam module rather than vi.mock it. The one narrow exception is deterministic seams, the clock, ID generation, and randomness, which get pinned by injection or fake timers rather than mocked on the unit. That’s the lesson on time and IDs, not this one.
    • No app/ imports. /lib sits below the application: the app imports from /lib, never the other way around. A /lib test that reaches up into app/ has reversed the dependency direction, which is both an architectural mistake and a sign the thing under test isn’t really a library function. This one isn’t left to discipline. A lint rule, no-restricted-paths, catches a /lib-to-app/ import structurally, so the build stops you before review does.

    All three reduce to the same move. When the test wants something the world has to provide, the function isn’t pure, and a pure-function test is the wrong tool. The seam belongs in the integration layer the next chapter builds.

    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.

    Put it all together and you get a file you can hold as the target shape. 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, as you can predict by now, 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, walked region by region:

    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, when a file needs them, join this same block.

    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. Everything below walks what mapDatabaseError does, and 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, and assert the primitive result with toBe. Read its name and it 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 “everything else” path, the one most likely to regress. Asserting both the mapped path and the fallback path is the start of the two-path discipline a later lesson makes the rule.

    1 / 1

    That’s the whole craft of the base layer in one file. The command you run while you write it is the inner-loop reflex: vitest --project unit --watch re-runs the unit project on every save, so the catalog goes green or red the instant you change error-mapping.ts. And the coverage thresholds you set in the last chapter, 90% lines and 85% branches on /lib/**, ride this exact same glob, which means the file you just wrote is also what keeps that number honest. Open a /lib file, write its sibling, and watch it go green: that’s the loop, and you now have every piece of it.