Full-Stack Questions

Testing Tools: Jest, Vitest, Testing Library, Playwright

Mocks and fake timers, MSW, user-event, testing hooks and Server Components, Playwright locators and flaky tests, database tests with Prisma.

Lesson 2128 min read#testing

On this page

  1. Words used in this lesson
  2. Jest vs Vitest
  3. Test doubles: stub, spy, mock, fake
  4. Fake timers
  5. MSW: mock the network, not fetch
  6. React Testing Library: queries and user-event
  7. Testing Next.js: Server Components and route handlers
  8. Playwright
  9. Test databases with Prisma
  10. Coverage and snapshots: two common myths
  11. Interview questions
  12. Key takeaways
  13. What's next

In one line: a good test tool setup is boring: Vitest or Jest to run tests, Testing Library to act like a user, MSW to fake the network, Playwright for a few real-browser flows, and a real database when the question is about data. Interviewers want to hear what you fake, what you keep real, and why.

At 4+ years you are not asked "what is a unit test?" You are asked why a test is flaky, why vi.mock fails with "before initialization", how you test an async Server Component, and what you do about the test database. This lesson covers the tools and the traps that come with them.

Strategy is not repeated here. What to test and at which level is in Frontend Testing Strategy. Backend API tests, contract tests and what to mock on the server are in Testing an API. This lesson is the tool layer under both.

Versions used and run in October 2026: Vitest 5.0, Jest 30.5, Testing Library React 16, user-event 14, MSW 3.0, Playwright 1.64, Prisma 7.10 (with SQLite), Next.js 16.3. Every code sample below was run.

Words used in this lesson

  • A test runner is a program that finds your test files, runs them and prints which passed. Vitest and Jest are test runners.
  • A test double is a fake thing you use in a test instead of the real thing. It is like a crash-test dummy in a car test.
  • A stub is a double that returns a fixed answer. A spy is a wrapper that records how a real function was called, and still runs it. A mock is a double that records calls so you can check them. A fake is a small, working copy of the real thing (an in-memory database).
  • Fake timers are a replacement for setTimeout, setInterval and Date where you move the clock by hand.
  • MSW (Mock Service Worker) is a library that catches network requests in tests and answers them with responses you wrote.
  • jsdom is a library that gives Node.js a fake browser page (document, window) so components can render without a real browser.
  • Hoisting means a tool moves a line of code to the top of the file before it runs.
  • A flaky test is a test that sometimes passes and sometimes fails with the same code.
  • A snapshot test saves the output of something and fails when the output changes later.
  • Code coverage is a number that says how many lines or branches of your code ran during the tests.

Jest vs Vitest

Jest 30Vitest 5
Made byMeta, then the OpenJS FoundationThe Vite team and community
Transforms code withBabel or SWC (you configure it; next/jest does it for Next.js)Vite, the same pipeline as your app
ES modules and TypeScriptNeeds a transform or flagsWork out of the box
Globals (test, expect)OnOff by default (import { test } from 'vitest')
Mock helperjest.fn, jest.mockvi.fn, vi.mock
Default test timeout5 seconds5 seconds
jsdomA separate package, jest-environment-jsdomSet environment: 'jsdom' and install jsdom

Difference in one line: Jest brings its own pipeline and you bend it to your code; Vitest reuses the pipeline your app already has. The API is about 90% the same, so moving between them is mostly a rename.

Choose Vitest for new Vite or Next.js projects with TypeScript. Keep Jest for a large working suite or React Native. The Next.js docs support both, and they say the same thing about both: neither can render an async Server Component, so use end-to-end tests for those.

Old advice says "Jest mocks your dependencies automatically". That is wrong now. The automock setting is false by default. Jest 30 also removed the old alias toBeCalled() (use toHaveBeenCalled()). I ran it and got toBeCalled is not a function.

Test doubles: stub, spy, mock, fake

JavaScript
import { test, expect, vi } from 'vitest'import { welcome, type Mailer } from '../src/mail'test('mock: record calls and check them', async () => {  const send = vi.fn().mockResolvedValue(undefined)  await welcome({ email: 'a@x.com', name: 'Ann' }, { send })  expect(send).toHaveBeenCalledExactlyOnceWith('a@x.com', 'Hi Ann')})test('fake: a small working copy', async () => {  const outbox: string[] = []  const fake: Mailer = { send: async (to, body) => { outbox.push(`${to}:${body}`) } }  await welcome({ email: 'a@x.com', name: 'Ann' }, fake)  expect(outbox).toEqual(['a@x.com:Hi Ann'])})test('spy: wrap a real method, keep its behaviour', () => {  const obj = { double: (n: number) => n * 2 }  const spy = vi.spyOn(obj, 'double')  expect(obj.double(4)).toBe(8)  expect(spy).toHaveBeenCalledWith(4)})
DoubleReal code runs?Use it to
StubNoGive a fixed answer ("the API returns 500")
SpyYesCheck a call happened, without changing behaviour
MockNoCheck how it was called (arguments, count)
FakeA simple versionReplace something heavy (database, queue) with something small that behaves the same

Passing a double in as an argument (as above) is the easy case. It works because welcome takes mailer as a parameter. Code that takes its dependencies as arguments needs far less mocking.

In current Vitest, vi.restoreAllMocks() only restores spies made with vi.spyOn. It does not reset the call history of vi.fn(). I checked: after restoreAllMocks the count was still 1, and after clearAllMocks it was 0. Old posts say the opposite.

Mocking a module

JavaScript
import { vi } from 'vitest'import { label } from '../src/report'      // report.ts imports { now } from './clock'const { fakeNow } = vi.hoisted(() => ({ fakeNow: vi.fn(() => 7) }))vi.mock('../src/clock', () => ({ now: fakeNow }))

vi.mock (and jest.mock) is hoisted: it is moved above your imports so the fake is in place before report.ts loads. The price is that the factory runs before your other top-level lines. If the factory uses a normal const from your file, you get Cannot access 'fakeNow' before initialization. I ran it. vi.hoisted is the fix: it runs a small function at the very top and gives you its result. In Jest the same fix is a variable whose name starts with mock.

A second trap: vi.spyOn(clock, 'now').mockReturnValue(1) changes what other files see. A function inside clock.ts that calls now() directly still got the real time in my run, because it never goes through the export.

Mock the edges of your system (network, clock, randomness, next/headers). Do not mock your own modules to make a test easy; that is what Frontend Testing Strategy warns about.

Fake timers

JavaScript
test('debounce runs once after the quiet time', () => {  vi.useFakeTimers()  const fn = vi.fn()  const d = debounce(fn, 300)  d('a'); d('b'); d('c')  vi.advanceTimersByTime(299)  expect(fn).not.toHaveBeenCalled()  vi.advanceTimersByTime(1)  expect(fn).toHaveBeenCalledExactlyOnceWith('c')  vi.useRealTimers()})
  • Fake timers also fake Date. Use vi.setSystemTime(new Date('2026-01-01')) to test "is the coupon expired?" without waiting. (In Jest: jest.setSystemTime.)
  • If a timer callback uses promises, use the async version: await vi.advanceTimersByTimeAsync(1000). The sync version does not let promise callbacks run in between.
  • Always go back to real timers in afterEach, or the next test inherits the fake clock.

Vitest + Testing Library + fake timers can hang. Testing Library waits with a real setTimeout(0) unless it sees the jest global. Under Vitest that global does not exist, so await user.type(...) waited forever, and my test timed out. Even advanceTimers in userEvent.setup() did not help. What worked: vi.useFakeTimers({ shouldAdvanceTime: true }), which lets the fake clock tick forward with real time. Another fix is to set globalThis.jest = { advanceTimersByTime: vi.advanceTimersByTime.bind(vi) }. In Jest this just works.

MSW: mock the network, not fetch

JavaScript
// test/server.tsimport { setupServer } from 'msw/node'import { http, HttpResponse } from 'msw'export const server = setupServer(  http.get('/api/users', () => HttpResponse.json([{ id: 1, name: 'Ann' }])),)// test/msw-setup.ts  (listed in vitest `setupFiles`)beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))afterEach(() => server.resetHandlers())afterAll(() => server.close())
JavaScript
test('shows an error when the API fails', async () => {  server.use(http.get('/api/users', () => new HttpResponse(null, { status: 500 })))  render(<Users />)  expect(await screen.findByRole('alert')).toHaveTextContent('HTTP 500')})

Why this beats vi.mock('fetch'): your real fetch call, your data library, retries and error code all run. Only the server is fake. A mock of fetch hides bugs in how you build the URL and read the response.

  • onUnhandledRequest: 'error' makes a request with no handler fail the test. Without it, MSW lets the request go to the real network. I ran it and saw [MSW] Error: intercepted a request without a matching request handler.
  • server.use(...) adds a handler for one test. resetHandlers() in afterEach removes it, so tests do not leak into each other.
  • Gotcha: Node's fetch cannot resolve a relative URL like /api/users in jsdom. I had to give fetch a base address. In your app, use an absolute URL from an environment variable.
  • MSW 3 is new (released late September 2026). The http, HttpResponse and setupServer code above ran on 3.0.2. Check the changelog before you upgrade a large suite.

The backend side of this (a fake server for third-party APIs) is in Testing an API.

React Testing Library: queries and user-event

React Testing Library (RTL) is a library that renders a React component and lets you find and use elements the way a user does. user-event is a companion library that simulates real user actions (type, click, tab) with all the events a browser would send.

Which query? There are three families, and the name tells you what happens when nothing is found:

FamilyNot foundFound twiceUse when
getBy…ThrowsThrowsThe element must be there now
queryBy…Returns nullThrowsYou check it is not there
findBy…Waits, then throwsThrowsIt will appear soon (async)

Add All (getAllBy…) when many matches are fine.

Order of preference: getByRole (with name), then getByLabelText, then getByText, and getByTestId last. Roles match what a screen reader sees, so a good role query also proves your page is accessible.

JavaScript
test('user-event: click and see the result', async () => {  const user = userEvent.setup()  const onSave = vi.fn()  render(<Counter onSave={onSave} />)  const save = screen.getByRole('button', { name: 'Save' })  expect(save).toBeDisabled()  await user.click(screen.getByRole('button', { name: 'Add one' }))  expect(screen.getByText('Count: 1')).toBeInTheDocument()  await user.click(save)  expect(onSave).toHaveBeenCalledWith(1)})

Difference in one line: fireEvent sends one raw event; userEvent plays the whole story of a click (hover, mouse down, focus, mouse up, click) or of typing (key down, input, key up). userEvent finds bugs that fireEvent hides, such as a disabled button that still got a click. Always call userEvent.setup() first and await every action.

Hooks and async UI

  • A custom hook is a function that uses React hooks. You cannot call it outside a component, so use renderHook, which runs it inside a tiny test component. Wrap changes in act(() => ...).
JavaScript
const { result } = renderHook(() => useToggle())act(() => result.current.toggle())expect(result.current.on).toBe(true)
  • act tells React "finish all updates from this change before I look". RTL and user-event already wrap their actions in it. A warning "not wrapped in act(...)" usually means an update happened after your test ended its check. The fix is to wait for the end state (await screen.findBy…), not to sprinkle act.
  • Prefer findBy… over waitFor(() => expect(...)). If you use waitFor, put one assertion inside it and no side effects such as clicks.

Testing Next.js: Server Components and route handlers

Rule from the Next.js docs: async Server Components are not supported by Jest or Vitest renderers, so use E2E tests for them. Two honest options exist.

Option 1: a unit-style trick. An async component is only an async function. Call it, await the result, and render the JSX it returns:

JavaScript
test('async Server Component: await the function, then render the result', async () => {  const ui = await OrderPage({ params: Promise.resolve({ id: '7' }) })  render(ui)  expect(screen.getByRole('heading')).toHaveTextContent('Order 7: book')})

This worked in my run, including params as a Promise (Next.js 16 passes params as a Promise). Rendering it directly with render(<OrderPage … />) failed: React logged "is an async Client Component. Only Server Components can be async" and the page stayed empty.

The limit is real. This tests your branches and markup, not caching, streaming, Suspense or layouts. Anything from next/headers must be mocked: vi.mock('next/headers', () => ({ cookies: async () => ({ get: … }) })) worked for me.

Option 2: Playwright against next build and next start. This is the real thing. The Next.js docs recommend testing production code. Use it for pages that depend on caching or streaming.

Route handlers are the easy case. A handler is a function that takes a Request and returns a Response. Call it with a real NextRequest in the Node environment:

JavaScript
const res = await GET(new NextRequest('http://localhost/api/orders?id=7'))expect(res.status).toBe(200)expect(await res.json()).toEqual({ id: 7, item: 'book', total: 12 })

I ran the 200, 400 (bad id) and 404 (missing order) cases this way. No server is started. For middleware-level behaviour (redirects, auth cookies) use Playwright. See Next.js, Lesson 5 for the rendering model behind this.

Playwright

Playwright is a tool that drives a real browser from code. A locator is an object that describes how to find an element; it looks again each time you use it. Auto-waiting means Playwright waits before each action until the element is visible, stable, enabled and able to receive the click.

JavaScript
test('add a todo (auto-wait: no sleeps)', async ({ page }) => {  await page.goto('/')  await page.getByLabel('New todo').fill('buy milk')  await page.getByRole('button', { name: 'Add' }).click()  await expect(page.getByRole('status')).toHaveText('Saved')      // retries until true  await expect(page.getByRole('listitem')).toHaveText(['buy milk'])})
  • Locators: prefer getByRole, getByLabel, getByText, then getByTestId. Avoid CSS paths like div > div:nth-child(3).
  • Strict mode: if a locator matches more than one element, an action fails. I ran it: strict mode violation: getByRole('listitem') resolved to 2 elements. That is a feature. Narrow the locator instead of adding .first().
  • Web-first assertions (await expect(locator).toHaveText(...)) retry for 5 seconds by default. The whole test has 30 seconds by default (both can be changed). Actions have no timeout of their own, so they live inside the test timeout.
  • Fixtures: a fixture is setup that a test asks for by name and Playwright builds fresh for each test, then cleans up. Use test.extend to make your own (logged-in page, seeded data).
JavaScript
const test = base.extend<{ app: Page }>({  app: async ({ page }, use) => { await page.goto('/'); await use(page) },})
  • Network inside the browser: page.route('**/api/todos', r => r.fulfill({ json: … })) fakes one call. I used it to test the UI against a made-up response. Inside a Playwright test, route is the simple built-in way.
  • Config: webServer starts your app and waits for it. Use retries in CI only, set trace: 'on-first-retry', and use fullyParallel: true once tests are independent.

Flaky tests and the trace viewer

The classic cause of flakiness is reading state once instead of waiting for it:

JavaScript
const text = await page.getByRole('status').textContent()   // reads once, no retryexpect(text).toBe('Saved')                                  // got "Saving..."

My slow demo server took 400 ms, so this failed every time. On a fast machine it would pass sometimes. That is how a flaky test is born. The fix is one line: await expect(page.getByRole('status')).toHaveText('Saved').

CauseFix
One-time read (textContent, isVisible)Web-first expect that retries
waitForTimeout(2000)Wait for the real signal: an element, a URL, a response
Shared data between testsEach test creates its own user and data
Real third-party callsFake with page.route or a test server
Order of testsTests must pass in any order and in parallel

Trace viewer: a trace is a recording of the whole test: each step, a DOM snapshot before and after, the network calls, the console and the source line. With trace: 'on-first-retry' and retries: 1, the first retry saved trace.zip. Open it with npx playwright show-trace trace.zip and you see the page as it was when the step failed. Retries hide flakiness, so track tests that needed a retry ("flaky" in the report) and fix them; do not just raise the retry count.

Test databases with Prisma

Backend rules (real database, isolation, speed) are in Testing an API. Here are the Prisma-specific choices. I ran all three with Prisma 7.10 and SQLite.

StrategyHowGoodBad
Clean tables each testdeleteMany() (or TRUNCATE) in beforeEachSimple, the code under test uses a normal clientSlower with many tables; order of deletes matters with foreign keys
Rollback transactionOpen db.$transaction, run the test with tx, throw to roll backVery fast, nothing left behindThe code must accept tx; code that opens its own transaction or uses a different connection will not see the data
One database per workerCopy a template file or create a schema per workerParallel files never collideSetup work; needs a template built once
JavaScript
// global setup: build ONE template database from the schema, onceconst sql = execSync('npx prisma migrate diff --from-empty --to-schema prisma/schema.prisma --script').toString()new Database('.testdb/template.db').exec(sql.slice(sql.indexOf('-- CreateTable')))// per test file: its own copy, keyed by the worker numberexport function workerDb() {  const file = `.testdb/worker-${process.env.VITEST_POOL_ID ?? '0'}.db`  copyFileSync('.testdb/template.db', file)  return makeClient('file:./' + file)}// rollback strategyclass Rollback extends Error {}await db.$transaction(async (tx) => { await run(tx); throw new Rollback() }).catch(e => { if (!(e instanceof Rollback)) throw e })

What passed: a duplicate email failed with Prisma error code P2002; deleting a user cascaded to its orders; the rollback test left zero rows. These are the checks a mock could never give you.

  • Use the same database engine as production. SQLite is fine for a demo, but it differs from PostgreSQL in types, JSON, case-sensitivity and locking. If production is Postgres, test on Postgres (a container, or one local server with one schema per worker).
  • Build the template from your real migrations (prisma migrate deploy in CI). I generated SQL from the schema with migrate diff and did not run migrate deploy itself.
  • Never point tests at a shared or production DATABASE_URL. Read the URL from a test-only variable and refuse to start if it does not contain "test".

Coverage and snapshots: two common myths

Coverage measures what ran, not what was checked. I wrote this test:

JavaScript
test('runs the code, checks nothing', () => {  price(3, true)  price(3, false)})

The Vitest report (v8 provider) showed 100% branch coverage for price.ts, and the only uncovered line was another function I never called. The test can never fail. Coverage is useful in one direction: a low or red number shows code that no test touched. A high number proves nothing. Chasing 100% leads to tests that call code and assert nothing. Better: look at the uncovered lines in a pull request, and use mutation testing (a tool such as Stryker changes your code on purpose and checks that a test fails) on the important modules.

Snapshots. toMatchInlineSnapshot() writes the expected value into the test file. toMatchSnapshot() writes it to a .snap file. My snapshot of a tiny counter's DOM was 18 lines for a component with two buttons. Nobody reads that diff. Rules: small values only, prefer inline, review every change, and never run -u (update all) to make red go green. Details and the visual-test option are in Frontend Testing Strategy.

Interview questions

Jest or Vitest: which do you choose, and why? (must know)

For a new TypeScript, Vite or Next.js project I choose Vitest. It reuses the same transform as the app, so ES modules and TypeScript work with no extra setup, and watch mode is fast. The API is almost the same as Jest, so people feel at home. I keep Jest when a large suite already works or for React Native, because moving has a cost and no user benefit. One thing is the same for both: they cannot render async Server Components, so I use Playwright for those.

You migrate a suite from Jest to Vitest. What breaks? (senior signal)

Mostly renames: jest.fn to vi.fn, jest.mock to vi.mock, and I add import { test, expect, vi } from 'vitest' because globals are off by default. Three real traps. First, vi.mock is hoisted, so a factory that uses a top-level variable fails with "before initialization"; I fix it with vi.hoisted. Second, restoreAllMocks in current Vitest no longer clears the call history of vi.fn(), so I add clearAllMocks or set mockReset in the config. Third, Testing Library with fake timers can hang because it does not see the jest global; I use shouldAdvanceTime: true.

What is the difference between a stub, a spy, a mock and a fake? (must know)

A stub gives a fixed answer and I do not check it. A spy wraps the real function, so the real code still runs, and records the calls. A mock replaces the function and records calls so I can check the arguments and count. A fake is a small working copy, like an in-memory outbox instead of a real mail service. I choose by what I want to prove: a fake or stub when I care about the result, a mock when the call itself is the behaviour (an email was sent once). People say "mock" for all four, so I name the one I mean. Too many mocks on your own code is a smell; pass dependencies as arguments instead.

vi.mock fails with "Cannot access 'x' before initialization". Why? (trap)

vi.mock is hoisted to the top of the file, above my imports, so the fake module is ready before the real one loads. The factory function then runs before my other top-level lines, including the const fakeNow = vi.fn() I wrote above it. That variable does not exist yet. The fix is vi.hoisted(() => ({ fakeNow: vi.fn() })), which also runs at the top and returns the value. In Jest, the babel plugin allows variables whose name starts with mock.

You spied on an exported function, but the code under test still used the real one. Why? (trap)

A spy replaces the property on the module's export object. Code that imports the function from that module sees the spy. But a function inside the same file calls the local binding directly, not the export, so it never sees the spy. I ran it: spyOn(clock, 'now') worked for importers, but stamp() inside clock.ts still returned the real time. The fix is to move the thing I want to fake into its own module and use vi.mock, or pass it in as an argument.

How do you test code that uses setTimeout, like a debounce or a retry with delay? (must know)

I use fake timers. I call vi.useFakeTimers(), run the code, and move the clock with advanceTimersByTime. For a debounce I check that after 299 ms the function was not called, and after 300 ms it was called once with the last argument. If a promise is involved, I use advanceTimersByTimeAsync. setSystemTime fakes Date, which is how I test expiry. I always restore real timers in afterEach.

Your component test hangs after you turn on fake timers and use user.type. What happened? (trap)

Testing Library waits for the next tick with a real setTimeout(0), unless it detects Jest fake timers by looking for the jest global. Under Vitest there is none, so it waits on a timer that is faked and never fires. I reproduced this: even advanceTimers in userEvent.setup did not help. vi.useFakeTimers({ shouldAdvanceTime: true }) fixed it, because the clock then ticks with real time. A second fix is a small globalThis.jest shim. A third is not to fake timers in that test, and to wait with findBy… instead.

Why do you use MSW instead of mocking fetch? (must know)

MSW fakes the server, not the client. My real fetch, my data library, my retries and my error handling all run, and only the response is invented. If I mock fetch itself, I can break the URL building or the way I read the body, and the test still passes. I set onUnhandledRequest: 'error' so a request nobody planned for fails the test instead of leaking to the real network. I keep happy-path handlers in one file, and override a single handler with server.use for the error case, then resetHandlers after each test.

getBy, queryBy, findBy: when do you use which? (must know)

getBy throws if it finds nothing, so I use it when the element must be there now. queryBy returns null, so I use it to prove something is absent: expect(queryByText('x')).toBeNull(). findBy waits (up to 1 second by default) and is for things that appear after an async step. All three throw if there are many matches; the All versions return an array. For the choice of query, I go getByRole with a name first, then label, then text, and test id last, because the role is what a user and a screen reader depend on.

userEvent or fireEvent? (must know)

userEvent. fireEvent sends one DOM event. userEvent sends the full sequence a browser would send: hover, pointer down, focus, pointer up, click, or for typing, key down, input, key up for each character. It respects disabled buttons and readonly inputs, so it finds bugs that fireEvent skips. I call userEvent.setup() once per test and await each action. I use fireEvent only for an event userEvent does not model, such as a custom event or a scroll.

You see "not wrapped in act(...)". What do you do? (senior signal)

act makes React finish all state updates caused by an action before I look at the screen. Testing Library already wraps its calls. The warning means a state update happened outside any wait: for example, a fetch finished after the test had finished checking. I do not add act everywhere. I find the update (usually a data load or timer) and wait for the final state with await findBy…. If a real timer causes it, I use fake timers and act(() => vi.advanceTimersByTime(...)). For a hook test, I wrap the call in act directly.

How do you unit test an async Server Component? (senior signal)

Honestly, the tools cannot render it. React warns "only Server Components can be async" if I try render(<Page />). What I do is call it as a function: const ui = await Page({ params: Promise.resolve({ id: '7' }) }), then render(ui). That tests the branches and markup, and I mock next/headers and my data layer. I ran this. But it does not test caching, streaming or layouts. For those I use Playwright against next build and next start, as the Next.js docs suggest.

How do you test a route handler in the App Router? (must know)

A route handler is just a function: GET(request) returns a Response. I import it, call it with new NextRequest('http://localhost/api/orders?id=7'), and check status and await res.json(). No server starts, so it is fast. I test the happy path, bad input (400), not found (404), and an auth failure. I fake the data layer, or better, use a test database. For cookies or headers, I pass them in the request. What I cannot see this way is middleware and the real routing, so I keep one or two Playwright tests for those.

How does Playwright's auto-waiting work, and why does it matter? (must know)

Before each action, Playwright checks that the element is attached, visible, stable (not moving), enabled and not covered, and it waits until that is true or the timeout hits. Web-first assertions like expect(locator).toHaveText() retry for 5 seconds by default. This replaces sleeps, which are slow when the page is fast and too short when it is slow. The mistake that breaks it is reading a value once, such as await locator.textContent() followed by a normal expect. I demonstrated that this fails against a slow API.

A Playwright test passes locally and fails one in ten times in CI. How do you find the cause? (senior signal)

First I get evidence: trace: 'on-first-retry' and retries: 1 in CI, then open trace.zip with show-trace to see the page, network and console at the failing step. I also run the test locally with --repeat-each=20, and with fewer or more workers. Common causes: a one-time read, a fixed sleep, shared data between tests, a slow CI machine exposing a missing wait, a real third-party call, or test order. I fix the cause, not the symptom. I do not raise the timeout or retries without a reason. I track "flaky" tests in the report and treat them as bugs, since a suite people re-run until green stops being believed.

How do you set up the database for tests with Prisma? (senior signal)

I use a real database of the same kind as production. The schema comes from the real migrations, built once in a global setup. Then I pick isolation by need. Clean tables with deleteMany in beforeEach is the simplest. A rollback transaction is fastest: run the test inside $transaction with the tx client and throw at the end; the catch is that the code must accept tx. For parallel test files, I give each worker its own database or schema, keyed by the worker number; I ran this with a copied template file. I read the connection string from a test-only variable and refuse to run if it looks like production. I do mock Prisma for small branching tests, but not for anything about queries or constraints. A mock cannot show a P2002 unique error or a cascade delete.

Your manager asks for 90% coverage. What do you say? (trap)

I would say coverage is a good question-asker and a bad goal. A test that calls a function and asserts nothing gives full coverage and can never fail; I wrote one and the report showed 100% branches. What a low number does tell me is that some code ran in no test. I would set a floor to prevent decline, look at uncovered lines in pull requests, and put the real effort into the risky code: money, permissions, data changes. For the most critical modules I would try mutation testing, which changes the code and checks that a test fails. That measures whether tests actually check something.

Are snapshot tests good or bad? (trap)

Both, by size. A small snapshot of a clear value (an error object, a parsed result) is fine, especially inline, where the expected value sits in the test. A snapshot of a whole component's DOM is noise: my counter with two buttons made an 18-line file, and any markup change breaks it. Reviewers then press update without reading. Rules I follow: keep them small, prefer toMatchInlineSnapshot, read each diff, never use -u to hide a failure, and replace most DOM snapshots with targeted assertions such as getByRole(...). For visual checks I use Playwright screenshots with a tolerance.

Key takeaways

  • Vitest and Jest are about 90% the same API. Vitest reuses your Vite pipeline; neither renders async Server Components.
  • vi.mock is hoisted: use vi.hoisted for shared variables. A spy does not change calls inside the same file.
  • Fake timers fake Date too. With Testing Library under Vitest, use shouldAdvanceTime: true.
  • MSW fakes the server, with onUnhandledRequest: 'error' and resetHandlers after each test.
  • getBy throws, queryBy returns null, findBy waits. Use getByRole first and userEvent with await.
  • Call an async Server Component as a function, then render the result; use Playwright for the real behaviour. Call route handlers directly with NextRequest.
  • In Playwright, never read once: use retrying expect. Strict locators, fixtures and trace: 'on-first-retry' find flakiness.
  • Test the database with the real engine and real migrations; isolate by cleaning, rolling back, or one database per worker.
  • Coverage shows untested code, not tested behaviour. Keep snapshots small.

What's next

Lesson 22: OAuth 2.0 and OpenID Connect. How users sign in to a Next.js app, and how to protect every route and Server Action on the server.

Published by Jainik · Last updated · How these lessons are made

Keep learning

Related lessons

Part of the study guide

Related series