Arrange, act, assert one behavior
Writing Vitest unit tests as arrange, act, assert, each covering one behavior and asserting what the caller observes rather than the implementation.
You wrote this test, and it passes. Tomorrow a teammate renames an internal helper. No behavior changed, but the test goes red: it was asserting on the helper’s call arguments. The author patches the assertion and reruns to green. Weeks later the same function forces the same dance, and again. Soon the team draws the only conclusion the evidence supports: this test lies. They stop reading it, and trust its neighbors a little less too.
The bug wasn’t in the assertion you kept patching. The test was coupled to how the code did its work instead of what it did for the caller. This lesson gives you the shape that avoids that coupling: a test written as Arrange / Act / Assert, covering one behavior, under a name that reads as that behavior, asserting only what the caller can observe. A test is worth keeping only if it fails when the bug ships and stays green every other time, so it never cries wolf.
Arrange, act, assert: the three-part shape
Section titled “Arrange, act, assert: the three-part shape”Every test does the same three things in the same order.
Arrange builds the inputs and fixtures the test needs. Act invokes the unit under test, once. Assert verifies what came back. A blank line between the three lets a reviewer read the beats without parsing the code.
A pure function is the cleanest place to see this rhythm: it takes inputs and returns a value or throws, nothing else.
Here is a test for mapError, the dispatch you wrote in lib/error-mapping.ts that turns any thrown error into a Result keyed by a stable code, so a ZodError becomes a 'validation' failure.
import { describe, it, expect } from 'vitest';import { ZodError } from 'zod';import { mapError } from './error-mapping';
describe('mapError', () => { it('maps a ZodError to a validation failure', () => { const error = new ZodError([]);
const result = mapError(error);
expect(result).toMatchObject({ ok: false, error: { code: 'validation' }, }); });});The skeleton. The project runs with globals off, so every file imports describe / it / expect from 'vitest' by name. describe groups the tests for one unit; it holds one behavior.
import { describe, it, expect } from 'vitest';import { ZodError } from 'zod';import { mapError } from './error-mapping';
describe('mapError', () => { it('maps a ZodError to a validation failure', () => { const error = new ZodError([]);
const result = mapError(error);
expect(result).toMatchObject({ ok: false, error: { code: 'validation' }, }); });});Arrange. Build the one input this behavior needs: a ZodError, the schema-failure case.
import { describe, it, expect } from 'vitest';import { ZodError } from 'zod';import { mapError } from './error-mapping';
describe('mapError', () => { it('maps a ZodError to a validation failure', () => { const error = new ZodError([]);
const result = mapError(error);
expect(result).toMatchObject({ ok: false, error: { code: 'validation' }, }); });});Act. Call the unit once and capture the result. One Act per test: call the function twice and you’re testing two things.
import { describe, it, expect } from 'vitest';import { ZodError } from 'zod';import { mapError } from './error-mapping';
describe('mapError', () => { it('maps a ZodError to a validation failure', () => { const error = new ZodError([]);
const result = mapError(error);
expect(result).toMatchObject({ ok: false, error: { code: 'validation' }, }); });});Assert. Check the observable outcome: the Result the caller branches on, and its code. toMatchObject asserts the fields that matter without pinning the userMessage or fieldErrors the object also carries.
Once the three beats are second nature, malformed tests stand out.
An expect in the Arrange section, like expect(error.issues).toHaveLength(0) before the Act, tests your own fixture instead of the function.
Two Act-and-Assert pairs in one it are two behaviors in one test; split them.
A test with no Arrange, where the Act reaches straight for a global, is usually hiding a missing fixture.
One behavior per test, named for the behavior
Section titled “One behavior per test, named for the behavior”One behavior per test raises two questions: what counts as one behavior, and what makes a good name? They have the same answer, because a good name is just the behavior stated in words.
A behavior is one thing the caller observes: one return shape, one branch taken, one side effect, or one thrown error.
One behavior can need several assertions.
When createInvoice returns the row it just created, checking its id, status, and total is three expect calls for a single behavior, “it returns the created invoice,” so it stays one test.
But when a function returns data and writes an audit-log row and throws on bad input, those are three things the caller observes, and one it over all three gives you three tests wearing one name.
To tell them apart, ask: if this fails, can a reader name the broken behavior from the name alone?
If the answer is “depends which assertion failed,” you have more than one behavior.
Sort each item below.
Each item describes what one `it` block asserts. Decide whether it's a single behavior that can keep its many assertions, or several behaviors hiding in one test. Drag each item into the bucket it belongs to, then press Check.
id, status, and total403 is returned when the role is below admincode and message on a duplicate emailNow the name.
The course pattern is it('<observable outcome> when <conditions>'), present tense: the describe carries the unit, the it carries the behavior.
Read together, they should form a sentence a teammate can parse on the pull request without opening the source:
describe('safeLimit', () => { it('allows the request through when Redis is unreachable', () => { /* ... */ }); it('blocks the request when the quota is exhausted', () => { /* ... */ });});“safeLimit allows the request through when Redis is unreachable” is the fail-open carve-out from the rate limiter you built.
Compare names that say nothing: 'works', 'works correctly', 'returns the right value', 'handles the case', 'test 1'.
When one fails, the report tells you 'works' is broken, which helps no one; the fix is mechanical, replace it with the outcome and the condition.
Test the behavior, not the implementation
Section titled “Test the behavior, not the implementation”This is the part the opening story was really about, and where tests quietly rot.
The rule: assert on what the caller observes.
The return value, the thrown error, the database row written, the HTTP status and body.
Do not assert on which private helper got called, the data structure mid-flight, or the order of the queries.
A test that checks “the function called _buildQuery(args)” is bolted to internal structure, so inlining _buildQuery breaks it even though every input still returns the identical output.
A test that checks “returns rows ordered by createdAt descending” is bolted to the contract, and that same refactor leaves it green.
One reflex decides it: the black-box thought experiment. Swap the implementation for a different one that satisfies the same contract, and ask whether the test still passes. If yes, you’re testing behavior; if no, implementation. Run it while deciding what to assert.
The trap is that the implementation-coupled test rarely looks wrong; it looks like something a competent developer writes on autopilot.
Watch the same scenario tested two ways, on safeLimit, which wraps limiter.limit(key) and must fail open when Redis is unreachable so an outage doesn’t lock every user out.
import { describe, it, expect, vi } from 'vitest';import { safeLimit, signInLimiter } from '@/lib/rate-limit';
describe('safeLimit', () => { it('allows the request through when Redis is unreachable', async () => { const spy = vi .spyOn(signInLimiter, 'limit') .mockRejectedValue(new Error('ECONNREFUSED'));
await safeLimit(signInLimiter, 'ip:1.2.3.4');
expect(spy).toHaveBeenCalledWith('ip:1.2.3.4'); });});This breaks the moment the function calls its collaborator differently, even when the output is byte-for-byte identical. It asserts the wiring, that limiter.limit was called with that key, not the decision the caller acts on. Route through a cache first and it goes red on a refactor that changed no behavior.
import { describe, it, expect, vi } from 'vitest';import { safeLimit, signInLimiter } from '@/lib/rate-limit';
describe('safeLimit', () => { it('allows the request through when Redis is unreachable', async () => { vi.spyOn(signInLimiter, 'limit').mockRejectedValue(new Error('ECONNREFUSED'));
const result = await safeLimit(signInLimiter, 'ip:1.2.3.4');
expect(result).toMatchObject({ success: true }); });});This survives any refactor that still produces the same decision. It asserts what the caller acts on: the request is allowed through, with success: true as the fail-open verdict. The spy still sets up the failure, legitimate Arrange, but the assertion is on the observable result, never on the spy.
Both tests spy on limiter.limit to arrange the failure; only the assertion differs, and the black-box test shows why it matters.
Replace safeLimit with a rewrite that consults a local cache before touching Redis: the contract test stays green, while the implementation test fails because limit wasn’t called the way it expected.
Same behavior, yet only one test reacted, and it reacted to the wrong thing.
That is the spy smell , and naming it precisely keeps you from overcorrecting: the problem is not mocks, which are how you arrange a dependency you can’t trigger for real.
The problem is toHaveBeenCalledWith on a value you set up two lines earlier: it verifies your own setup, then reports it as if it had tested the function.
The same logic governs how you stub external dependencies: mock the network, not the function.
When a test needs a third-party call stubbed, the stub belongs at the seam where your code meets the wire, not at the function calling it.
Mocking the function (“the code called fetchInvoice”) couples to a name, so renaming it to loadInvoice breaks the test.
Mocking the network (“a GET /invoices/:id returns this body”) couples to the contract and survives any rename on your side.
The tool for stubbing the wire, MSW , comes in the integration-testing chapter; here you only need the principle.
What to assert at each layer
Section titled “What to assert at each layer”You proved the rule on a pure function, where “observable” means the return value. The rule holds as you move up the stack; only the shape of “observable” changes, and the table below names each layer’s surface to assert against.
The tinted band in the middle is the integration layer: Server Actions, route handlers, and webhook receivers. This is where the bugs in a web app like this one cluster, so each seam earns two tests, not one: the observable success path, then the fail-closed branch, the 403, the rejected body, or the error the framework catches. Unhappy-path testing goes deeper in the next chapter.
The most common way to overshoot is to test the framework.
A unit test that drives Next.js’s render pipeline for a Server Component exercises Vercel’s code, not yours, and breaks whenever the framework changes under you.
Stop at the framework boundary: assert the Server Action body, the data-fetching helper, and the validator, not <Link>, redirect(), notFound(), or whether page.tsx rendered.
Choosing the matcher so failures explain themselves
Section titled “Choosing the matcher so failures explain themselves”The test name tells you which behavior broke. The assertion failure should tell you how, and that depends on the matcher you reach for.
Compare two failures.
The first asserts only the flag, and fails with expected false to be true: correct, but it tells you the flag flipped and nothing about why.
expect(result.ok).toBe(true);The second asserts the shape, so when status comes back 'draft' instead of 'paid', the diff names that exact field.
expect(result).toMatchObject({ ok: true, data: { id: expect.any(String), status: 'paid' },});Now the failure alone tells you what broke, no trip to the source or the debugger.
That is the payoff for choosing the matcher deliberately instead of reaching for toBe by reflex.
The course leans on a small set:
toBefor primitives and identity, as inexpect(total).toBe(0).toEqualfor deep value equality across a whole structure.toMatchObjectfor partial-shape matching, the workhorse forResultvalues and database rows. Assert the fields the caller depends on, not every field the row carries.toContainEqualfor “this item is somewhere in the array.”toThrowfor the error path.expect.any(String)andexpect.objectContaining(...)for fields that legitimately vary, like generated IDs and timestamps.
That last one is a stopgap. The durable fix is to make the value deterministic by pinning the clock and the ID generator, which the next chapter covers. For now, match the varying field loosely and assert the rest.
Snapshots are the matcher most easily misused. A snapshot is a behavior assertion only when it captures a contract the caller depends on, such as a rendered email template or an RFC 9457 body shape. It turns into an implementation assertion the moment it captures whatever the function happened to return today. The tell is churn: a snapshot that needs updating every other pull request is pinned to implementation, and each update quietly trains the team to approve without reading. Use snapshots for email templates and RFC 9457 shapes, and almost nowhere else.
Read the test, not the source
Section titled “Read the test, not the source”You’ll use this skill mostly when reviewing someone else’s tests, not when writing your own.
Read the test file with the source closed, and ask whether you could reimplement the unit from these tests alone. If you can, the tests are documentation, readable by the next engineer who’s never seen the code. If the file reads like a transcript of the implementation, naming private helpers, asserting call order, mirroring the source line for line, flag it. It’s the same lens you applied to coverage: ask what would have to change for this test to fail meaningfully, and if the only answer is “you’d have to delete the test,” it tests nothing.
This is why test names earn their keep.
Run vitest run --reporter=verbose and the reporter lists every name, a behavior catalog a new engineer reads first when something breaks.
Names that read as behaviors make that report documentation; names like 'works' make it noise.
So review this pull request adding a test file for safeLimit.
Read it source closed, asking only whether each test describes a behavior or an implementation, and comment on every line where the shape has slipped.
Review this test file the way you would on a real PR — read only the tests and flag anything coupled to implementation, bundling two behaviors, or named for nothing. Click any line to leave a review comment, then press Submit review.
import { describe, it, expect, vi } from 'vitest';import { safeLimit, signInLimiter } from '@/lib/rate-limit';
describe('safeLimit', () => { it('allows the request through when Redis is unreachable', async () => { const spy = vi .spyOn(signInLimiter, 'limit') .mockRejectedValue(new Error('ECONNREFUSED'));
await safeLimit(signInLimiter, 'ip:1.2.3.4');
expect(spy).toHaveBeenCalledWith('ip:1.2.3.4'); });
it('works', async () => { const allowed = await safeLimit(signInLimiter, 'ip:9.9.9.9'); expect(allowed.success).toBe(true);
vi.spyOn(signInLimiter, 'limit').mockResolvedValue({ success: false } as never); const blocked = await safeLimit(signInLimiter, 'ip:9.9.9.9'); expect(blocked.success).toBe(false); });});This asserts the wiring — that limiter.limit was called with that key — not the decision the caller acts on. Route safeLimit through a cache, or change how it forwards the key, and it goes red though nothing observable changed. Assert the returned decision ({ success: true }) instead.
'works' describes nothing — when it fails the report says works is broken, which is no help. Name the behavior with <outcome> when <conditions>.
toBe(true) on success fails with “expected false to be true” and tells you nothing. toMatchObject({ success: true }) at least documents the result shape in the diff.
This it asserts both the under-budget verdict (line 17) and the over-quota verdict (line 21). Two behaviors, one test — when it fails you can’t name which broke. Split into two its.
Every plant here is the same disease wearing different clothes — the test describes how the code works, not what it does for the caller. The spy assertion, the empty name, the weak matcher, the bundled behaviors: each fails the same check — could a stranger learn this unit’s contract from the file alone, and would it fail only when that contract actually breaks?
Four labels, one defect.
The spy assertion couples to plumbing, the empty name describes nothing, the toBe(true) reports a flipped bit instead of a diff, and the bundled it hides two behaviors, but every one is the same mistake: a test written around the implementation instead of the behavior.
External resources
Section titled “External resources”The writeups behind behavior-over-implementation are worth reading once; the Vitest matcher reference is the page to keep open while writing assertions.
The full matcher surface — the page to keep open while choosing how an assertion should fail.
Kent C. Dodds on why implementation-coupled tests give false negatives on refactors and false positives on real bugs.
The durable reference on what a unit test is — solitary vs sociable, and asserting on observable behavior.