20 — Tests Are Executable Documentation of Behavior
"Extra homework after the feature ships" was my testing model, which made every test feel like a chore. The frame that changed it: a test is an executable claim about behavior, and tests are organized by scope. Each layer of scope trades speed for confidence — fast at the bottom, trustworthy at the top. Once I saw the layers separately, "what should I test" turned into "what scope is this claim about."
The whole shape is a pyramid (some people now call it a trophy, but the trade-off is identical). Lots of small, instant tests at the bottom proving one thing each; a few slow, expensive tests at the top proving the whole user journey:
The three scopes
Three layers, three claims about scope [1]:
- Unit tests — assert one function in isolation. Pure logic, no network, no database, no DOM. Thousands of them run in a second.
- Integration tests — assert that several units cooperate. Does my form component call the validation function and the submit handler in the right order? Does my API route read the right cache tag and purge it?
- End-to-end (E2E) tests — assert the whole user journey by driving a real browser: open the page, click the button, fill the form, see the success message. The closest thing to "did the user's task actually work."
The trade-off between them is the whole point. A unit test tells me formatPrice(9.5) returns "$9.50" — instantly, every save. It tells me nothing about whether the user can actually check out. An E2E test tells me checkout works end to end — but it takes thirty seconds, can flake (fail for reasons unrelated to my code, like a slow network), and tells me nothing about which of the dozens of functions involved broke. Each layer compensates for the other's blind spot. That's why you stack them, not pick one.
Unit — Jest and Vitest
The bottom layer is the domain of the unit test runner. You write expect(x).toBe(y), the runner finds the test files, runs them in parallel, and reports red or green [2][5].
Jest is the incumbent — Facebook's framework, the one most older tutorials assume [5]. It has auto-mocking (auto-generating fake stand-ins for modules), snapshot testing (saving a copy of the output and flagging when it changes), coverage reports (how much of your code the tests actually exercise), and a watch mode that re-runs only the tests affected by your edit. For years it was the default answer to "how do I test JS."
Vitest is the newer one I actually reach for now [2]. It's Vite-native: if your app builds with Vite (most modern setups do), Vitest reads the same config and runs your tests through the same transform pipeline — so a test and the dev server never disagree about how a file is parsed. Its API is deliberately Jest-compatible, so describe, it, expect all feel the same; migration is mostly deleting Jest config. The mental shortcut I use: Vitest is Jest, but it speaks Vite natively. On a Vite project there's no good reason left to wire up Jest.
End-to-end — Cypress and Playwright
The top layer is where the test stops pretending and drives a real browser. A unit test uses a mock — a fake stand-in — for the DOM; an E2E test is a user, clicking and typing in an actual page rendered by an actual server [3][4].
Cypress was the framework that made E2E approachable [4]. It runs inside the browser alongside the app, which gives it a couple of distinctive properties: time-travel debugging (you hover a command in the log and the app snaps back to that DOM state), automatic waiting (it retries until an element appears, no manual sleep), and a really pleasant interactive runner. Historically it was Chromium-only and ran in the same origin as the app, which limited some cross-origin flows.
Playwright is Microsoft's entry and the one I'd pick for a new project today [3]. The defining feature is cross-browser: one API drives Chromium, Firefox, and WebKit, so you can verify a flow on the engines behind Chrome, Firefox, and Safari rather than just Chromium. It's also multi-language — the same test can be written in TypeScript, Python, .NET, or Java — and it auto-waits, intercepts network requests, and can simulate mobile viewports. Its model is out-of-process: the test script talks to the browser over a protocol, which sidesteps the same-origin limits Cypress wrestled with.
Both are legitimate. The rule of thumb I use: Cypress if I want the nicest developer experience and a Chromium-only scope is fine; Playwright if I need cross-browser coverage or multi-language teams.
Which scope, then which tool
A habit these notes left me with: before I write a test, I ask what scope is this claim? That single question decides the tool before it decides anything else.
Claim | Scope | Tool |
|---|---|---|
"formatPrice() returns "$9.50" for 9.5" | Unit | Vitest (or Jest) |
"the form calls validate then submit" | Integration | Vitest (+ jsdom, a fake DOM, or RTL, the React Testing Library) |
"a user can log in and see their name" | E2E | Playwright (or Cypress) |
Pushing every claim to the top of the pyramid is the classic mistake — an all-E2E suite is slow, flaky, and hopeless to debug. Pushing everything to the bottom is the opposite mistake — a thousand green unit tests while checkout is actually broken. The pyramid shape isn't dogma; it's the natural consequence of the speed/confidence trade-off. You write many claims at the cheap layer and few at the expensive one, because the expensive ones are where your suite spends its budget.
A Next.js case — two homes for one app
So far I've been talking as if all the code lives in one place. A Next.js App Router project breaks that assumption, and that's the part I had to re-learn. In a Next.js app, a component has one of two homes: a Server Component runs on the server — no browser, no DOM — and ships finished HTML; a Client Component runs in the browser and is where interactivity lives. Same file tree, two different worlds, and a test has to respect which world it's reaching into.
The rule that fell out for me: test a Server Component the way you'd test a function's result, not a button's behavior. A Server Component has no DOM to poke, so you can't render it in a fake browser and start clicking. Instead you test the pieces you can hold in isolation — the data-fetching function it calls, the pure helper that formats its output — and you leave proving the full page, Server Component included, to the E2E layer, which is the only layer where a real server actually runs.
In a project like this site, the claims and their tools line up like this:
Claim | Where the code lives | Scope | Tool |
|---|---|---|---|
"fetchBlogs() returns the newest posts first" | a plain async function in lib/ | Unit | Vitest |
"the like button calls toggleLike then refreshes the count" | a Server Action + a Client Component | Integration | Vitest + React Testing Library |
"a visitor opens /en/blog, sees posts, and can open one" | the whole app, server and browser together | E2E | Playwright against next dev or a built app |
Two details worth spelling out, because they were the traps I actually hit:
- Server Actions and route handlers are just async functions. A Server Action isn't magic; it's an async function that happens to be callable from the browser. You can import it in a test and call it directly to check the logic, then check the UI wiring separately. The trick is to keep the interesting logic in plain functions in lib/, so the "just call the function" test doesn't drag the whole framework along with it.
- Playwright runs against the real app, not against components. You point it at next dev or a built-and-started server. That's on purpose: the E2E layer's whole job is "a real browser against a real server," and it's the only layer where your Server Components actually get exercised end to end.
The habit this leaves me with: before I write any test in a Next.js project, I ask which home the code lives in. Server code → pull the logic into a plain function and unit-test it; client code → React Testing Library; the whole journey → Playwright. It's the same pyramid as before — the split between server and browser just decides which layer I'm standing on.
Why I wrote this down
The reason testing ever felt like homework was that I was picking tools before I'd decided what I was claiming. "Should I use Jest or Cypress for this?" is the wrong first question; the right one is "am I claiming something about one function, a few units, or a user's journey?" Answer that, and the tool picks itself — Jest/Vitest for the bottom, Cypress/Playwright for the top, and the pyramid's shape falls out of the trade-off for free.
References
[1] Atlassian, "The different types of software tests," 2024. [Online]. Available: https://www.atlassian.com/continuous-delivery/software-testing/types-of-software-testing
[2] Vitest, "Vitest — Next generation testing framework," 2024. [Online]. Available: https://vitest.dev/
[3] Microsoft, "Playwright: Fast and reliable end-to-end testing," 2024. [Online]. Available: https://playwright.dev/
[4] Cypress, "Cypress — JavaScript end-to-end testing framework," 2024. [Online]. Available: https://www.cypress.io/
[5] Meta Open Source, "Jest — Delightful JavaScript testing," 2024. [Online]. Available: https://jestjs.io/
[6] Vercel, "Testing," Next.js Documentation, 2024. [Online]. Available: https://nextjs.org/docs/app/guides/testing
[7] Testing Library, "React Testing Library," 2024. [Online]. Available: https://testing-library.com/docs/react-testing-library/intro/
Knowledge check · Question 1 of 6
The defining trade-off between a unit test and an E2E test is…
Comments
Leave a Comment
You must be signed in to comment
0 Comments
No comments yet. Be the first to comment!