---
title: "20 — Tests Are Executable Documentation of Behavior"
uid: testing-your-apps
tags: ["vitest", "jest", "playwright", "testing", "cypress", "e2e", "roadmap:frontend", "nextjs"]
excerpt: "A test is an executable claim about behavior, organized by scope — and each scope trades speed for confidence: fast at the bottom, trustworthy at the top."
date: 2026-08-12T18:35:09+0000
source: https://www.aveshina.my.id/en/blog/testing-your-apps
---

"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:

```figure
<svg viewBox="0 0 620 340" xmlns="http://www.w3.org/2000/svg" class="my-6 w-full max-w-2xl" role="img" aria-label="A testing pyramid with three layers. A wide indigo base labelled Unit tests — one function, instant. A narrower amber middle labelled Integration — a few units together. A small green apex labelled End-to-end — the whole user flow through a real browser. A vertical axis on the left: confidence increases upward, speed increases downward. Tool names float beside the layers: Jest and Vitest beside Unit, Cypress and Playwright beside E2E.">
  <g font-family="ui-sans-serif, system-ui, sans-serif" text-rendering="geometricPrecision">

    <!-- left axis -->
    <line x1="60" y1="40" x2="60" y2="300" stroke="#cbd5e1" stroke-width="1.5"/>
    <polygon points="60,34 56,44 64,44" fill="#cbd5e1"/>
    <polygon points="60,306 56,296 64,296" fill="#cbd5e1"/>
    <text x="44" y="55" font-size="11" font-weight="700" fill="#475569" text-anchor="middle">confidence</text>
    <text x="44" y="290" font-size="11" font-weight="700" fill="#475569" text-anchor="middle">speed</text>

    <!-- Unit (base) -->
    <polygon points="110,280 510,280 450,210 170,210" fill="#e0e7ff" stroke="#6366f1" stroke-width="1.5"/>
    <text x="310" y="242" font-size="15" font-weight="700" fill="#1e1b4b" text-anchor="middle">Unit</text>
    <text x="310" y="262" font-size="11" fill="#475569" text-anchor="middle">one function · instant</text>

    <!-- Integration (middle) -->
    <polygon points="170,210 450,210 400,140 220,140" fill="#fef9c3" stroke="#ca8a04" stroke-width="1.5"/>
    <text x="310" y="172" font-size="14" font-weight="700" fill="#422006" text-anchor="middle">Integration</text>
    <text x="310" y="190" font-size="11" fill="#475569" text-anchor="middle">units together</text>

    <!-- E2E (apex) -->
    <polygon points="220,140 400,140 310,70" fill="#dcfce7" stroke="#16a34a" stroke-width="1.5"/>
    <text x="310" y="108" font-size="13" font-weight="700" fill="#052e16" text-anchor="middle">End-to-end</text>
    <text x="310" y="125" font-size="10.5" fill="#052e16" text-anchor="middle">whole flow · browser</text>

    <!-- tool tags -->
    <g font-size="11" font-family="ui-monospace, monospace" fill="#1e1b4b">
      <rect x="525" y="226" width="80" height="22" rx="5" fill="#e0e7ff" stroke="#6366f1" stroke-width="1"/>
      <text x="565" y="241" text-anchor="middle">Jest</text>
      <rect x="525" y="252" width="80" height="22" rx="5" fill="#e0e7ff" stroke="#6366f1" stroke-width="1"/>
      <text x="565" y="267" text-anchor="middle">Vitest</text>
    </g>
    <g font-size="11" font-family="ui-monospace, monospace" fill="#052e16">
      <rect x="525" y="156" width="80" height="22" rx="5" fill="#dcfce7" stroke="#16a34a" stroke-width="1"/>
      <text x="565" y="171" text-anchor="middle">Playwright</text>
      <rect x="525" y="130" width="80" height="22" rx="5" fill="#dcfce7" stroke="#16a34a" stroke-width="1"/>
      <text x="565" y="145" text-anchor="middle">Cypress</text>
    </g>

    <text x="310" y="320" font-size="11" font-style="italic" fill="#64748b" text-anchor="middle">lots of cheap tests at the bottom · a few expensive ones at the top</text>
  </g>
</svg>
```

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

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:

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](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/](https://vitest.dev/)

[3] Microsoft, "Playwright: Fast and reliable end-to-end testing," 2024. [Online]. Available: [https://playwright.dev/](https://playwright.dev/)

[4] Cypress, "Cypress — JavaScript end-to-end testing framework," 2024. [Online]. Available: [https://www.cypress.io/](https://www.cypress.io/)

[5] Meta Open Source, "Jest — Delightful JavaScript testing," 2024. [Online]. Available: [https://jestjs.io/](https://jestjs.io/)

[6] Vercel, "Testing," Next.js Documentation, 2024. [Online]. Available: [https://nextjs.org/docs/app/guides/testing](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/](https://testing-library.com/docs/react-testing-library/intro/)

```quiz
Q: The defining trade-off between a unit test and an E2E test is…
- unit tests are slower; E2E tests are instant
- unit tests are instant but prove little about UX; E2E prove the journey but are slow
- there is no real difference
correct: 1
explain: Each layer trades speed for confidence. Unit tests run in milliseconds but say nothing about the real user flow; E2E tests prove the journey but take seconds to minutes and can flake.

Q: You're on a Vite project and need a unit test runner. The most native choice is…
- Jest, configured with a Vite transform
- Vitest, because it reads the same Vite config and pipeline
correct: 1
explain: Vitest is Vite-native and Jest-compatible — it uses your existing Vite pipeline so tests and the dev server agree on how files are parsed. There's rarely a reason to bolt Jest onto a Vite project.

Q: What makes Playwright distinctive among E2E tools?
- It only runs in Chromium
- It drives Chromium, Firefox, and WebKit from one API
- It runs tests inside the app's own browser origin
correct: 1
explain: Playwright's cross-browser support (Chromium, Firefox, WebKit) is its defining feature. Cypress, by contrast, historically ran in Chromium and in-process with the app.

Q: "A user can sign in and see their own name on the dashboard." This is a claim at which scope?
- Unit
- Integration
- End-to-end
correct: 2
explain: A full user journey through a real browser is an end-to-end claim, so it belongs at the top of the pyramid with Playwright or Cypress.

Q: Why is an all-E2E test suite considered an anti-pattern?
- E2E tests are too easy to write
- They are slow, flaky, and hard to localize when they fail
- E2E tests can't verify any real behavior
correct: 1
explain: E2E tests spend the most budget per claim. A suite made only of them becomes slow and brittle, and when one goes red it rarely tells you which of the many units involved broke — which is exactly what unit and integration tests are for.

Q: In a Next.js App Router project, why can't you unit-test a Server Component with a fake DOM like jsdom?
- Server Components are too large to render
- They run on the server with no browser or DOM, so there's nothing for jsdom to render
- They only run in production
correct: 1
explain: A Server Component renders on the server and ships HTML — no browser, no DOM. jsdom is a fake browser, so there's nothing for it to render. You test the logic the component calls instead, and let the E2E layer prove the rendered page.
```
