Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
Comprehensive Playwright testing guide covering E2E, component, API, visual, accessibility, and security tests.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
frameworks/nextjs.md
1# Next.js Testing Patterns23## Table of Contents451. [Setup](#setup)62. [App Router Patterns](#app-router-patterns)73. [Pages Router Patterns](#pages-router-patterns)84. [Dynamic Routes](#dynamic-routes)95. [API Routes](#api-routes)106. [Middleware Testing](#middleware-testing)117. [Hydration Testing](#hydration-testing)128. [next/image Testing](#nextimage-testing)139. [NextAuth.js Authentication](#nextauthjs-authentication)1410. [Tips](#tips)1511. [Anti-Patterns](#anti-patterns)1612. [Related](#related)1718> **When to use**: Testing Next.js applications with App Router, Pages Router, API routes, middleware, SSR, dynamic routes, and server components.19> **Prerequisites**: [configuration.md](../core/configuration.md), [locators.md](../core/locators.md)2021## Setup2223### Configuration with webServer2425```typescript26// playwright.config.ts27import { defineConfig, devices } from '@playwright/test';2829export default defineConfig({30testDir: './tests',31fullyParallel: true,32forbidOnly: !!process.env.CI,33retries: process.env.CI ? 2 : 0,34workers: process.env.CI ? '50%' : undefined,3536use: {37baseURL: 'http://localhost:3000',38trace: 'on-first-retry',39screenshot: 'only-on-failure',40},4142projects: [43{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },44{ name: 'mobile', use: { ...devices['iPhone 14'] } },45],4647webServer: {48command: process.env.CI49? 'npm run build && npm run start'50: 'npm run dev',51url: 'http://localhost:3000',52reuseExistingServer: !process.env.CI,53timeout: 120_000,54env: {55NODE_ENV: process.env.CI ? 'production' : 'test',56},57},58});59```6061### Environment Variables6263Next.js loads `.env.test` when `NODE_ENV=test`:6465```bash66# .env.test (commit this)67NEXT_PUBLIC_API_URL=http://localhost:3000/api68DATABASE_URL=postgresql://localhost:5432/test_db6970# .env.test.local (gitignored)71NEXTAUTH_SECRET=test-secret-local72```7374## App Router Patterns7576### Server Component Content7778```typescript79test('renders server component content', async ({ page }) => {80await page.goto('/');8182await expect(page.getByRole('heading', { name: 'Welcome', level: 1 })).toBeVisible();83await expect(page.getByRole('navigation', { name: 'Main' })).toBeVisible();84});85```8687### Loading States with Streaming8889```typescript90test('loading state during data streaming', async ({ page }) => {91await page.route('**/api/stats', async (route) => {92await new Promise((r) => setTimeout(r, 2000));93await route.continue();94});9596await page.goto('/dashboard');9798await expect(page.getByRole('progressbar')).toBeVisible();99await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();100await expect(page.getByRole('progressbar')).toBeHidden();101});102```103104### Nested Layouts105106```typescript107test('layouts persist across navigation', async ({ page }) => {108await page.goto('/dashboard/analytics');109110const sidebar = page.getByRole('navigation', { name: 'Dashboard' });111await expect(sidebar).toBeVisible();112113await sidebar.getByRole('link', { name: 'Settings' }).click();114await page.waitForURL('/dashboard/settings');115116await expect(sidebar).toBeVisible();117await expect(page.getByRole('heading', { name: 'Settings' })).toBeVisible();118});119```120121## Pages Router Patterns122123### SSR with getServerSideProps124125```typescript126test('page with getServerSideProps renders data', async ({ page }) => {127await page.goto('/blog');128129await expect(page.getByRole('heading', { name: 'Blog', level: 1 })).toBeVisible();130await expect(page.getByRole('article')).toHaveCount(10);131await expect(page.getByRole('article').first()).toContainText(/\w+/);132});133```134135### Static Generation with getStaticProps136137```typescript138test('static page shows pre-rendered content', async ({ page }) => {139await page.goto('/about');140141await expect(page.getByRole('heading', { name: 'About Us' })).toBeVisible();142await expect(page.getByText('Founded in 2020')).toBeVisible();143});144```145146## Dynamic Routes147148### Slug Parameters149150```typescript151test('dynamic [slug] renders correct content', async ({ page }) => {152await page.goto('/blog/testing-guide');153154await expect(page.getByRole('heading', { level: 1 })).toContainText('Testing Guide');155await expect(page.getByText('Page not found')).toBeHidden();156});157158test('non-existent slug shows 404', async ({ page }) => {159const response = await page.goto('/blog/nonexistent-post');160161expect(response?.status()).toBe(404);162await expect(page.getByRole('heading', { name: '404' })).toBeVisible();163});164```165166### Catch-All Routes167168```typescript169test('catch-all handles nested paths', async ({ page }) => {170await page.goto('/docs/getting-started/installation');171await expect(page.getByRole('heading', { name: 'Installation' })).toBeVisible();172173await page.goto('/docs/api/configuration');174await expect(page.getByRole('heading', { name: 'Configuration' })).toBeVisible();175});176```177178### Query Parameters179180```typescript181test('query parameters filter content', async ({ page }) => {182await page.goto('/products?category=electronics&sort=price-asc');183184await expect(page.getByRole('heading', { name: 'Electronics' })).toBeVisible();185186const prices = await page.getByTestId('product-price').allTextContents();187const numericPrices = prices.map((p) => parseFloat(p.replace('$', '')));188expect(numericPrices).toEqual([...numericPrices].sort((a, b) => a - b));189});190```191192## API Routes193194### Direct API Testing195196```typescript197test('GET /api/products returns list', async ({ request }) => {198const response = await request.get('/api/products');199200expect(response.ok()).toBeTruthy();201const body = await response.json();202expect(body.products).toBeInstanceOf(Array);203expect(body.products[0]).toHaveProperty('id');204expect(body.products[0]).toHaveProperty('name');205});206207test('POST /api/products creates item', async ({ request }) => {208const response = await request.post('/api/products', {209data: { name: 'Test Product', price: 29.99 },210});211212expect(response.status()).toBe(201);213const body = await response.json();214expect(body.product.name).toBe('Test Product');215});216217test('POST /api/products validates fields', async ({ request }) => {218const response = await request.post('/api/products', {219data: { name: '' },220});221222expect(response.status()).toBe(400);223const body = await response.json();224expect(body.error).toContainEqual(expect.objectContaining({ field: 'price' }));225});226```227228### API Through UI229230```typescript231test('form submission calls API', async ({ page }) => {232await page.goto('/products/new');233234await page.getByLabel('Product name').fill('Widget');235await page.getByLabel('Price').fill('19.99');236await page.getByRole('button', { name: 'Create product' }).click();237238await expect(page.getByText('Product created successfully')).toBeVisible();239await page.waitForURL('/products/**');240});241```242243## Middleware Testing244245### Auth Redirects246247```typescript248test('unauthenticated user redirected to login', async ({ page }) => {249await page.goto('/dashboard');250251expect(page.url()).toContain('/login');252await expect(page.getByRole('heading', { name: 'Sign in' })).toBeVisible();253});254255test('redirect preserves return URL', async ({ page }) => {256await page.goto('/dashboard/settings');257258const url = new URL(page.url());259expect(url.pathname).toBe('/login');260expect(url.searchParams.get('callbackUrl') || url.searchParams.get('returnTo'))261.toContain('/dashboard/settings');262});263```264265### Security Headers266267```typescript268test('middleware sets security headers', async ({ page }) => {269const response = await page.goto('/');270271const headers = response!.headers();272expect(headers['x-frame-options']).toBe('DENY');273expect(headers['x-content-type-options']).toBe('nosniff');274});275```276277### Locale Rewrites278279```typescript280test('middleware rewrites based on locale', async ({ page, context }) => {281await context.setExtraHTTPHeaders({282'Accept-Language': 'fr-FR,fr;q=0.9',283});284285await page.goto('/');286287await expect(page.getByText('Bienvenue')).toBeVisible();288});289```290291## Hydration Testing292293### Console Error Detection294295```typescript296test('no hydration errors in console', async ({ page }) => {297const consoleErrors: string[] = [];298page.on('console', (msg) => {299if (msg.type() === 'error') {300consoleErrors.push(msg.text());301}302});303304await page.goto('/');305await page.getByRole('button', { name: 'Get started' }).click();306307const hydrationErrors = consoleErrors.filter(308(e) =>309e.includes('Hydration') ||310e.includes('hydration') ||311e.includes('did not match')312);313expect(hydrationErrors).toEqual([]);314});315```316317### Interactive Elements After Hydration318319```typescript320test('interactive elements work after hydration', async ({ page }) => {321await page.goto('/');322323const counter = page.getByTestId('counter-value');324await expect(counter).toHaveText('0');325326await page.getByRole('button', { name: 'Increment' }).click();327await expect(counter).toHaveText('1');328});329```330331## next/image Testing332333```typescript334test('hero image loads with srcset', async ({ page }) => {335await page.goto('/');336337const heroImage = page.getByRole('img', { name: 'Hero banner' });338await expect(heroImage).toBeVisible();339340const srcset = await heroImage.getAttribute('srcset');341expect(srcset).toBeTruthy();342expect(srcset).toContain('w=');343344const loading = await heroImage.getAttribute('loading');345expect(loading).not.toBe('lazy');346});347348test('offscreen images lazy load', async ({ page }) => {349await page.goto('/gallery');350351const offscreenImage = page.getByRole('img', { name: 'Gallery item 20' });352353await offscreenImage.scrollIntoViewIfNeeded();354await expect(offscreenImage).toBeVisible();355356const naturalWidth = await offscreenImage.evaluate(357(img: HTMLImageElement) => img.naturalWidth358);359expect(naturalWidth).toBeGreaterThan(0);360});361```362363## NextAuth.js Authentication364365### Setup Project366367```typescript368// playwright.config.ts369export default defineConfig({370projects: [371{ name: 'setup', testMatch: /auth\.setup\.ts/ },372{373name: 'authenticated',374use: { storageState: 'playwright/.auth/user.json' },375dependencies: ['setup'],376},377{ name: 'unauthenticated', testMatch: '**/*.unauth.spec.ts' },378],379});380```381382### Auth Setup383384```typescript385// tests/auth.setup.ts386import { test as setup, expect } from '@playwright/test';387388const authFile = 'playwright/.auth/user.json';389390setup('authenticate via credentials', async ({ page }) => {391await page.goto('/login');392await page.getByLabel('Email').fill('[email protected]');393await page.getByLabel('Password').fill(process.env.TEST_PASSWORD!);394await page.getByRole('button', { name: 'Sign in' }).click();395396await page.waitForURL('/dashboard');397await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();398399await page.context().storageState({ path: authFile });400});401```402403### Authenticated Tests404405```typescript406test('authenticated user sees dashboard', async ({ page }) => {407await page.goto('/dashboard');408409await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();410await expect(page.getByText('[email protected]')).toBeVisible();411});412```413414## Tips415416### Dev Server vs Production Build417418| Scenario | Command | Trade-off |419|---|---|---|420| Local development | `npm run dev` | Fast iteration, no production behavior |421| CI pipeline | `npm run build && npm run start` | Tests real production bundle |422423### Turbopack424425```typescript426webServer: {427command: process.env.CI428? 'npm run build && npm run start'429: 'npx next dev --turbopack',430url: 'http://localhost:3000',431reuseExistingServer: !process.env.CI,432},433```434435### Multiple webServer Entries436437```typescript438webServer: [439{440command: 'npm run dev:api',441url: 'http://localhost:4000/health',442reuseExistingServer: !process.env.CI,443},444{445command: 'npm run dev',446url: 'http://localhost:3000',447reuseExistingServer: !process.env.CI,448},449],450```451452## Anti-Patterns453454| Don't Do This | Problem | Do This Instead |455|---|---|---|456| `await page.waitForTimeout(3000)` | Arbitrary waits are fragile | `await page.waitForURL('/path')` or `await expect(locator).toBeVisible()` |457| Test `getServerSideProps` directly | Depends on req/res context | Navigate to page and verify rendered output |458| Mock your own API routes | Hides real API bugs | Let real API handle requests; mock only external services |459| `page.goto('http://localhost:3000/path')` | Breaks when port changes | Use `page.goto('/path')` with `baseURL` |460| Run `npm run build` locally for every test | Extremely slow | Use `npm run dev` locally with `reuseExistingServer: true` |461| Test `next/image` by checking exact URLs | Paths change between dev/prod | Assert on `alt`, visibility, `naturalWidth > 0`, `srcset` |462| Test server actions by calling as functions | Server actions need Next.js runtime | Trigger through UI (forms, buttons) |463464## Related465466- [configuration.md](../core/configuration.md) -- Playwright configuration including `webServer`467- [authentication.md](../advanced/authentication.md) -- authentication setup and `storageState`468- [api-testing.md](../testing-patterns/api-testing.md) -- testing API routes with `request` context469- [react.md](react.md) -- React patterns for Next.js client components470