Tests that mock fetch() don't catch real caching behavior — ISR, Full Route Cache, and Data Cache behave differently in test vs production
Vitest handles unit and integration tests with jsdom or node environment — fast but limited to isolated logic, not real rendering
Playwright E2E tests run against a real browser and server — they catch caching, hydration, and data loading issues that Vitest misses
Test isolation means each test starts with a clean state — no shared cache, no shared data, no shared session
Component testing with Vitest + React Testing Library validates UI logic but skips the server component layer entirely
Biggest mistake: relying solely on Vitest unit tests with mocked fetch(), and deploying without Playwright E2E tests against a real Next.js server
✦ Definition~90s read
What is Testing Next.js Applications?
Testing Next.js 16 applications requires a multi-layered strategy because no single testing tool can cover the full stack. Vitest provides fast, isolated unit and integration tests for non-server code — utilities, hooks, Zod schemas, and pure components.
★
Testing in Next.js 16 is like testing a restaurant kitchen.
It runs in Node.js with jsdom for DOM simulation and supports mocking for external dependencies. However, Vitest does not run a real Next.js server, so it cannot test caching (Full Route Cache, ISR, Data Cache), server-side rendering (Server Components), or real API behavior.
Playwright fills this gap with browser-based E2E tests against a real Next.js server. Playwright tests execute actual JavaScript in Chromium, Firefox, or WebKit, making real HTTP requests, triggering real caching behavior, and rendering real HTML. They catch the bugs that matter most: broken user flows, incorrect data loading, hydration mismatches, cache staleness, and navigation issues.
The trade-off is speed — Playwright tests take seconds to run compared to milliseconds for Vitest.
The third layer — environment verification — tests that the production environment is correctly configured. A simple test that starts the Next.js server with production-only environment variables and checks the health endpoint catches the 'works in staging, fails in production' gap.
This layer is often overlooked but prevents the most expensive class of failure: a successful deployment that immediately breaks for all users because of missing configuration.
Test isolation is critical across all layers. Each test must start with a clean state — no shared mocks, no shared database records, no shared session data. Without isolation, test order dependencies cause flaky failures that erode trust in the test suite.
In Vitest, vi.clearAllMocks() in beforeEach ensures clean mock state. In Playwright, clearing cookies, localStorage, and using isolated data fixtures ensures each test runs independently.
Plain-English First
Testing in Next.js 16 is like testing a restaurant kitchen. Vitest unit tests are like checking that each individual ingredient is correct — the salt is sodium chloride, the eggs are fresh. But they don't tell you if the dish tastes right when all ingredients are combined and cooked. Playwright E2E tests are like hiring a food critic to eat the full meal — they experience what the customer experiences, including the cooking time (cache), the plating (rendering), and the temperature (data freshness). If you only test ingredients, you miss the dish. If you only test the dish, you don't catch bad ingredients. You need both.
⚙ Browser compatibility
Latest versions — ✓ supported
Chrome
Firefox
Safari
Edge
✓
✓
✓
✓
Next.js 16 testing has a gap that catches teams off guard: unit tests with mocked fetch() pass with flying colors, but the same application fails in production because caching, server rendering, and environment variables behave fundamentally differently in test vs real deployment.
The gap is structural. Vitest runs in a Node.js environment with jsdom — there is no actual Next.js server, no Full Route Cache, no ISR regeneration, no Client Router Cache. When you mock fetch(), you're testing your component logic against fake data, not the real data-loading pipeline. Playwright E2E tests run against a real Next.js dev server or build output — they exercise the actual caching, rendering, and data loading that users experience. But Playwright tests are slower and flakier, so teams prioritize Vitest and skip the E2E layer.
In this article, you'll learn the complete testing stack: Vitest for unit and integration tests of isolated logic, component testing with React Testing Library for UI interactions, Playwright for E2E tests that catch caching and data loading bugs, and the critical testing patterns that actually caught production issues — specifically the runtime environment variable gap that let tests pass while production failed.
The Testing Gap — Why Vitest Alone Is Not Enough
Vitest is a fast, Jest-compatible test runner that runs in Node.js. It uses jsdom to simulate a browser environment for component tests. It is excellent for testing isolated logic: utility functions, hooks, Zod schemas, and pure components with mocked props.
But Vitest does NOT run a real Next.js server. There is no Full Route Cache. No ISR regeneration. No Data Cache. No Client Router Cache. No server-side rendering of Server Components. When you mock fetch() in a Vitest test, you are testing your component against fake data, not the real data-loading pipeline.
This gap means: a Vitest test that mocks a successful API response will pass even if the real API returns a 500 error, a timeout, a different data shape, or an empty array. The test validates that the component renders with the expected data format — but it does not validate that the component loads data correctly in a real server context.
Vitest is necessary but not sufficient. Use it for unit tests and integration tests of isolated logic. But do not rely on it alone to validate that your Next.js application works correctly.
Vitest for isolated logic: hooks, schemas, utilities, pure components with mocked data
Playwright for integrated behavior: page rendering, data loading, caching, navigation, form submission
Component testing (Vitest + RTL) sits in the middle: validates UI logic but skips server rendering
Missing Playwright E2E tests is the most common testing gap — teams deploy without validating real behavior
Production Insight
The env gap incident was invisible to Vitest tests because they ran in isolation with mocked environment variables. It was invisible to Playwright E2E tests because they ran against the staging server with all env vars configured. The production environment was different — and neither test caught the difference.
Rule: Vitest tests validate logic. Playwright tests validate behavior. Neither validates environment configuration. Add a separate 'deployment smoke test' that verifies the production environment.
Key Takeaway
Vitest is for isolated logic — not for validating real Next.js behavior.
Vitest has no Next.js server, no cache, no real rendering.
Always pair Vitest with Playwright E2E tests against a real server.
thecodeforge.io
Nextjs Testing Vitest Playwright
Setting Up Vitest for Next.js 16 — Unit and Component Tests
Next.js 16 ships with @next/jest for Vitest compatibility. The setup involves configuring Vitest to use the Next.js environment and aliases, allowing you to import components and utilities using the @/ alias.
For unit tests (utilities, hooks, schemas): use the node environment. For component tests (React components with jsdom): use the jsdom environment. For Server Components: Vitest cannot fully test Server Components because they require a server runtime — you can only test their rendered output through integration tests with Playwright.
Key configuration: set up Vitest to resolve @/ imports, use jsdom for component tests, and mock global fetch() for tests that don't need real API calls.
Vitest excels at testing utility functions, Zod schemas, hooks (using renderHook), and pure presentation components. It struggles with Server Components, data-fetching components, and anything that depends on the Next.js runtime. Focus your Vitest efforts on the non-server parts of your application.
Production Insight
The most valuable Vitest tests in the checkout app were the Zod schema validation tests and the utility function tests. The schema tests caught malformed data before it reached production. The utility tests validated currency formatting, date parsing, and discount calculation. These tests were fast (<50ms each) and caught real bugs. The component tests, on the other hand, caught mostly style issues that would have been visible in a quick visual check.
Rule: spend your Vitest budget on utility and schema tests — they have the highest bug-per-test ratio.
Key Takeaway
Vitest is best for utilities, hooks, and Zod schemas.
Component testing with jsdom skips server rendering — use Playwright for page-level validation.
Focus unit test effort on logic that's hard to validate visually.
Playwright E2E Testing — The Tests That Actually Catch Production Bugs
Playwright E2E tests run against a real Next.js server — either the dev server (next dev) or the production build (next build && next start). They use a real browser (Chromium, Firefox, or WebKit) that executes actual JavaScript, renders actual HTML, and makes real HTTP requests.
This is the only testing layer that exercises your application's actual behavior: Full Route Cache, ISR regeneration, Data Cache, Client Router Cache, Server Action invocations, form submissions, navigation, and error handling.
Playwright tests are slower than Vitest tests — seconds per test instead of milliseconds. They are also more sensitive to timing (waiting for elements, navigation, data loading). But they catch the bugs that matter: the 'works in development, fails in production' bugs that Vitest cannot catch.
playwright-config.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
// ============================================// Playwright Configuration for Next.js 16// ============================================import { defineConfig, devices } from'@playwright/test'exportdefaultdefineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 4 : undefined,
reporter: 'html',
use: {
baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
webServer: {
command:
process.env.CI
? 'next start --port 3000' // Production build
: 'next dev --port 3000', // Dev server
port: 3000,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
})
// ---- E2E test: Validate checkout page ----// File: e2e/checkout.spec.tsimport { test, expect } from'@playwright/test'test('checkout page loads and submits an order', async ({
page,
}) => {
await page.goto('/products/test-product')
// Wait for the product to load (tests real data fetching)awaitexpect(page.locator('h1')).toBeVisible()
// Click buy buttonawait page.click('button:has-text("Buy Now")')
// Navigate to checkoutawait page.waitForURL('/checkout')
// Fill in checkout formawait page.fill('input[name="email"]', 'test@example.com')
await page.fill('input[name="card"]', '4242424242424242')
await page.fill('input[name="cvv"]', '123')
// Submit form — tests Server Actionawait page.click('button:has-text("Pay Now")')
// Wait for successawaitexpect(page.locator('text=Payment successful')).toBeVisible()
})
Run Playwright Against Production Build, Not Dev Server
next dev has different behavior than next start: no caching, automatic fast refresh, error overlay. Your production build has Full Route Cache, ISR, Data Cache, and minified code. Run Playwright against the production build (next build && next start) to catch production-only bugs.
Production Insight
The env gap incident was caught only after adding a Playwright test that ran against the production build with production-only environment variables. The test started the server and immediately checked that the homepage loaded without errors. The missing DATABASE_URL caused the checkout Server Action to fail — the test caught it because it actually submitted a checkout form.
Rule: every critical user flow (checkout, login, search) needs a Playwright E2E test that runs against the production build. These tests catch the runtime bugs that unit tests can't see.
Key Takeaway
Playwright tests run against a real Next.js server with real caching and rendering.
Always run Playwright against the production build, not the dev server.
Every critical user flow needs a Playwright E2E test.
CI vs Production: Testing Environment GapWhy tests pass in CI but fail in productionCI EnvironmentProduction EnvironmentEnvironment VariablesMocked or default valuesReal platform-specific valuesCaching BehaviorNo cache or in-memory stubDistributed cache with TTLServer ActionsMocked with static responsesReal execution with side effectsNetwork ConditionsLocalhost with low latencyReal network with variable latencyTest IsolationFresh state per test runShared state across requestsTHECODEFORGE.IO
thecodeforge.io
Nextjs Testing Vitest Playwright
Testing Server Actions — The Unit Challenge
Server Actions are server-side functions that accept FormData. Testing them in Vitest is possible but limited — you can call the function directly, pass FormData, and assert on the return value. But you cannot test the transport layer (how the form data is serialized and sent), the cache revalidation (revalidatePath/Tag), or the error page that Next.js renders when the action throws.
The pattern: test the Server Action's business logic in unit tests (validation, database calls, return values). Test the Server Action's integration (form submission, error handling, cache revalidation) in Playwright E2E tests.
For unit testing a Server Action: create the FormData, call the function directly, and assert on the state return value. Mock the database calls and revalidate functions. This validates that the action handles valid and invalid input correctly.
Server Actions have several failure modes: validation errors (client input), business logic errors (out of stock), system errors (database down), and cache revalidation errors (unlikely but possible). Test each path — the error state handling is often where bugs hide.
Production Insight
The checkout app had comprehensive success-path tests but minimal error-path tests. The database connection error handler returned a generic error message — but the test that validated this wasn't written. When the production DATABASE_URL was missing, the Server Action threw an uncaught error instead of returning a user-friendly message. A simple error-path test would have caught this.
Rule: every Server Action needs at least three tests — valid input (success), invalid input (validation error), and system failure (graceful error).
Key Takeaway
Unit-test Server Action business logic in Vitest with mocked dependencies.
Test integration (form submission, errors, revalidation) in Playwright E2E.
Every Server Action needs success, validation-error, and system-error tests.
Testing Caching Behavior — The Missing Test Layer
Caching is invisible in Vitest and behaves differently in dev vs production. A test that mocks fetch() and validates component rendering does not test: ISR regeneration timing, Data Cache staleness, Full Route Cache invalidation via revalidatePath, or Client Router Cache behavior on back-navigation.
To test caching behavior, you need Playwright E2E tests against the production build with explicit cache assertions. Check that the correct cache headers are returned. Verify that data updates appear after revalidation. Assert that the Client Router Cache serves stale data on back-navigation (and that router.refresh() clears it).
These tests are slow but they catch the most expensive class of bugs — stale data in production.
cache-testing.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
// ============================================// Testing Caching Behavior with Playwright// ============================================import { test, expect } from'@playwright/test'// ---- Test ISR regeneration ----test('product page regenerates after revalidation', async ({
page,
request,
}) => {
// Step 1: Visit the page to prime the cacheawait page.goto('/products/test-product')
const firstPrice = await page.locator('.price').textContent()
// Step 2: Trigger revalidation via APIconst response = await request.post('/api/revalidate', {
data: {
path: '/products/test-product',
secret: process.env.REVALIDATION_SECRET,
},
})
expect(response.ok()).toBeTruthy()
// Step 3: Visit the page again (should be fresh)await page.goto('/products/test-product', {
// Force fresh load — bypass Router Cache
waitUntil: 'networkidle',
})
const secondPrice = await page.locator('.price').textContent()
// The price should have changed (data was regenerated)expect(secondPrice).not.toBe(firstPrice)
})
// ---- Test Cache-Control headers ----test('product page has correct cache headers', async ({
request,
}) => {
const response = await request.get('/products/test-product')
const cacheControl = response.headers()['cache-control']
expect(cacheControl).toContain('s-maxage=60')
expect(cacheControl).toContain('stale-while-revalidate')
})
// ---- Test Router Cache behavior ----test('Router Cache serves stale on back navigation', async ({
page,
}) => {
// Step 1: Visit product pageawait page.goto('/products/test-product')
await page.locator('h1').waitFor()
// Step 2: Navigate awayawait page.goto('/')
// Step 3: Navigate back — should use Router Cacheawait page.goBack()
// The page should render instantly (from Router Cache)awaitexpect(page.locator('h1')).toBeVisible()
})
// ---- Test data staleness window ----test('stale data is served within revalidate window', async ({
page,
request,
}) => {
// Visit the pageawait page.goto('/products/test-product')
const firstRenderTime = await page.evaluate(
() => window.__RENDERED_AT
)
// Visit again immediately — should be cachedawait page.goto('/products/test-product', {
waitUntil: 'networkidle',
})
const secondRenderTime = await page.evaluate(
() => window.__RENDERED_AT
)
// Should be the same cached versionexpect(secondRenderTime).toBe(firstRenderTime)
})
Caching behavior is completely different in dev mode (next dev): no Full Route Cache, no ISR, no Data Cache persistence. Cache assertions will fail or give false positives. Always run caching tests against next build && next start.
Production Insight
A team had ISR with revalidate: 3600 on their blog page. All tests passed. But a Playwright test that checked cache headers on the production build revealed that the CDN was overriding the Cache-Control header with a 24-hour TTL — rendering the ISR revalidate essentially useless. The test caught the CDN misconfiguration that had been silently causing 24-hour stale blog posts.
Rule: cache behavior is the most under-tested area in Next.js applications. Dedicate at least 20% of your E2E tests to caching assertions.
Key Takeaway
Cache behavior is invisible in Vitest — only Playwright E2E tests catch cache bugs.
Cache tests must run against the production build (next build && next start).
Dedicate E2E tests to cache header assertions and revalidation verification.
Test Isolation — Why Shared State Causes Flaky Tests
Test isolation means each test starts with a clean state: no cached data, no logged-in session, no database records from previous tests. Shared state between tests causes flaky failures — test B passes when run alone but fails when run after test A because test A left the database in a different state.
In Vitest: use vi.clearAllMocks() in beforeEach. Create fresh FormData for each test. Mock database calls to return predictable data per test.
In Playwright: use test.describe.serial for sequential tests that share state, or test.describe.configure({ mode: 'parallel' }) for isolated tests. Clear cookies, localStorage, and sessionStorage between tests with page.context().clearCookies() and page.evaluate(() => localStorage.clear()). Use isolated database transactions or test-specific data fixtures.
test-isolation.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
// ============================================// Test Isolation Patterns// ============================================import { test, expect, typePage } from'@playwright/test'import { describe, it, vi, beforeEach } from'vitest'// ---- Vitest: Reset mocks between tests ----describe('Server Action tests', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('test 1 — independent', () => {
// ...
})
it('test 2 — also independent', () => {
// clearAllMocks() ensures mock state is fresh
})
})
// ---- Playwright: Clear browser state ----
test.describe('isolated tests', () => {
// Use a beforeEach to clear state
test.beforeEach(async ({ context }) => {
await context.clearCookies()
await context.addInitScript(() => {
localStorage.clear()
sessionStorage.clear()
})
})
test('user can login', async ({ page }) => {
await page.goto('/login')
// ...
})
test('user can logout', async ({ page }) => {
// Fresh state — no login from previous testawait page.goto('/dashboard')
await expect(page).toHaveURL('/login') // Redirected because not authenticated
})
})
// ---- Playwright: Isolated database per test ----
test.describe('database-dependent tests', () => {
// Create test data per testtest('creates a product', async ({ page }) => {
await page.goto('/admin/products/new')
// ... create a product
})
test('lists products', async ({ page }) => {
// This test should NOT depend on the product created above// Use database seeding or fixtures for consistent data
})
})
Playwright runs tests in parallel by default. If your tests share database state (e.g., both create a user with the same email), they will conflict. Use isolated database schemas/test-specific data, or run tests sequentially with test.describe.serial.
Production Insight
A team had Playwright tests that passed 95% of the time and failed 5% randomly. The issue: test A created a product, and test B expected an empty product list. When run in parallel, test B sometimes ran before test A (passed) and sometimes after (failed because the product existed). Fixing isolation resolved the flakiness.
Rule: if a Playwright test fails intermittently, assume it's an isolation problem before assuming it's a flaky selector.
Key Takeaway
Test isolation means no shared state between tests.
Clear mocks in Vitest beforeEach. Clear cookies/storage in Playwright beforeEach.
Flaky tests are almost always isolation problems — not timing issues.
A complete Next.js 16 testing strategy has three layers: unit/integration tests (Vitest), E2E tests (Playwright), and environment configuration verification (a test that validates the production environment).
Vitest layer: Zod schema validation, utility functions, hook logic, pure component rendering. Fast (<1s per file), high coverage of isolated logic.
Playwright layer: Critical user flows (checkout, login, search), page rendering (data loading, caching, hydration), form submissions (Server Actions, validation, errors), navigation (Router Cache, back button, deep links). Slow (30-60s per test), low coverage of individual components but high confidence in user-facing behavior.
Environment verification layer: server startup with production-only env vars, health check endpoint response, database connectivity, external API connectivity, and cache behavior assertions.
This three-layer strategy catches: logic bugs (Vitest), user-facing bugs (Playwright), and configuration bugs (environment verification).
env-smoke-test.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
// ============================================// Environment Verification Test// ============================================import { describe, it, expect } from'vitest'// ---- Test that validates production environment ----// Run this in CI with production env vars onlydescribe('production environment verification', () => {
it('all required environment variables are set', () => {
const requiredVars = [
'DATABASE_URL',
'OPENAI_KEY',
'AUTH_SECRET',
'NEXT_PUBLIC_API_URL',
]
const missing = requiredVars.filter(
(v) => !process.env[v]
)
expect(missing).toEqual([])
})
it('NEXT_PUBLIC_ vars do not contain secrets', () => {
const publicVars = Object.keys(process.env)
.filter((k) => k.startsWith('NEXT_PUBLIC_'))
.filter(
(k) =>
/key|secret|token|password|auth/i.test(k)
)
expect(publicVars).toEqual([])
})
it('server can start with current environment', async () => {
// This test starts the Next.js server and checks it responds// Implemented in CI as a separate stepexpect(true).toBe(true)
})
})
// ---- CI script: start-and-check.sh ----// #!/bin/bash// # Start the production server// npx next start --port 3000 &// SERVER_PID=$!// sleep 5//// # Check health endpoint// curl -f http://localhost:3000/api/health || {// echo "Server failed to start with production environment"// kill $SERVER_PID// exit 1// }//// # Check cache headers// curl -I http://localhost:3000/products/test | grep -q 's-maxage' || {// echo "Cache headers missing"// kill $SERVER_PID// exit 1// }//// kill $SERVER_PID
Layer 1 (Vitest): Fast, isolated, high coverage — catches logic bugs early
Layer 2 (Playwright): Slow, real browser, low coverage — catches behavior bugs before users
Layer 3 (Env verification): Pre-deploy check — catches configuration gaps between staging and production
Production Insight
After the env gap incident, the team added a three-layer testing strategy. The env verification test (layer 3) caught the missing DATABASE_URL before the next deployment. The Playwright test (layer 2) caught a Server Action error that only occurred in production (different API response shape). The Vitest tests (layer 1) continued catching schema and utility bugs. In 6 months, zero production incidents related to environment configuration.
Rule: three testing layers, three different bug types. No single layer catches everything. All three are required.
Key Takeaway
Three-layer testing: Vitest for logic, Playwright for behavior, env verification for configuration.
Environment verification tests prevent the 'works in staging, fails in production' gap.
All three layers are required — no single layer catches everything.
● Production incidentPOST-MORTEMseverity: high
Tests Passed in CI but Failed in Production — The Runtime Env Gap
Symptom
After a routine deployment, the checkout page showed '500: Internal Server Error' for all users. The error was logged as 'DATABASE_URL is undefined' in the Server Action. The staging environment worked fine. The production environment didn't. All CI tests — 247 Vitest tests, 38 Playwright tests — had passed.
Assumption
The team assumed the CI tests validated the full application. Since Vitest tests passed with mocked data and Playwright tests rendered the checkout page successfully against the staging server, the deployment was approved. The production failure was assumed to be an infrastructure issue, not a code issue.
Root cause
The CI Playwright tests ran against a staging server that had all environment variables configured. The production environment was missing the DATABASE_URL variable because a platform migration (Vercel to self-hosted) didn't transfer all environment variables. The Server Action accessed process.env.DATABASE_URL, which was undefined in production. The team had no runtime environment variable validation (no Zod schema at startup), so the server started successfully despite missing configuration — it only crashed when a user hit the checkout page. All tests passed against the staging environment where the variable was present.
Fix
1) Added a lib/env.ts with Zod runtime validation — the server now crashes on startup with a clear error message if any required environment variable is missing. 2) Changed Playwright E2E tests to run against the production-like environment (same env file as production). 3) Added a CI test that verifies the application can start with ONLY the environment variables that are defined in the production environment — preventing the 'works in staging, fails in prod' gap. 4) Added a pre-deploy check that compares environment variables between staging and production. 5) Implemented graceful fallback: if DATABASE_URL is missing, the Server Action returns a user-friendly error instead of throwing.
Key lesson
Tests that run against a staging environment with complete env vars don't validate production readiness
Runtime environment variable validation (Zod at startup) catches missing config before users are affected
E2E tests must run against the exact same environment configuration as production — not a superset
Add a CI test that starts the application with production-only env vars to catch the 'env gap' before deploy
Production debug guideDiagnose test gaps and production-only failures5 entries
Symptom · 01
All tests pass but production crashes on startup
→
Fix
Check if the production environment has all required environment variables. Add Zod runtime validation that crashes on missing env vars at startup — not on first request. Ensure CI tests run against production-like environment configuration.
Symptom · 02
Component tests pass but page renders incorrectly in production
→
Fix
Component tests with mocked data don't validate server-side data loading. Add Playwright E2E tests that render the actual page against the real server, including data fetching and caching behavior.
Symptom · 03
E2E tests pass in CI but fail in production
→
Fix
Check if the CI Playwright server has different configuration than production. Common differences: environment variables, database seeding, caching behavior (CI has no cache, production has aggressive caching).
Symptom · 04
Vitest mock returns data but real fetch fails
→
Fix
Your test mocks success responses but the real API may return errors, timeouts, or unexpected shapes. Add Playwright tests with the real API (or a realistic test API) to validate error handling and loading states.
Symptom · 05
Hydration errors only in production builds
→
Fix
Production uses minified code with different module resolution. Run Playwright tests against the production build (next build && next start), not the dev server, to catch hydration mismatches.
★ Testing Quick Debug ReferenceFast commands for setting up and debugging Next.js 16 tests
Playwright E2E tests against the production build exercise real caching, rendering, and data loading
3
Server Actions need both unit tests (business logic with mocked DB) and E2E tests (form submission flow)
4
Cache behavior is the most under-tested area
dedicate E2E tests to cache header and revalidation assertions
5
Test isolation prevents flaky tests
clear mocks in Vitest, clear browser state in Playwright
6
Environment verification tests prevent the 'works in staging, fails in production' configuration gap
INTERVIEW PREP · PRACTICE MODE
Interview Questions on This Topic
Q01SENIOR
Design a testing strategy for a Next.js 16 e-commerce application. What ...
Q02SENIOR
Why can't Vitest with mocked fetch() replace Playwright E2E tests?
Q03SENIOR
How would you write a test that validates Next.js ISR behavior for a pro...
Q01 of 03SENIOR
Design a testing strategy for a Next.js 16 e-commerce application. What tools would you use and what would each layer cover?
ANSWER
Three-layer testing strategy. Layer 1: Vitest for unit and integration tests. Cover Zod schemas (validation of input data), utility functions (currency formatting, date parsing, discount calculation), hook logic (useActionState with mocked Server Actions), and pure presentation components with mocked props. Fast, high coverage, catches logic bugs early. Layer 2: Playwright for E2E tests against the production build. Cover critical user flows (checkout, login, search, product listing), page rendering (Server Component output, hydration), form submissions with Server Actions (including error states), navigation and Router Cache behavior, and cache assertion (Cache-Control headers, ISR revalidation). Layer 3: Environment verification test that runs in CI with production-only environment variables. Verifies all required env vars are present, NEXT_PUBLIC_ doesn't contain secrets, server starts successfully, and health endpoint responds. The key insight: each layer catches different bug types — none is sufficient alone. Most teams stop at layer 1 and deploy with caching and environment bugs.
Q02 of 03SENIOR
Why can't Vitest with mocked fetch() replace Playwright E2E tests?
ANSWER
Vitest with mocked fetch() tests isolated component logic against fake data in a simulated browser (jsdom). It does not test: real data fetching with network conditions (timeouts, errors, slow responses), server-side rendering (Server Components execute in Node.js, not jsdom), caching behavior (Full Route Cache, Data Cache, ISR, Client Router Cache are not present in Vitest), environment configuration (Vitest tests against the CI environment, not the production environment), and real browser behavior (hydration, event handling, browser API compatibility). Playwright fills these gaps by running tests against a real Next.js server with real browser execution. The two are complementary, not interchangeable.
Q03 of 03SENIOR
How would you write a test that validates Next.js ISR behavior for a product page?
ANSWER
I would use Playwright against the production build. The test would: 1) Visit the product page to prime the ISR cache and record the rendered content (e.g., the price). 2) Trigger on-demand revalidation via the /api/revalidate endpoint (POST with the product path and secret). 3) Verify the API returns { revalidated: true }. 4) Visit the product page again with cache bypass headers or after waiting for the regeneration to complete. 5) Assert that the rendered content has changed (the price updated) or that a cache header like x-nextjs-cache is STALE or MISS instead of HIT. 6) Additionally, assert that the Cache-Control response header includes the expected s-maxage value. The test validates that the ISR revalidation pipeline works end-to-end — from webhook trigger to fresh page render.
01
Design a testing strategy for a Next.js 16 e-commerce application. What tools would you use and what would each layer cover?
SENIOR
02
Why can't Vitest with mocked fetch() replace Playwright E2E tests?
SENIOR
03
How would you write a test that validates Next.js ISR behavior for a product page?
SENIOR
FAQ · 5 QUESTIONS
Frequently Asked Questions
01
Why do my Vitest tests pass but the app fails in production?
Vitest runs in Node.js with jsdom — there is no real Next.js server, no caching, no real API calls. If your test mocks fetch() to return success, it won't catch real API failures, incorrect data shapes, or caching behavior. Additionally, if your tests run with all environment variables configured but production is missing some, Vitest won't catch the gap. Always supplement Vitest with Playwright E2E tests against the production build, and add environment verification tests.
Was this helpful?
02
Should I run Playwright tests against the dev server or production build?
Production build (next build && next start). The dev server (next dev) has no caching, different error handling, and hot module replacement — all of which mask production bugs. Playwright tests against the production build exercise the real Full Route Cache, ISR, Data Cache, and minified code that users experience. The trade-off is slower startup time (build first, then start), but the reliability gain is worth the wait.
Was this helpful?
03
How do I test Server Actions without calling the real database?
Use Vitest unit tests for Server Action business logic. Mock the database calls and revalidate functions with vi.mock(). Create FormData objects, call the Server Action function directly, and assert on the return value. For integration testing (form submission flow, error handling UI), use Playwright E2E tests against a test database or a mocked API layer.
Was this helpful?
04
What is test isolation and why does it matter for Next.js?
Test isolation means each test starts with a clean state — no shared cache, no shared database records, no shared session data. Without isolation, tests become flaky: test B passes when run alone but fails when run after test A because test A left the database or cache in a different state. For Vitest, clear mocks in beforeEach. For Playwright, clear cookies/localStorage between tests and use isolated database fixtures.
Was this helpful?
05
How do I test caching behavior in Playwright?
Run Playwright against the production build (next build && next start). Test cache header assertions (check Cache-Control includes s-maxage and stale-while-revalidate). Test revalidation flows: visit a page, trigger revalidatePath/Tag, visit again and verify the content changed. Test Router Cache behavior: navigate to a page, navigate away, go back, and assert the page renders instantly (from cache). Note that these tests are timing-dependent — use waitForSelector and toBeVisible instead of fixed wait times.