Dev Logs
/Next.js/ Chapter 23: Testing
Chapters
  • 01Chapter 1: Introduction & Setup
  • 02Chapter 2: Project Structure & Configuration
  • 03Chapter 3: Layouts & Pages
  • 04Chapter 4: Linking & Navigation
  • 05Chapter 5: Dynamic Routes & Params
  • 06Chapter 6: Route Groups & Organization
  • 07Chapter 7: Parallel & Intercepting Routes
  • 08Chapter 8: Loading, Suspense & Streaming
  • 09Chapter 9: Error Handling
  • 10Chapter 10: Server & Client Components
  • 11Chapter 11: Data Fetching
  • 12Chapter 12: Server Actions & Mutations
  • 13Chapter 13: Route Handlers
  • 14Chapter 14: Caching & use cache
  • 15Chapter 15: Revalidation & ISR
  • 16Chapter 16: Cache Components & Partial Prerendering
  • 17Chapter 17: Proxy (formerly Middleware)
  • 18Chapter 18: Authentication & Authorization
  • 19Chapter 19: Metadata, SEO & OG Images
  • 20Chapter 20: Images & Fonts
  • 21Chapter 21: Styling
  • 22Chapter 22: Performance Optimization
  • 23Chapter 23: Testing
    • Plain English Explanation
    • Vitest setup
    • Jest, if you must
    • Testing Client Components
    • Mocking navigation hooks
    • Testing Server Components
    • What doesn't work
    • What does work: test the data layer
    • Testing Server Actions
    • Testing Route Handlers
    • Testing Proxy
    • End-to-end with Playwright
    • Testing forms without JavaScript
    • Reusing authentication
    • What to test, and how much
    • Common Pitfalls
    • . Trying to render async Server Components
    • . E2E against next dev
    • . Mocking so much the test proves nothing
    • . Testing implementation details
    • . getByTestId everywhere
    • . Not testing the security cases
    • . Flaky E2E from fixed waits
    • . Chasing 100% coverage
    • When & Why to Use
    • Mini Practice Problems
    • Problem 1: What's wrong?
    • Problem 2: Write the security tests
    • Problem 3: Pick the layer
    • Problem 4: Build the suite
    • Interview Notes
    • Common Questions
    • Asked at Companies
    • Visual Memory Aid
    • Key Takeaways
  • 24Chapter 24: Deployment & Self-Hosting
  • 25Chapter 25: Upgrading to Next.js 16
  • 26Chapter 26: Capstone Project
  • 27Chapter 27: React Performance Profiling
All chapters

🧪 Chapter 23: Testing

What you can unit test in the App Router, what you genuinely can't, and where E2E earns its keep.

📖 Plain English Explanation

Testing a Next.js App Router app has an awkward truth at its centre:

Async Server Components are not fully supported by React's testing libraries.

You can't reliably render(<Page />) when Page is an async function that awaits a database. React Testing Library was designed for client components that render synchronously and then update. An async server component is a different thing.

That constraint shapes the whole strategy:

Server Components  →  test the DATA FUNCTIONS they call (unit)
                      test the RENDERED PAGE (E2E)

Client Components  →  test normally with Testing Library

Server Actions     →  call them as plain functions (integration)

Route Handlers     →  call the exported GET/POST directly (integration)

Whole flows        →  Playwright

The good news: this pushes you toward a healthier test suite. Most bugs in a Next.js app aren't "did this div render" — they're "does this query return the right rows" and "can a logged-out user reach this page". Both are testable, and neither needs a component renderer.

🏃 Vitest setup

Vitest is faster than Jest and needs less configuration. It's the default recommendation for new projects.

bash
npm install -D vitest @vitejs/plugin-react jsdom \
  @testing-library/react @testing-library/dom @testing-library/user-event \
  @testing-library/jest-dom
ts
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import path from 'node:path'

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: ['./vitest.setup.ts'],
  },
  resolve: {
    alias: { '@': path.resolve(__dirname, './') },
  },
})
ts
// vitest.setup.ts
import '@testing-library/jest-dom/vitest'
import { cleanup } from '@testing-library/react'
import { afterEach, vi } from 'vitest'

afterEach(() => {
  cleanup()
  vi.clearAllMocks()
})
json
// package.json
{
  "scripts": {
    "test": "vitest",
    "test:run": "vitest run",
    "test:coverage": "vitest run --coverage"
  }
}

Jest, if you must

Jest still works and has a Next.js preset:

js
// jest.config.js
const nextJest = require('next/jest')

const createJestConfig = nextJest({ dir: './' })

module.exports = createJestConfig({
  testEnvironment: 'jest-environment-jsdom',
  setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
  moduleNameMapper: { '^@/(.*)$': '<rootDir>/$1' },
})

Use Jest when you're already on it. Vitest is a better default for new work — faster startup, native ESM, and the same API.

🧩 Testing Client Components

The straightforward case. Nothing Next.js-specific.

tsx
// components/Counter.tsx
'use client'
import { useState } from 'react'

export function Counter({ initial = 0 }: { initial?: number }) {
  const [count, setCount] = useState(initial)
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount((c) => c + 1)}>Increment</button>
      <button onClick={() => setCount(initial)}>Reset</button>
    </div>
  )
}
tsx
// components/Counter.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it } from 'vitest'
import { Counter } from './Counter'

describe('Counter', () => {
  it('starts at the initial value', () => {
    render(<Counter initial={5} />)
    expect(screen.getByText('Count: 5')).toBeInTheDocument()
  })

  it('increments on click', async () => {
    const user = userEvent.setup()
    render(<Counter />)

    await user.click(screen.getByRole('button', { name: 'Increment' }))
    await user.click(screen.getByRole('button', { name: 'Increment' }))

    expect(screen.getByText('Count: 2')).toBeInTheDocument()
  })

  it('resets to the initial value', async () => {
    const user = userEvent.setup()
    render(<Counter initial={10} />)

    await user.click(screen.getByRole('button', { name: 'Increment' }))
    await user.click(screen.getByRole('button', { name: 'Reset' }))

    expect(screen.getByText('Count: 10')).toBeInTheDocument()
  })
})

Query by role, not by test ID. getByRole('button', { name: 'Increment' }) fails if the button loses its accessible name — which is a real bug. getByTestId passes regardless.

Mocking navigation hooks

Components using useRouter, usePathname, or useSearchParams need those mocked:

tsx
// components/NavLink.test.tsx
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { NavLink } from './NavLink'

const mockPush = vi.fn()

vi.mock('next/navigation', () => ({
  usePathname: () => '/dashboard',
  useRouter: () => ({ push: mockPush, replace: vi.fn(), refresh: vi.fn() }),
  useSearchParams: () => new URLSearchParams('tab=overview'),
}))

describe('NavLink', () => {
  it('marks the current route as active', () => {
    render(<NavLink href="/dashboard">Dashboard</NavLink>)
    expect(screen.getByRole('link')).toHaveAttribute('aria-current', 'page')
  })

  it('does not mark other routes as active', () => {
    render(<NavLink href="/settings">Settings</NavLink>)
    expect(screen.getByRole('link')).not.toHaveAttribute('aria-current')
  })
})
jsx
// components/NavLink.test.js
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { NavLink } from './NavLink'

vi.mock('next/navigation', () => ({
  usePathname: () => '/dashboard',
  useRouter: () => ({ push: vi.fn(), replace: vi.fn(), refresh: vi.fn() }),
  useSearchParams: () => new URLSearchParams(),
}))

describe('NavLink', () => {
  it('marks the current route as active', () => {
    render(<NavLink href="/dashboard">Dashboard</NavLink>)
    expect(screen.getByRole('link')).toHaveAttribute('aria-current', 'page')
  })
})

🖥️ Testing Server Components

What doesn't work

tsx
// ❌ don't do this
import { render } from '@testing-library/react'
import Page from './page'

it('renders posts', async () => {
  render(await Page())      // fragile, unsupported, will break
})

Async Server Components aren't officially supported by React Testing Library. Some people make it work with await Page(); it's brittle and breaks on framework internals.

What does work: test the data layer

Extract the logic. Test that.

ts
// lib/posts.ts
import { cache } from 'react'
import { db } from '@/lib/db'

export const getPublishedPosts = cache(async (limit = 10) => {
  return db.post.findMany({
    where: { published: true },
    orderBy: { createdAt: 'desc' },
    take: limit,
  })
})

export function formatPostSummary(post: Post) {
  return {
    ...post,
    excerpt: post.body.slice(0, 160).trimEnd() + '…',
    readingMinutes: Math.max(1, Math.ceil(post.body.split(/\s+/).length / 200)),
  }
}
ts
// lib/posts.test.ts
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { getPublishedPosts, formatPostSummary } from './posts'
import { db } from '@/lib/db'

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

describe('getPublishedPosts', () => {
  beforeEach(() => vi.clearAllMocks())

  it('only requests published posts', async () => {
    vi.mocked(db.post.findMany).mockResolvedValue([])
    await getPublishedPosts()

    expect(db.post.findMany).toHaveBeenCalledWith(
      expect.objectContaining({ where: { published: true } })
    )
  })

  it('respects the limit', async () => {
    vi.mocked(db.post.findMany).mockResolvedValue([])
    await getPublishedPosts(5)

    expect(db.post.findMany).toHaveBeenCalledWith(
      expect.objectContaining({ take: 5 })
    )
  })
})

describe('formatPostSummary', () => {
  it('truncates the excerpt to 160 characters', () => {
    const post = { body: 'a'.repeat(500) } as Post
    expect(formatPostSummary(post).excerpt).toHaveLength(161)  // 160 + ellipsis
  })

  it('estimates reading time at 200 words per minute', () => {
    const post = { body: Array(400).fill('word').join(' ') } as Post
    expect(formatPostSummary(post).readingMinutes).toBe(2)
  })

  it('never reports less than one minute', () => {
    const post = { body: 'short' } as Post
    expect(formatPostSummary(post).readingMinutes).toBe(1)
  })
})

This is a better test than rendering the component would be. It checks the actual logic — the query shape, the truncation, the rounding — instead of asserting that a <li> exists.

The rule: keep Server Components thin. They should fetch and compose. All the logic worth testing belongs in functions you can call directly.

⚙️ Testing Server Actions

Server Actions are just async functions. Import and call them.

ts
// app/actions/posts.ts
'use server'
import { z } from 'zod'
import { db } from '@/lib/db'
import { verifySession } from '@/lib/dal'
import { revalidatePath } from 'next/cache'

const schema = z.object({
  title: z.string().min(3, 'Title must be at least 3 characters'),
  body: z.string().min(10, 'Body is too short'),
})

export async function createPost(prev: State, formData: FormData): Promise<State> {
  const session = await verifySession()
  if (!session) return { message: 'Not signed in' }

  const parsed = schema.safeParse({
    title: formData.get('title'),
    body: formData.get('body'),
  })
  if (!parsed.success) {
    return { errors: parsed.error.flatten().fieldErrors }
  }

  await db.post.create({
    data: { ...parsed.data, authorId: session.userId },
  })
  revalidatePath('/posts')
  return { success: true }
}
ts
// app/actions/posts.test.ts
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { createPost } from './posts'
import { db } from '@/lib/db'
import { verifySession } from '@/lib/dal'

vi.mock('@/lib/db', () => ({ db: { post: { create: vi.fn() } } }))
vi.mock('@/lib/dal', () => ({ verifySession: vi.fn() }))
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))

function form(fields: Record<string, string>) {
  const fd = new FormData()
  Object.entries(fields).forEach(([k, v]) => fd.set(k, v))
  return fd
}

describe('createPost', () => {
  beforeEach(() => vi.clearAllMocks())

  it('rejects unauthenticated requests', async () => {
    vi.mocked(verifySession).mockResolvedValue(null)

    const result = await createPost({}, form({ title: 'Hi', body: 'x'.repeat(20) }))

    expect(result.message).toBe('Not signed in')
    expect(db.post.create).not.toHaveBeenCalled()   // ← the important assertion
  })

  it('returns field errors for invalid input', async () => {
    vi.mocked(verifySession).mockResolvedValue({ userId: 'u1', role: 'user' })

    const result = await createPost({}, form({ title: 'ab', body: 'short' }))

    expect(result.errors?.title?.[0]).toMatch(/at least 3/)
    expect(result.errors?.body).toBeDefined()
    expect(db.post.create).not.toHaveBeenCalled()
  })

  it('always uses the session user as the author', async () => {
    vi.mocked(verifySession).mockResolvedValue({ userId: 'u1', role: 'user' })

    await createPost({}, form({ title: 'Valid title', body: 'x'.repeat(20) }))

    expect(db.post.create).toHaveBeenCalledWith({
      data: expect.objectContaining({ authorId: 'u1' }),
    })
  })
})

That third test is the one worth writing. It proves the author can't be spoofed from the form — a real vulnerability class, verified automatically.

Test the security properties. Unauthenticated rejection, ownership checks, and input validation are exactly what unit tests are good at, and exactly what breaks silently in production.

🔌 Testing Route Handlers

Import the exported method and pass a Request.

ts
// app/api/posts/route.ts
import { db } from '@/lib/db'
import { verifySession } from '@/lib/dal'

export async function GET(request: Request) {
  const session = await verifySession()
  if (!session) {
    return Response.json({ error: 'Unauthorized' }, { status: 401 })
  }

  const { searchParams } = new URL(request.url)
  const limit = Math.min(Number(searchParams.get('limit') ?? 10), 100)

  const posts = await db.post.findMany({
    where: { authorId: session.userId },
    take: limit,
  })
  return Response.json(posts)
}
ts
// app/api/posts/route.test.ts
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { GET } from './route'
import { db } from '@/lib/db'
import { verifySession } from '@/lib/dal'

vi.mock('@/lib/db', () => ({ db: { post: { findMany: vi.fn() } } }))
vi.mock('@/lib/dal', () => ({ verifySession: vi.fn() }))

describe('GET /api/posts', () => {
  beforeEach(() => vi.clearAllMocks())

  it('returns 401 when unauthenticated', async () => {
    vi.mocked(verifySession).mockResolvedValue(null)

    const res = await GET(new Request('http://localhost/api/posts'))

    expect(res.status).toBe(401)
    expect(await res.json()).toEqual({ error: 'Unauthorized' })
  })

  it('scopes results to the session user', async () => {
    vi.mocked(verifySession).mockResolvedValue({ userId: 'u1', role: 'user' })
    vi.mocked(db.post.findMany).mockResolvedValue([])

    await GET(new Request('http://localhost/api/posts'))

    expect(db.post.findMany).toHaveBeenCalledWith(
      expect.objectContaining({ where: { authorId: 'u1' } })
    )
  })

  it('caps the limit at 100', async () => {
    vi.mocked(verifySession).mockResolvedValue({ userId: 'u1', role: 'user' })
    vi.mocked(db.post.findMany).mockResolvedValue([])

    await GET(new Request('http://localhost/api/posts?limit=99999'))

    expect(db.post.findMany).toHaveBeenCalledWith(
      expect.objectContaining({ take: 100 })
    )
  })
})
js
// app/api/posts/route.test.js
import { describe, expect, it, vi } from 'vitest'
import { GET } from './route'
import { verifySession } from '@/lib/dal'

vi.mock('@/lib/db', () => ({ db: { post: { findMany: vi.fn() } } }))
vi.mock('@/lib/dal', () => ({ verifySession: vi.fn() }))

describe('GET /api/posts', () => {
  it('returns 401 when unauthenticated', async () => {
    vi.mocked(verifySession).mockResolvedValue(null)
    const res = await GET(new Request('http://localhost/api/posts'))
    expect(res.status).toBe(401)
  })
})

🚦 Testing Proxy

Next.js ships experimental utilities specifically for this (Chapter 17):

ts
// proxy.test.ts
import { describe, expect, it } from 'vitest'
import {
  unstable_doesProxyMatch,
  isRedirect,
  getRedirectUrl,
} from 'next/experimental/testing/server'
import { NextRequest } from 'next/server'
import { proxy, config } from './proxy'
import nextConfig from './next.config'

describe('proxy matcher', () => {
  it('does not run on static assets', () => {
    expect(
      unstable_doesProxyMatch({ config, nextConfig, url: '/_next/static/a.js' })
    ).toBe(false)
  })

  it('runs on dashboard routes', () => {
    expect(
      unstable_doesProxyMatch({ config, nextConfig, url: '/dashboard' })
    ).toBe(true)
  })
})

describe('proxy behaviour', () => {
  it('redirects unauthenticated users to login', async () => {
    const request = new NextRequest('https://app.test/dashboard')
    const response = await proxy(request)

    expect(isRedirect(response)).toBe(true)
    expect(getRedirectUrl(response)).toContain('/login')
  })

  it('does not loop on /login', async () => {
    const request = new NextRequest('https://app.test/login')
    const response = await proxy(request)

    expect(isRedirect(response)).toBe(false)
  })
})

Testing the matcher is genuinely worth it. A regex that accidentally excludes /dashboard is a security incident no type checker catches.

🎭 End-to-end with Playwright

Where Server Components actually get tested — by running the real app.

bash
npm init playwright@latest
ts
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test'

export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  retries: process.env.CI ? 2 : 0,
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'mobile', use: { ...devices['iPhone 14'] } },
  ],
  webServer: {
    command: 'npm run build && npm run start',   // ← production build
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
    timeout: 120_000,
  },
})

Run E2E against next build && next start, not next dev. Dev mode has no prefetching, different caching, and different error handling. You'd be testing a different application.

ts
// e2e/posts.spec.ts
import { test, expect } from '@playwright/test'

test.describe('post creation', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/login')
    await page.getByLabel('Email').fill('test@example.com')
    await page.getByLabel('Password').fill('password123')
    await page.getByRole('button', { name: 'Sign in' }).click()
    await expect(page).toHaveURL('/dashboard')
  })

  test('creates a post and shows it in the list', async ({ page }) => {
    await page.goto('/posts/new')

    await page.getByLabel('Title').fill('My integration test post')
    await page.getByLabel('Body').fill('Body content that is long enough.')
    await page.getByRole('button', { name: 'Publish' }).click()

    await expect(page.getByText('My integration test post')).toBeVisible()
  })

  test('shows validation errors without losing input', async ({ page }) => {
    await page.goto('/posts/new')

    await page.getByLabel('Title').fill('ab')
    await page.getByLabel('Body').fill('Body content that is long enough.')
    await page.getByRole('button', { name: 'Publish' }).click()

    await expect(page.getByText(/at least 3 characters/)).toBeVisible()
    // The important part — the form kept what the user typed
    await expect(page.getByLabel('Body')).toHaveValue(
      'Body content that is long enough.'
    )
  })
})

test('redirects unauthenticated users away from the dashboard', async ({ page }) => {
  await page.goto('/dashboard')
  await expect(page).toHaveURL(/\/login/)
})

Testing forms without JavaScript

A genuinely useful test that only makes sense in the App Router — proving progressive enhancement works:

ts
// e2e/no-js.spec.ts
import { test, expect } from '@playwright/test'

test.use({ javaScriptEnabled: false })

test('the login form works without JavaScript', async ({ page }) => {
  await page.goto('/login')
  await page.getByLabel('Email').fill('test@example.com')
  await page.getByLabel('Password').fill('password123')
  await page.getByRole('button', { name: 'Sign in' }).click()

  await expect(page).toHaveURL('/dashboard')
})

If this passes, your Server Actions are genuinely progressively enhanced. If it fails, you've got an onSubmit somewhere you didn't need.

Reusing authentication

Logging in before every test is slow. Save the session once:

ts
// e2e/auth.setup.ts
import { test as setup, expect } from '@playwright/test'

const authFile = 'e2e/.auth/user.json'

setup('authenticate', async ({ page }) => {
  await page.goto('/login')
  await page.getByLabel('Email').fill('test@example.com')
  await page.getByLabel('Password').fill('password123')
  await page.getByRole('button', { name: 'Sign in' }).click()
  await expect(page).toHaveURL('/dashboard')

  await page.context().storageState({ path: authFile })
})
ts
// playwright.config.ts
projects: [
  { name: 'setup', testMatch: /auth\.setup\.ts/ },
  {
    name: 'chromium',
    use: { ...devices['Desktop Chrome'], storageState: 'e2e/.auth/user.json' },
    dependencies: ['setup'],
  },
]

Performance tests — asserting that a component doesn't re-render unnecessarily, or that an interaction stays under a latency budget — are a different discipline with different tools. Chapter 27: React Performance Profiling covers render-count assertions, Playwright performance budgets, and Lighthouse CI.

🎯 What to test, and how much

                    ╱╲
                   ╱E2E╲          few — critical user journeys
                  ╱──────╲        (signup, checkout, auth gates)
                 ╱        ╲
                ╱Integration╲     some — actions, handlers, proxy
               ╱────────────╲     (real logic, mocked DB)
              ╱              ╲
             ╱      Unit      ╲   many — pure functions,
            ╱──────────────────╲  data helpers, client components

A practical minimum for a real app:

✅ Every Server Action: unauthenticated case + validation + ownership
✅ Every Route Handler: 401 case + happy path + input clamping
✅ Data functions: query shape, edge cases, formatting
✅ Client components with logic: user interaction paths
✅ Proxy: matcher coverage + no redirect loops
✅ E2E: signup, login, the one flow that makes you money

Skip: snapshot tests of markup, tests that assert a <div> exists, and anything testing framework behaviour rather than your own.

⚠️ Common Pitfalls

1. Trying to render async Server Components

Not supported. Test the data functions and cover rendering with E2E.

2. E2E against next dev

Different caching, no prefetching, different error UI. Always build first.

3. Mocking so much the test proves nothing

ts
// ❌ this test verifies the mock, not the code
vi.mock('./posts', () => ({ createPost: vi.fn(() => ({ success: true })) }))
it('creates a post', async () => {
  expect((await createPost()).success).toBe(true)
})

Mock the boundary (database, external API), not the thing under test.

4. Testing implementation details

ts
// ❌ breaks on any refactor
expect(component.state.isOpen).toBe(true)

// ✅ tests behaviour
expect(screen.getByRole('dialog')).toBeVisible()

5. getByTestId everywhere

getByRole doubles as an accessibility check. If you can't find your button by its role and name, neither can a screen reader.

6. Not testing the security cases

The tests that matter most are "unauthenticated user is rejected" and "user A can't touch user B's data". Both are easy to write and both catch real vulnerabilities.

7. Flaky E2E from fixed waits

ts
// ❌
await page.waitForTimeout(2000)

// ✅ auto-retrying assertion
await expect(page.getByText('Saved')).toBeVisible()

Playwright's expect retries until timeout. Fixed sleeps are either too short (flaky) or too long (slow).

8. Chasing 100% coverage

Coverage measures lines executed, not correctness. A suite with 100% coverage and no security tests is worse than one at 60% that verifies your auth checks.

🎯 When & Why to Use

Vitest / Jest when:
  ✅ Pure functions, formatters, validators
  ✅ Server Actions and Route Handlers
  ✅ Client Components with real interaction logic
  ✅ Data-layer query shapes

Playwright when:
  ✅ Full user journeys
  ✅ Anything involving Server Component rendering
  ✅ Auth gates and redirects
  ✅ Progressive enhancement (JS disabled)
  ✅ Cross-browser and mobile viewports

Cypress when:
  ✅ Your team already uses it
  ⚠️ Playwright is the better default for new projects
     (faster, real multi-browser, better parallelism)

Skip testing:
  ❌ Framework behaviour (routing, layouts)
  ❌ Markup snapshots
  ❌ Trivial getters

🏋️ Mini Practice Problems

Problem 1: What's wrong?

ts
import { render } from '@testing-library/react'
import DashboardPage from '@/app/dashboard/page'

it('shows the user name', async () => {
  render(await DashboardPage())
  expect(screen.getByText('Alice')).toBeInTheDocument()
})

Why is this fragile? Give two better approaches.

Problem 2: Write the security tests

ts
'use server'
export async function deleteComment(id: string) {
  const session = await verifySession()
  if (!session) return { error: 'Unauthorized' }

  const comment = await db.comment.findUnique({ where: { id } })
  if (comment.authorId !== session.userId) return { error: 'Forbidden' }

  await db.comment.delete({ where: { id } })
  return { success: true }
}

Write four tests. One of them should catch a bug that's already in this code — find it.

Problem 3: Pick the layer

Unit, integration, or E2E?

  • A. A currency formatter
  • B. A login form's validation messages
  • C. Whether a logged-out user is redirected from /settings
  • D. Whether a Server Action rejects a spoofed user ID
  • E. Whether the checkout flow completes end to end
  • F. Whether Proxy skips /_next/static

Problem 4: Build the suite

For a blog with public posts, an author dashboard, and comments — write the test plan. List every test, its layer, and what would break in production if it were missing.

💼 Interview Notes

Common Questions

Q: How do you test Server Components? You mostly don't unit test them. Async Server Components aren't supported by React Testing Library. Instead, extract logic into plain functions and unit test those, then cover the rendered result with Playwright against a production build.

Q: How do you test a Server Action? Import it and call it like any async function, mocking the database and session layer. The valuable assertions are the security ones: unauthenticated requests are rejected, the target record is derived from the session, and invalid input never reaches the database.

Q: Why run E2E against a production build? Dev mode disables prefetching, behaves differently for caching, and shows a different error overlay. Testing next dev means testing an application your users never see.

Q: What does testing with JavaScript disabled prove? That your Server Action forms are genuinely progressively enhanced. If the form still submits and the flow completes, you've got real <form action> semantics and not a hidden onSubmit.

Q: Why prefer getByRole over getByTestId? getByRole asserts the accessible name and role, so it doubles as an accessibility test and fails when you break the semantics. getByTestId passes even if the element is unreachable by assistive technology.

Q: How do you test Proxy? next/experimental/testing/server provides unstable_doesProxyMatch for matcher coverage, plus isRedirect/getRedirectUrl for behaviour. Testing the matcher is important — a wrong regex silently un-protects routes.

Q: What's your testing strategy for a Next.js app? Many unit tests for pure logic and client components, a solid layer of integration tests over Server Actions and Route Handlers focused on security properties, and a small number of E2E tests covering the flows that make money.

🏢 Asked at Companies

  • Vercel: "How would you test a page that's an async Server Component? Talk through the constraint."
  • Stripe: "What tests would you require before merging a new Server Action?"
  • Shopify: "The E2E suite is flaky in CI and passes locally. How do you diagnose it?"
  • Airbnb: "Where do you draw the line between unit, integration, and E2E?"

📊 Visual Memory Aid

              WHAT'S TESTABLE HOW

  ┌──────────────────┬─────────┬──────────────────────┐
  │                  │  Unit   │  Approach            │
  ├──────────────────┼─────────┼──────────────────────┤
  │ Pure functions   │   ✅    │ import and call      │
  │ Client Component │   ✅    │ Testing Library      │
  │ Server Action    │   ✅    │ import and call      │
  │ Route Handler    │   ✅    │ call GET/POST        │
  │ Proxy            │   ✅    │ testing/server utils │
  │ Server Component │   ❌    │ → E2E                │
  │ Full flow        │   ❌    │ → E2E                │
  └──────────────────┴─────────┴──────────────────────┘


              THE TESTS THAT MATTER MOST

  For every Server Action / Route Handler:
    □ unauthenticated → rejected, DB untouched
    □ wrong owner     → rejected, DB untouched
    □ invalid input   → error returned, DB untouched
    □ happy path      → correct data written

  These four catch the bugs that become incidents.


              E2E ENVIRONMENT

  ❌ webServer: 'npm run dev'
       no prefetch · different caching · dev error overlay

  ✅ webServer: 'npm run build && npm run start'
       what your users actually get

🎯 Key Takeaways

  1. Async Server Components can't be unit tested reliably. Keep them thin, extract the logic into plain functions, and cover the rendering with E2E.
  2. Server Actions and Route Handlers are just functions — import them, mock the database and session, and assert the security properties.
  3. Test that unauthorized access fails, not just that authorized access works. Those tests catch the bugs that turn into incidents.
  4. Run Playwright against next build && next start. Dev mode is a different application.
  5. getByRole over getByTestId — it doubles as an accessibility assertion, so a passing test means a usable interface.

Next Chapter: Deployment & Self-Hosting →

Practice: Add tests to an existing app in this order — one Server Action's four security cases, one Route Handler's 401 path, a Proxy matcher test, and one Playwright journey with JavaScript disabled. Then break each behaviour deliberately and confirm the right test fails.


PreviousChapter 22: Performance OptimizationNextChapter 24: Deployment & Self-Hosting

Open source, free forever. Built by iammhador.

Contribute on GitHub