Home JavaScript Next.js 16 Tests Passed in CI but Failed in Production — The Runtime Env Gap
Intermediate 4 min · July 12, 2026
Testing Next.js Applications: Unit, Integration, and E2E Testing

Next.js 16 Tests Passed in CI but Failed in Production — The Runtime Env Gap

Vitest tests passed with mock fetch() but production caching behavior caused real API failures.

N
Naren Founder & Principal Engineer

20+ years shipping production JavaScript and front-end systems at scale. Drawn from code that ran under real load.

Follow
Production
production tested
July 12, 2026
last updated
1,787
articles · all by Naren
Before you start⏱ 15 min
  • React
  • Node.js 18+
  • Next.js basics
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • 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
ChromeFirefoxSafariEdge

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 = Ingredient Testing, Playwright = Dish Testing
  • 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.
nextjs-testing-vitest-playwright THECODEFORGE.IO Next.js 16 Testing Architecture Layers From unit to production runtime Unit Tests (Vitest) Component tests | Server action mocks | Cache stubs E2E Tests (Playwright) Page navigation | API calls | Client-side rendering CI Pipeline Vitest suite | Playwright suite | Lint & build Staging Environment Production-like env vars | Real caching layer | Server actions live Production Runtime Env vars from platform | Edge caching | Server actions deployed THECODEFORGE.IO
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.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
// ============================================
// Vitest Configuration for Next.js 16
// ============================================

import { defineConfig } from 'vitest/config'
import path from 'path'

// ---- Vitest config with Next.js aliases ----

export default defineConfig({
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: ['./vitest.setup.ts'],
    include: ['**/*.test.{ts,tsx}'],
    // Run component tests with jsdom
    pool: 'forks',
    poolOptions: {
      forks: {
        singleFork: true,
      },
    },
  },
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './'),
      '@/lib': path.resolve(__dirname, './lib'),
      '@/app': path.resolve(__dirname, './app'),
      '@/components': path.resolve(__dirname, './components'),
    },
  },
})

// ---- vitest.setup.ts ----

import '@testing-library/jest-dom'
import { vi } from 'vitest'

// Mock global fetch for all tests
vi.stubGlobal('fetch', vi.fn())

// Mock IntersectionObserver (not available in jsdom)
vi.stubGlobal(
  'IntersectionObserver',
  vi.fn(() => ({
    observe: vi.fn(),
    unobserve: vi.fn(),
    disconnect: vi.fn(),
  }))
)
Try it live
Test Utilities, Not Components
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'

export default defineConfig({
  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.ts
import { 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)
  await expect(page.locator('h1')).toBeVisible()

  // Click buy button
  await page.click('button:has-text("Buy Now")')

  // Navigate to checkout
  await page.waitForURL('/checkout')

  // Fill in checkout form
  await 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 Action
  await page.click('button:has-text("Pay Now")')

  // Wait for success
  await expect(page.locator('text=Payment successful')).toBeVisible()
})
Try it live
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 Gap Why tests pass in CI but fail in production CI Environment Production Environment Environment Variables Mocked or default values Real platform-specific values Caching Behavior No cache or in-memory stub Distributed cache with TTL Server Actions Mocked with static responses Real execution with side effects Network Conditions Localhost with low latency Real network with variable latency Test Isolation Fresh state per test run Shared state across requests THECODEFORGE.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-action-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
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
92
93
94
95
// ============================================
// Testing Server Actions
// ============================================

import { describe, it, expect, vi, beforeEach } from 'vitest'
import { checkout } from '@/app/actions/checkout'

// ---- Mock revalidateTag ----

vi.mock('next/cache', () => ({
  revalidateTag: vi.fn(),
  revalidatePath: vi.fn(),
}))

// ---- Mock database ----

vi.mock('@/lib/db', () => ({
  db: {
    query: vi.fn(),
  },
}))

import { revalidateTag } from 'next/cache'
import { db } from '@/lib/db'

describe('checkout Server Action', () => {
  const mockFormData = (overrides = {}) => {
    const data = new FormData()
    data.set('email', 'test@example.com')
    data.set('card', '4242424242424242')
    data.set('cvv', '123')
    data.set('idempotencyKey', 'test-key-123')
    data.set('productId', 'prod-1')
    data.set('quantity', '1')
    for (const [key, value] of Object.entries(overrides)) {
      data.set(key, value as string)
    }
    return data
  }

  beforeEach(() => {
    vi.clearAllMocks()
  })

  it('should succeed with valid data', async () => {
    vi.mocked(db.query).mockResolvedValueOnce({ id: 'order-1' })

    const result = await checkout(
      { success: false },
      mockFormData()
    )

    expect(result.success).toBe(true)
    expect(result.message).toBeDefined()
    expect(revalidateTag).toHaveBeenCalledWith('orders')
  })

  it('should return validation errors for invalid email', async () => {
    const result = await checkout(
      { success: false },
      mockFormData({ email: 'not-an-email' })
    )

    expect(result.success).toBe(false)
    expect(result.errors?.email).toBeDefined()
  })

  it('should return error for out-of-stock product', async () => {
    vi.mocked(db.query).mockResolvedValueOnce(null)

    const result = await checkout(
      { success: false },
      mockFormData()
    )

    expect(result.success).toBe(false)
    expect(result.errors?.form).toContain('no longer available')
  })

  it('should handle database errors gracefully', async () => {
    vi.mocked(db.query).mockRejectedValueOnce(
      new Error('Connection refused')
    )

    const result = await checkout(
      { success: false },
      mockFormData()
    )

    expect(result.success).toBe(false)
    // Should not expose system error details
    expect(result.errors?.form).not.toContain('Connection refused')
    expect(result.errors?.form).toContain('try again')
  })
})
Try it live
Test Both Success and Error Paths
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 cache
  await page.goto('/products/test-product')
  const firstPrice = await page.locator('.price').textContent()

  // Step 2: Trigger revalidation via API
  const 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 page
  await page.goto('/products/test-product')
  await page.locator('h1').waitFor()

  // Step 2: Navigate away
  await page.goto('/')

  // Step 3: Navigate back — should use Router Cache
  await page.goBack()

  // The page should render instantly (from Router Cache)
  await expect(page.locator('h1')).toBeVisible()
})

// ---- Test data staleness window ----

test('stale data is served within revalidate window', async ({
  page,
  request,
}) => {
  // Visit the page
  await page.goto('/products/test-product')
  const firstRenderTime = await page.evaluate(
    () => window.__RENDERED_AT
  )

  // Visit again immediately — should be cached
  await page.goto('/products/test-product', {
    waitUntil: 'networkidle',
  })

  const secondRenderTime = await page.evaluate(
    () => window.__RENDERED_AT
  )

  // Should be the same cached version
  expect(secondRenderTime).toBe(firstRenderTime)
})
Try it live
Cache Tests Must Run Against Production Build
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, type Page } 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 test
    await 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 test
  test('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
  })
})
Try it live
Parallel Tests Require Full Isolation
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.

The Complete Testing Strategy — Vitest + Playwright + Environment Verification

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 only

describe('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 step
    expect(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
Try it live
Three Testing Layers, Three Different Bug Types
  • 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
Tests pass but production crashes
Immediate action
Check if env vars are validated at startup
Commands
grep -rn 'envSchema\|safeParse\|z.object' lib/env.ts 2>/dev/null || echo 'No env validation found'
grep -rn "process.exit" lib/env.ts 2>/dev/null || echo 'No startup crash on missing env'
Fix now
Add Zod validation at lib/env.ts that calls process.exit(1) if required env vars are missing
Component test passes but page fails in production+
Immediate action
Check if you test with real data loading, not just mocked data
Commands
grep -rn 'mock' app/__tests__/ --include='*.ts' --include='*.tsx' | head -10
grep -rn "vi.mock\|jest.mock" app/ --include='*.ts' --include='*.tsx'
Fix now
Add Playwright E2E tests that render against a real server with real data
Playwright tests flaky in CI+
Immediate action
Check for timing-dependent selectors and missing wait strategies
Commands
grep -rn 'waitForTimeout\|page.waitFor(5000)' e2e/ --include='*.spec.ts'
grep -rn "expect.*toBeVisible\|waitForSelector" e2e/ --include='*.spec.ts'
Fix now
Replace waitForTimeout with waitForSelector or toBeVisible — wait for elements, not time
E2E tests use dev server but production build behaves differently+
Immediate action
Run Playwright against the production build
Commands
npx next build && npx next start &
npx playwright test --config playwright.prod.config.ts
Fix now
Add a CI step that builds the app and runs Playwright against the production server
⚙ Quick Reference
6 commands from this guide
FileCommand / CodePurpose
vitest.config.tsexport default defineConfig({Setting Up Vitest for Next.js 16
playwright-config.tsexport default defineConfig({Playwright E2E Testing
server-action-test.tsvi.mock('next/cache', () => ({Testing Server Actions
cache-testing.tstest('product page regenerates after revalidation', async ({Testing Caching Behavior
test-isolation.tsdescribe('Server Action tests', () => {Test Isolation
env-smoke-test.tsdescribe('production environment verification', () => {The Complete Testing Strategy

Key takeaways

1
Vitest tests isolated logic with mocked data
it does not validate real Next.js behavior
2
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.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
Why do my Vitest tests pass but the app fails in production?
02
Should I run Playwright tests against the dev server or production build?
03
How do I test Server Actions without calling the real database?
04
What is test isolation and why does it matter for Next.js?
05
How do I test caching behavior in Playwright?
N
Naren Founder & Principal Engineer

20+ years shipping production JavaScript and front-end systems at scale. Drawn from code that ran under real load.

Follow
Verified
production tested
July 12, 2026
last updated
1,787
articles · all by Naren
🔥

That's Next.js. Mark it forged?

4 min read · try the examples if you haven't

Previous
Environment Variables and Configuration in Next.js 16
39 / 56 · Next.js
Next
Internationalization (i18n) in Next.js 16