mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-12 17:22:38 +02:00
1014 lines
35 KiB
Markdown
1014 lines
35 KiB
Markdown
|
|
# Testing Next.js Apps with Playwright
|
||
|
|
|
||
|
|
> **When to use**: Testing Next.js applications -- App Router, Pages Router, API routes, middleware, SSR pages, dynamic routes, and server components. This guide covers E2E testing patterns specific to Next.js behavior.
|
||
|
|
> **Prerequisites**: [core/configuration.md](configuration.md), [core/locators.md](locators.md)
|
||
|
|
|
||
|
|
## Quick Reference
|
||
|
|
|
||
|
|
```bash
|
||
|
|
# Install Playwright in a Next.js project
|
||
|
|
npm init playwright@latest
|
||
|
|
|
||
|
|
# Run with Next.js dev server managed by Playwright
|
||
|
|
npx playwright test
|
||
|
|
|
||
|
|
# Run against a production build (recommended for CI)
|
||
|
|
npx playwright test --project=chromium
|
||
|
|
|
||
|
|
# Debug a single test with headed browser
|
||
|
|
npx playwright test tests/home.spec.ts --headed --debug
|
||
|
|
```
|
||
|
|
|
||
|
|
```
|
||
|
|
# .env.test — loaded by Next.js automatically when NODE_ENV=test
|
||
|
|
NEXT_PUBLIC_API_URL=http://localhost:3000/api
|
||
|
|
DATABASE_URL=postgresql://localhost:5432/test_db
|
||
|
|
NEXTAUTH_SECRET=test-secret-do-not-use-in-production
|
||
|
|
NEXTAUTH_URL=http://localhost:3000
|
||
|
|
```
|
||
|
|
|
||
|
|
## Setup
|
||
|
|
|
||
|
|
### Playwright Config for Next.js
|
||
|
|
|
||
|
|
The single most important configuration detail: use `webServer` to let Playwright start and manage your Next.js server.
|
||
|
|
|
||
|
|
**TypeScript**
|
||
|
|
```typescript
|
||
|
|
// playwright.config.ts
|
||
|
|
import { defineConfig, devices } from '@playwright/test';
|
||
|
|
import path from 'path';
|
||
|
|
|
||
|
|
export default defineConfig({
|
||
|
|
testDir: './tests',
|
||
|
|
testMatch: '**/*.spec.ts',
|
||
|
|
fullyParallel: true,
|
||
|
|
forbidOnly: !!process.env.CI,
|
||
|
|
retries: process.env.CI ? 2 : 0,
|
||
|
|
workers: process.env.CI ? '50%' : undefined,
|
||
|
|
|
||
|
|
use: {
|
||
|
|
baseURL: 'http://localhost:3000',
|
||
|
|
trace: 'on-first-retry',
|
||
|
|
screenshot: 'only-on-failure',
|
||
|
|
},
|
||
|
|
|
||
|
|
projects: [
|
||
|
|
{
|
||
|
|
name: 'chromium',
|
||
|
|
use: { ...devices['Desktop Chrome'] },
|
||
|
|
},
|
||
|
|
{
|
||
|
|
name: 'mobile',
|
||
|
|
use: { ...devices['iPhone 14'] },
|
||
|
|
},
|
||
|
|
],
|
||
|
|
|
||
|
|
webServer: {
|
||
|
|
command: process.env.CI
|
||
|
|
? 'npm run build && npm run start' // production build in CI
|
||
|
|
: 'npm run dev', // dev server locally
|
||
|
|
url: 'http://localhost:3000',
|
||
|
|
reuseExistingServer: !process.env.CI,
|
||
|
|
timeout: 120_000,
|
||
|
|
env: {
|
||
|
|
NODE_ENV: process.env.CI ? 'production' : 'test',
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
**JavaScript**
|
||
|
|
```javascript
|
||
|
|
// playwright.config.js
|
||
|
|
const { defineConfig, devices } = require('@playwright/test');
|
||
|
|
|
||
|
|
module.exports = defineConfig({
|
||
|
|
testDir: './tests',
|
||
|
|
testMatch: '**/*.spec.js',
|
||
|
|
fullyParallel: true,
|
||
|
|
forbidOnly: !!process.env.CI,
|
||
|
|
retries: process.env.CI ? 2 : 0,
|
||
|
|
workers: process.env.CI ? '50%' : undefined,
|
||
|
|
|
||
|
|
use: {
|
||
|
|
baseURL: 'http://localhost:3000',
|
||
|
|
trace: 'on-first-retry',
|
||
|
|
screenshot: 'only-on-failure',
|
||
|
|
},
|
||
|
|
|
||
|
|
projects: [
|
||
|
|
{
|
||
|
|
name: 'chromium',
|
||
|
|
use: { ...devices['Desktop Chrome'] },
|
||
|
|
},
|
||
|
|
{
|
||
|
|
name: 'mobile',
|
||
|
|
use: { ...devices['iPhone 14'] },
|
||
|
|
},
|
||
|
|
],
|
||
|
|
|
||
|
|
webServer: {
|
||
|
|
command: process.env.CI
|
||
|
|
? 'npm run build && npm run start'
|
||
|
|
: 'npm run dev',
|
||
|
|
url: 'http://localhost:3000',
|
||
|
|
reuseExistingServer: !process.env.CI,
|
||
|
|
timeout: 120_000,
|
||
|
|
env: {
|
||
|
|
NODE_ENV: process.env.CI ? 'production' : 'test',
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
### Environment Variables with `.env.test`
|
||
|
|
|
||
|
|
Next.js loads `.env.test` automatically when `NODE_ENV=test`. Use this for test-specific overrides.
|
||
|
|
|
||
|
|
```bash
|
||
|
|
# .env.test (commit this -- no real secrets)
|
||
|
|
NEXT_PUBLIC_API_URL=http://localhost:3000/api
|
||
|
|
NEXT_PUBLIC_FEATURE_FLAG_NEW_CHECKOUT=true
|
||
|
|
DATABASE_URL=postgresql://localhost:5432/test_db
|
||
|
|
|
||
|
|
# .env.test.local (gitignored -- real test secrets)
|
||
|
|
NEXTAUTH_SECRET=test-secret-local
|
||
|
|
STRIPE_TEST_KEY=sk_test_xxx
|
||
|
|
```
|
||
|
|
|
||
|
|
```bash
|
||
|
|
# .gitignore
|
||
|
|
.env*.local
|
||
|
|
playwright-report/
|
||
|
|
playwright/.auth/
|
||
|
|
test-results/
|
||
|
|
```
|
||
|
|
|
||
|
|
## Patterns
|
||
|
|
|
||
|
|
### Testing App Router Pages
|
||
|
|
|
||
|
|
**Use when**: Testing pages built with the Next.js App Router (`app/` directory). App Router pages are server components by default and may include streaming, suspense boundaries, and loading states.
|
||
|
|
**Avoid when**: You need to test isolated server component logic -- use unit tests for that. E2E tests verify the rendered result.
|
||
|
|
|
||
|
|
**TypeScript**
|
||
|
|
```typescript
|
||
|
|
import { test, expect } from '@playwright/test';
|
||
|
|
|
||
|
|
test.describe('App Router pages', () => {
|
||
|
|
test('home page renders server component content', async ({ page }) => {
|
||
|
|
await page.goto('/');
|
||
|
|
|
||
|
|
// Server components render on the server -- by the time Playwright
|
||
|
|
// sees the page, SSR content is already in the HTML
|
||
|
|
await expect(page.getByRole('heading', { name: 'Welcome', level: 1 })).toBeVisible();
|
||
|
|
await expect(page.getByRole('navigation', { name: 'Main' })).toBeVisible();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('loading state shows while data streams in', async ({ page }) => {
|
||
|
|
// Slow down the API to expose the loading state
|
||
|
|
await page.route('**/api/dashboard/stats', async (route) => {
|
||
|
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||
|
|
await route.continue();
|
||
|
|
});
|
||
|
|
|
||
|
|
await page.goto('/dashboard');
|
||
|
|
|
||
|
|
// Verify the loading skeleton appears during streaming
|
||
|
|
await expect(page.getByRole('progressbar')).toBeVisible();
|
||
|
|
|
||
|
|
// Then verify the real content replaces it
|
||
|
|
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
|
||
|
|
await expect(page.getByRole('progressbar')).toBeHidden();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('suspense boundary shows fallback then resolves', async ({ page }) => {
|
||
|
|
await page.goto('/products');
|
||
|
|
|
||
|
|
// The product list may be inside a Suspense boundary
|
||
|
|
// Playwright auto-waits, so just assert the final state
|
||
|
|
await expect(page.getByRole('listitem')).toHaveCount(12);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('nested layouts persist across navigation', async ({ page }) => {
|
||
|
|
await page.goto('/dashboard/analytics');
|
||
|
|
|
||
|
|
// Verify the dashboard layout sidebar is visible
|
||
|
|
const sidebar = page.getByRole('navigation', { name: 'Dashboard' });
|
||
|
|
await expect(sidebar).toBeVisible();
|
||
|
|
|
||
|
|
// Navigate to a sibling route -- layout should persist (no full reload)
|
||
|
|
await sidebar.getByRole('link', { name: 'Settings' }).click();
|
||
|
|
await page.waitForURL('/dashboard/settings');
|
||
|
|
|
||
|
|
// Sidebar is still there -- layout was not re-mounted
|
||
|
|
await expect(sidebar).toBeVisible();
|
||
|
|
await expect(page.getByRole('heading', { name: 'Settings' })).toBeVisible();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
**JavaScript**
|
||
|
|
```javascript
|
||
|
|
const { test, expect } = require('@playwright/test');
|
||
|
|
|
||
|
|
test.describe('App Router pages', () => {
|
||
|
|
test('home page renders server component content', async ({ page }) => {
|
||
|
|
await page.goto('/');
|
||
|
|
|
||
|
|
await expect(page.getByRole('heading', { name: 'Welcome', level: 1 })).toBeVisible();
|
||
|
|
await expect(page.getByRole('navigation', { name: 'Main' })).toBeVisible();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('loading state shows while data streams in', async ({ page }) => {
|
||
|
|
await page.route('**/api/dashboard/stats', async (route) => {
|
||
|
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||
|
|
await route.continue();
|
||
|
|
});
|
||
|
|
|
||
|
|
await page.goto('/dashboard');
|
||
|
|
|
||
|
|
await expect(page.getByRole('progressbar')).toBeVisible();
|
||
|
|
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
|
||
|
|
await expect(page.getByRole('progressbar')).toBeHidden();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('suspense boundary shows fallback then resolves', async ({ page }) => {
|
||
|
|
await page.goto('/products');
|
||
|
|
|
||
|
|
await expect(page.getByRole('listitem')).toHaveCount(12);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('nested layouts persist across navigation', async ({ page }) => {
|
||
|
|
await page.goto('/dashboard/analytics');
|
||
|
|
|
||
|
|
const sidebar = page.getByRole('navigation', { name: 'Dashboard' });
|
||
|
|
await expect(sidebar).toBeVisible();
|
||
|
|
|
||
|
|
await sidebar.getByRole('link', { name: 'Settings' }).click();
|
||
|
|
await page.waitForURL('/dashboard/settings');
|
||
|
|
|
||
|
|
await expect(sidebar).toBeVisible();
|
||
|
|
await expect(page.getByRole('heading', { name: 'Settings' })).toBeVisible();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
### Testing Pages Router (getServerSideProps / getStaticProps)
|
||
|
|
|
||
|
|
**Use when**: Testing pages built with the Pages Router (`pages/` directory) that use `getServerSideProps` or `getStaticProps` for data fetching.
|
||
|
|
**Avoid when**: Testing the data fetching functions directly -- that is a unit test concern. E2E tests verify what the user sees.
|
||
|
|
|
||
|
|
**TypeScript**
|
||
|
|
```typescript
|
||
|
|
import { test, expect } from '@playwright/test';
|
||
|
|
|
||
|
|
test.describe('Pages Router with SSR', () => {
|
||
|
|
test('page with getServerSideProps renders fetched data', async ({ page }) => {
|
||
|
|
await page.goto('/blog');
|
||
|
|
|
||
|
|
// getServerSideProps fetches posts on the server -- verify they render
|
||
|
|
await expect(page.getByRole('heading', { name: 'Blog', level: 1 })).toBeVisible();
|
||
|
|
await expect(page.getByRole('article')).toHaveCount(10);
|
||
|
|
|
||
|
|
// Verify server-fetched data appears (not a loading skeleton)
|
||
|
|
await expect(page.getByRole('article').first()).toContainText(/\w+/);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('page with getStaticProps shows pre-rendered content', async ({ page }) => {
|
||
|
|
await page.goto('/about');
|
||
|
|
|
||
|
|
// Static pages are pre-rendered at build time -- content is immediate
|
||
|
|
await expect(page.getByRole('heading', { name: 'About Us' })).toBeVisible();
|
||
|
|
await expect(page.getByText('Founded in 2020')).toBeVisible();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('client-side navigation with next/link preserves SPA behavior', async ({ page }) => {
|
||
|
|
await page.goto('/blog');
|
||
|
|
|
||
|
|
// Click a next/link -- this should be a client-side transition, not a full reload
|
||
|
|
const navigationPromise = page.waitForURL('/blog/my-first-post');
|
||
|
|
await page.getByRole('link', { name: 'My First Post' }).click();
|
||
|
|
await navigationPromise;
|
||
|
|
|
||
|
|
await expect(page.getByRole('heading', { name: 'My First Post', level: 1 })).toBeVisible();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
**JavaScript**
|
||
|
|
```javascript
|
||
|
|
const { test, expect } = require('@playwright/test');
|
||
|
|
|
||
|
|
test.describe('Pages Router with SSR', () => {
|
||
|
|
test('page with getServerSideProps renders fetched data', async ({ page }) => {
|
||
|
|
await page.goto('/blog');
|
||
|
|
|
||
|
|
await expect(page.getByRole('heading', { name: 'Blog', level: 1 })).toBeVisible();
|
||
|
|
await expect(page.getByRole('article')).toHaveCount(10);
|
||
|
|
await expect(page.getByRole('article').first()).toContainText(/\w+/);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('page with getStaticProps shows pre-rendered content', async ({ page }) => {
|
||
|
|
await page.goto('/about');
|
||
|
|
|
||
|
|
await expect(page.getByRole('heading', { name: 'About Us' })).toBeVisible();
|
||
|
|
await expect(page.getByText('Founded in 2020')).toBeVisible();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('client-side navigation with next/link preserves SPA behavior', async ({ page }) => {
|
||
|
|
await page.goto('/blog');
|
||
|
|
|
||
|
|
const navigationPromise = page.waitForURL('/blog/my-first-post');
|
||
|
|
await page.getByRole('link', { name: 'My First Post' }).click();
|
||
|
|
await navigationPromise;
|
||
|
|
|
||
|
|
await expect(page.getByRole('heading', { name: 'My First Post', level: 1 })).toBeVisible();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
### Testing Dynamic Routes (`[slug]`, `[...catchAll]`)
|
||
|
|
|
||
|
|
**Use when**: Testing pages with dynamic segments like `/blog/[slug]`, `/products/[id]`, or catch-all routes like `/docs/[...path]`.
|
||
|
|
**Avoid when**: The route is static -- no dynamic segments involved.
|
||
|
|
|
||
|
|
**TypeScript**
|
||
|
|
```typescript
|
||
|
|
import { test, expect } from '@playwright/test';
|
||
|
|
|
||
|
|
test.describe('dynamic routes', () => {
|
||
|
|
test('dynamic [slug] page renders correct content', async ({ page }) => {
|
||
|
|
await page.goto('/blog/nextjs-testing-guide');
|
||
|
|
|
||
|
|
await expect(page.getByRole('heading', { level: 1 })).toContainText('Next.js Testing Guide');
|
||
|
|
// Verify the slug maps to the correct content, not a 404
|
||
|
|
await expect(page.getByText('Page not found')).toBeHidden();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('non-existent slug shows 404 page', async ({ page }) => {
|
||
|
|
const response = await page.goto('/blog/this-post-does-not-exist');
|
||
|
|
|
||
|
|
// Next.js returns 404 for pages that call notFound() or return { notFound: true }
|
||
|
|
expect(response?.status()).toBe(404);
|
||
|
|
await expect(page.getByRole('heading', { name: '404' })).toBeVisible();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('catch-all route handles nested paths', async ({ page }) => {
|
||
|
|
await page.goto('/docs/getting-started/installation');
|
||
|
|
|
||
|
|
await expect(page.getByRole('heading', { name: 'Installation' })).toBeVisible();
|
||
|
|
|
||
|
|
// Navigate to a different docs path
|
||
|
|
await page.goto('/docs/api/configuration');
|
||
|
|
await expect(page.getByRole('heading', { name: 'Configuration' })).toBeVisible();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('dynamic route with query parameters', async ({ page }) => {
|
||
|
|
await page.goto('/products?category=electronics&sort=price-asc');
|
||
|
|
|
||
|
|
await expect(page.getByRole('heading', { name: 'Electronics' })).toBeVisible();
|
||
|
|
// Verify sort order is applied
|
||
|
|
const prices = await page.getByTestId('product-price').allTextContents();
|
||
|
|
const numericPrices = prices.map((p) => parseFloat(p.replace('$', '')));
|
||
|
|
expect(numericPrices).toEqual([...numericPrices].sort((a, b) => a - b));
|
||
|
|
});
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
**JavaScript**
|
||
|
|
```javascript
|
||
|
|
const { test, expect } = require('@playwright/test');
|
||
|
|
|
||
|
|
test.describe('dynamic routes', () => {
|
||
|
|
test('dynamic [slug] page renders correct content', async ({ page }) => {
|
||
|
|
await page.goto('/blog/nextjs-testing-guide');
|
||
|
|
|
||
|
|
await expect(page.getByRole('heading', { level: 1 })).toContainText('Next.js Testing Guide');
|
||
|
|
await expect(page.getByText('Page not found')).toBeHidden();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('non-existent slug shows 404 page', async ({ page }) => {
|
||
|
|
const response = await page.goto('/blog/this-post-does-not-exist');
|
||
|
|
|
||
|
|
expect(response?.status()).toBe(404);
|
||
|
|
await expect(page.getByRole('heading', { name: '404' })).toBeVisible();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('catch-all route handles nested paths', async ({ page }) => {
|
||
|
|
await page.goto('/docs/getting-started/installation');
|
||
|
|
|
||
|
|
await expect(page.getByRole('heading', { name: 'Installation' })).toBeVisible();
|
||
|
|
|
||
|
|
await page.goto('/docs/api/configuration');
|
||
|
|
await expect(page.getByRole('heading', { name: 'Configuration' })).toBeVisible();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('dynamic route with query parameters', async ({ page }) => {
|
||
|
|
await page.goto('/products?category=electronics&sort=price-asc');
|
||
|
|
|
||
|
|
await expect(page.getByRole('heading', { name: 'Electronics' })).toBeVisible();
|
||
|
|
const prices = await page.getByTestId('product-price').allTextContents();
|
||
|
|
const numericPrices = prices.map((p) => parseFloat(p.replace('$', '')));
|
||
|
|
expect(numericPrices).toEqual([...numericPrices].sort((a, b) => a - b));
|
||
|
|
});
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
### Testing API Routes
|
||
|
|
|
||
|
|
**Use when**: Testing Next.js API routes (`app/api/` or `pages/api/`) directly with Playwright's `request` context, or indirectly through UI interactions that call them.
|
||
|
|
**Avoid when**: Unit testing API handler logic in isolation -- use a unit testing framework for that.
|
||
|
|
|
||
|
|
**TypeScript**
|
||
|
|
```typescript
|
||
|
|
import { test, expect } from '@playwright/test';
|
||
|
|
|
||
|
|
test.describe('API routes -- direct testing', () => {
|
||
|
|
test('GET /api/products returns product list', async ({ request }) => {
|
||
|
|
const response = await request.get('/api/products');
|
||
|
|
|
||
|
|
expect(response.ok()).toBeTruthy();
|
||
|
|
const body = await response.json();
|
||
|
|
expect(body.products).toBeInstanceOf(Array);
|
||
|
|
expect(body.products.length).toBeGreaterThan(0);
|
||
|
|
expect(body.products[0]).toHaveProperty('id');
|
||
|
|
expect(body.products[0]).toHaveProperty('name');
|
||
|
|
expect(body.products[0]).toHaveProperty('price');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('POST /api/products creates a new product', async ({ request }) => {
|
||
|
|
const response = await request.post('/api/products', {
|
||
|
|
data: {
|
||
|
|
name: 'Test Product',
|
||
|
|
price: 29.99,
|
||
|
|
description: 'Created by Playwright',
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(response.status()).toBe(201);
|
||
|
|
const body = await response.json();
|
||
|
|
expect(body.product.name).toBe('Test Product');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('POST /api/products validates required fields', async ({ request }) => {
|
||
|
|
const response = await request.post('/api/products', {
|
||
|
|
data: { name: '' }, // missing required fields
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(response.status()).toBe(400);
|
||
|
|
const body = await response.json();
|
||
|
|
expect(body.error).toContainEqual(
|
||
|
|
expect.objectContaining({ field: 'price' })
|
||
|
|
);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
test.describe('API routes -- indirect through UI', () => {
|
||
|
|
test('form submission calls API and shows result', async ({ page }) => {
|
||
|
|
await page.goto('/products/new');
|
||
|
|
|
||
|
|
await page.getByLabel('Product name').fill('Widget');
|
||
|
|
await page.getByLabel('Price').fill('19.99');
|
||
|
|
await page.getByRole('button', { name: 'Create product' }).click();
|
||
|
|
|
||
|
|
// The UI calls POST /api/products internally
|
||
|
|
await expect(page.getByText('Product created successfully')).toBeVisible();
|
||
|
|
await page.waitForURL('/products/**');
|
||
|
|
});
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
**JavaScript**
|
||
|
|
```javascript
|
||
|
|
const { test, expect } = require('@playwright/test');
|
||
|
|
|
||
|
|
test.describe('API routes -- direct testing', () => {
|
||
|
|
test('GET /api/products returns product list', async ({ request }) => {
|
||
|
|
const response = await request.get('/api/products');
|
||
|
|
|
||
|
|
expect(response.ok()).toBeTruthy();
|
||
|
|
const body = await response.json();
|
||
|
|
expect(body.products).toBeInstanceOf(Array);
|
||
|
|
expect(body.products.length).toBeGreaterThan(0);
|
||
|
|
expect(body.products[0]).toHaveProperty('id');
|
||
|
|
expect(body.products[0]).toHaveProperty('name');
|
||
|
|
expect(body.products[0]).toHaveProperty('price');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('POST /api/products creates a new product', async ({ request }) => {
|
||
|
|
const response = await request.post('/api/products', {
|
||
|
|
data: {
|
||
|
|
name: 'Test Product',
|
||
|
|
price: 29.99,
|
||
|
|
description: 'Created by Playwright',
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(response.status()).toBe(201);
|
||
|
|
const body = await response.json();
|
||
|
|
expect(body.product.name).toBe('Test Product');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('POST /api/products validates required fields', async ({ request }) => {
|
||
|
|
const response = await request.post('/api/products', {
|
||
|
|
data: { name: '' },
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(response.status()).toBe(400);
|
||
|
|
const body = await response.json();
|
||
|
|
expect(body.error).toContainEqual(
|
||
|
|
expect.objectContaining({ field: 'price' })
|
||
|
|
);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
test.describe('API routes -- indirect through UI', () => {
|
||
|
|
test('form submission calls API and shows result', async ({ page }) => {
|
||
|
|
await page.goto('/products/new');
|
||
|
|
|
||
|
|
await page.getByLabel('Product name').fill('Widget');
|
||
|
|
await page.getByLabel('Price').fill('19.99');
|
||
|
|
await page.getByRole('button', { name: 'Create product' }).click();
|
||
|
|
|
||
|
|
await expect(page.getByText('Product created successfully')).toBeVisible();
|
||
|
|
await page.waitForURL('/products/**');
|
||
|
|
});
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
### Testing Middleware
|
||
|
|
|
||
|
|
**Use when**: Testing Next.js middleware that handles redirects, rewrites, authentication guards, geolocation-based routing, or header manipulation.
|
||
|
|
**Avoid when**: The middleware logic is trivial -- a redirect from `/old` to `/new` can be verified with a simple navigation test.
|
||
|
|
|
||
|
|
**TypeScript**
|
||
|
|
```typescript
|
||
|
|
import { test, expect } from '@playwright/test';
|
||
|
|
|
||
|
|
test.describe('middleware', () => {
|
||
|
|
test('unauthenticated user is redirected to login', async ({ page }) => {
|
||
|
|
// Visit a protected page without auth cookies
|
||
|
|
const response = await page.goto('/dashboard');
|
||
|
|
|
||
|
|
// Middleware should redirect to /login
|
||
|
|
expect(page.url()).toContain('/login');
|
||
|
|
await expect(page.getByRole('heading', { name: 'Sign in' })).toBeVisible();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('middleware redirect preserves the return URL', async ({ page }) => {
|
||
|
|
await page.goto('/dashboard/settings');
|
||
|
|
|
||
|
|
// Should redirect to login with a callbackUrl or returnTo parameter
|
||
|
|
const url = new URL(page.url());
|
||
|
|
expect(url.pathname).toBe('/login');
|
||
|
|
expect(url.searchParams.get('callbackUrl') || url.searchParams.get('returnTo'))
|
||
|
|
.toContain('/dashboard/settings');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('middleware sets security headers', async ({ page }) => {
|
||
|
|
const response = await page.goto('/');
|
||
|
|
|
||
|
|
const headers = response!.headers();
|
||
|
|
expect(headers['x-frame-options']).toBe('DENY');
|
||
|
|
expect(headers['x-content-type-options']).toBe('nosniff');
|
||
|
|
expect(headers['referrer-policy']).toBe('strict-origin-when-cross-origin');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('middleware rewrites based on locale', async ({ page, context }) => {
|
||
|
|
// Set Accept-Language header to simulate a French user
|
||
|
|
await context.setExtraHTTPHeaders({
|
||
|
|
'Accept-Language': 'fr-FR,fr;q=0.9',
|
||
|
|
});
|
||
|
|
|
||
|
|
await page.goto('/');
|
||
|
|
|
||
|
|
// Middleware should rewrite to the French locale
|
||
|
|
await expect(page.getByText('Bienvenue')).toBeVisible();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('middleware blocks unauthorized API access', async ({ request }) => {
|
||
|
|
// Call a protected API route without authentication
|
||
|
|
const response = await request.get('/api/admin/users');
|
||
|
|
|
||
|
|
expect(response.status()).toBe(401);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
**JavaScript**
|
||
|
|
```javascript
|
||
|
|
const { test, expect } = require('@playwright/test');
|
||
|
|
|
||
|
|
test.describe('middleware', () => {
|
||
|
|
test('unauthenticated user is redirected to login', async ({ page }) => {
|
||
|
|
const response = await page.goto('/dashboard');
|
||
|
|
|
||
|
|
expect(page.url()).toContain('/login');
|
||
|
|
await expect(page.getByRole('heading', { name: 'Sign in' })).toBeVisible();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('middleware redirect preserves the return URL', async ({ page }) => {
|
||
|
|
await page.goto('/dashboard/settings');
|
||
|
|
|
||
|
|
const url = new URL(page.url());
|
||
|
|
expect(url.pathname).toBe('/login');
|
||
|
|
expect(url.searchParams.get('callbackUrl') || url.searchParams.get('returnTo'))
|
||
|
|
.toContain('/dashboard/settings');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('middleware sets security headers', async ({ page }) => {
|
||
|
|
const response = await page.goto('/');
|
||
|
|
|
||
|
|
const headers = response.headers();
|
||
|
|
expect(headers['x-frame-options']).toBe('DENY');
|
||
|
|
expect(headers['x-content-type-options']).toBe('nosniff');
|
||
|
|
expect(headers['referrer-policy']).toBe('strict-origin-when-cross-origin');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('middleware rewrites based on locale', async ({ page, context }) => {
|
||
|
|
await context.setExtraHTTPHeaders({
|
||
|
|
'Accept-Language': 'fr-FR,fr;q=0.9',
|
||
|
|
});
|
||
|
|
|
||
|
|
await page.goto('/');
|
||
|
|
|
||
|
|
await expect(page.getByText('Bienvenue')).toBeVisible();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('middleware blocks unauthorized API access', async ({ request }) => {
|
||
|
|
const response = await request.get('/api/admin/users');
|
||
|
|
|
||
|
|
expect(response.status()).toBe(401);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
### Testing Hydration and SSR/CSR Consistency
|
||
|
|
|
||
|
|
**Use when**: Verifying that server-rendered HTML matches the client-side hydrated output. Hydration mismatches cause visual flicker, broken interactivity, or React errors in the console.
|
||
|
|
**Avoid when**: The page has no interactive client components -- pure server components do not hydrate.
|
||
|
|
|
||
|
|
**TypeScript**
|
||
|
|
```typescript
|
||
|
|
import { test, expect } from '@playwright/test';
|
||
|
|
|
||
|
|
test.describe('hydration', () => {
|
||
|
|
test('no hydration errors in console', async ({ page }) => {
|
||
|
|
const consoleErrors: string[] = [];
|
||
|
|
page.on('console', (msg) => {
|
||
|
|
if (msg.type() === 'error') {
|
||
|
|
consoleErrors.push(msg.text());
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
await page.goto('/');
|
||
|
|
// Wait for hydration to complete -- interactive elements become clickable
|
||
|
|
await page.getByRole('button', { name: 'Get started' }).click();
|
||
|
|
|
||
|
|
// Filter for hydration-specific errors
|
||
|
|
const hydrationErrors = consoleErrors.filter(
|
||
|
|
(e) =>
|
||
|
|
e.includes('Hydration') ||
|
||
|
|
e.includes('hydration') ||
|
||
|
|
e.includes('server-rendered') ||
|
||
|
|
e.includes('did not match')
|
||
|
|
);
|
||
|
|
expect(hydrationErrors).toEqual([]);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('interactive elements work after hydration', async ({ page }) => {
|
||
|
|
await page.goto('/');
|
||
|
|
|
||
|
|
// This button relies on a client component event handler
|
||
|
|
// If hydration fails, the click will do nothing
|
||
|
|
const counter = page.getByTestId('counter-value');
|
||
|
|
await expect(counter).toHaveText('0');
|
||
|
|
|
||
|
|
await page.getByRole('button', { name: 'Increment' }).click();
|
||
|
|
await expect(counter).toHaveText('1');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('date/time renders without hydration mismatch', async ({ page }) => {
|
||
|
|
// Dates are a common source of hydration mismatch because server
|
||
|
|
// and client may be in different timezones
|
||
|
|
await page.goto('/dashboard');
|
||
|
|
|
||
|
|
// Verify the date displays without flicker
|
||
|
|
const dateElement = page.getByTestId('current-date');
|
||
|
|
await expect(dateElement).toBeVisible();
|
||
|
|
// Verify it contains a plausible date format, not "undefined" or garbled text
|
||
|
|
await expect(dateElement).toHaveText(/\w+ \d{1,2}, \d{4}/);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
**JavaScript**
|
||
|
|
```javascript
|
||
|
|
const { test, expect } = require('@playwright/test');
|
||
|
|
|
||
|
|
test.describe('hydration', () => {
|
||
|
|
test('no hydration errors in console', async ({ page }) => {
|
||
|
|
const consoleErrors = [];
|
||
|
|
page.on('console', (msg) => {
|
||
|
|
if (msg.type() === 'error') {
|
||
|
|
consoleErrors.push(msg.text());
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
await page.goto('/');
|
||
|
|
await page.getByRole('button', { name: 'Get started' }).click();
|
||
|
|
|
||
|
|
const hydrationErrors = consoleErrors.filter(
|
||
|
|
(e) =>
|
||
|
|
e.includes('Hydration') ||
|
||
|
|
e.includes('hydration') ||
|
||
|
|
e.includes('server-rendered') ||
|
||
|
|
e.includes('did not match')
|
||
|
|
);
|
||
|
|
expect(hydrationErrors).toEqual([]);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('interactive elements work after hydration', async ({ page }) => {
|
||
|
|
await page.goto('/');
|
||
|
|
|
||
|
|
const counter = page.getByTestId('counter-value');
|
||
|
|
await expect(counter).toHaveText('0');
|
||
|
|
|
||
|
|
await page.getByRole('button', { name: 'Increment' }).click();
|
||
|
|
await expect(counter).toHaveText('1');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('date/time renders without hydration mismatch', async ({ page }) => {
|
||
|
|
await page.goto('/dashboard');
|
||
|
|
|
||
|
|
const dateElement = page.getByTestId('current-date');
|
||
|
|
await expect(dateElement).toBeVisible();
|
||
|
|
await expect(dateElement).toHaveText(/\w+ \d{1,2}, \d{4}/);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
### Testing next/image Optimization
|
||
|
|
|
||
|
|
**Use when**: Verifying that `next/image` components render correctly, lazy load offscreen images, and serve optimized formats.
|
||
|
|
**Avoid when**: You do not use `next/image` or image optimization is not a concern for your test.
|
||
|
|
|
||
|
|
**TypeScript**
|
||
|
|
```typescript
|
||
|
|
import { test, expect } from '@playwright/test';
|
||
|
|
|
||
|
|
test.describe('next/image', () => {
|
||
|
|
test('hero image loads with correct attributes', async ({ page }) => {
|
||
|
|
await page.goto('/');
|
||
|
|
|
||
|
|
const heroImage = page.getByRole('img', { name: 'Hero banner' });
|
||
|
|
await expect(heroImage).toBeVisible();
|
||
|
|
|
||
|
|
// Verify next/image sets srcset for responsive loading
|
||
|
|
const srcset = await heroImage.getAttribute('srcset');
|
||
|
|
expect(srcset).toBeTruthy();
|
||
|
|
expect(srcset).toContain('w='); // next/image adds width descriptors
|
||
|
|
|
||
|
|
// Verify priority images are not lazy-loaded
|
||
|
|
const loading = await heroImage.getAttribute('loading');
|
||
|
|
expect(loading).not.toBe('lazy'); // priority images use eager loading
|
||
|
|
});
|
||
|
|
|
||
|
|
test('offscreen images lazy load on scroll', async ({ page }) => {
|
||
|
|
await page.goto('/gallery');
|
||
|
|
|
||
|
|
// Get an image that is below the fold
|
||
|
|
const offscreenImage = page.getByRole('img', { name: 'Gallery item 20' });
|
||
|
|
|
||
|
|
// Before scroll: image should not have loaded its src yet
|
||
|
|
const initialSrc = await offscreenImage.getAttribute('src');
|
||
|
|
// next/image uses a blur placeholder or empty src for lazy images
|
||
|
|
|
||
|
|
// Scroll the image into view
|
||
|
|
await offscreenImage.scrollIntoViewIfNeeded();
|
||
|
|
await expect(offscreenImage).toBeVisible();
|
||
|
|
|
||
|
|
// Verify the image has loaded (naturalWidth > 0 means the image loaded)
|
||
|
|
const naturalWidth = await offscreenImage.evaluate(
|
||
|
|
(img: HTMLImageElement) => img.naturalWidth
|
||
|
|
);
|
||
|
|
expect(naturalWidth).toBeGreaterThan(0);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
**JavaScript**
|
||
|
|
```javascript
|
||
|
|
const { test, expect } = require('@playwright/test');
|
||
|
|
|
||
|
|
test.describe('next/image', () => {
|
||
|
|
test('hero image loads with correct attributes', async ({ page }) => {
|
||
|
|
await page.goto('/');
|
||
|
|
|
||
|
|
const heroImage = page.getByRole('img', { name: 'Hero banner' });
|
||
|
|
await expect(heroImage).toBeVisible();
|
||
|
|
|
||
|
|
const srcset = await heroImage.getAttribute('srcset');
|
||
|
|
expect(srcset).toBeTruthy();
|
||
|
|
expect(srcset).toContain('w=');
|
||
|
|
|
||
|
|
const loading = await heroImage.getAttribute('loading');
|
||
|
|
expect(loading).not.toBe('lazy');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('offscreen images lazy load on scroll', async ({ page }) => {
|
||
|
|
await page.goto('/gallery');
|
||
|
|
|
||
|
|
const offscreenImage = page.getByRole('img', { name: 'Gallery item 20' });
|
||
|
|
|
||
|
|
await offscreenImage.scrollIntoViewIfNeeded();
|
||
|
|
await expect(offscreenImage).toBeVisible();
|
||
|
|
|
||
|
|
const naturalWidth = await offscreenImage.evaluate(
|
||
|
|
(img) => img.naturalWidth
|
||
|
|
);
|
||
|
|
expect(naturalWidth).toBeGreaterThan(0);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
### Authentication with NextAuth.js / Auth.js
|
||
|
|
|
||
|
|
**Use when**: Testing login flows in Next.js apps using NextAuth.js or Auth.js. Use a setup project to authenticate once, then reuse `storageState` across tests.
|
||
|
|
**Avoid when**: Your app does not use session-based authentication.
|
||
|
|
|
||
|
|
**TypeScript**
|
||
|
|
```typescript
|
||
|
|
// playwright.config.ts (auth-specific excerpt)
|
||
|
|
import { defineConfig } from '@playwright/test';
|
||
|
|
|
||
|
|
export default defineConfig({
|
||
|
|
projects: [
|
||
|
|
{
|
||
|
|
name: 'setup',
|
||
|
|
testMatch: /auth\.setup\.ts/,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
name: 'authenticated',
|
||
|
|
use: { storageState: 'playwright/.auth/user.json' },
|
||
|
|
dependencies: ['setup'],
|
||
|
|
},
|
||
|
|
{
|
||
|
|
name: 'unauthenticated',
|
||
|
|
// No storageState -- tests run as logged-out user
|
||
|
|
testMatch: '**/*.unauth.spec.ts',
|
||
|
|
},
|
||
|
|
],
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// tests/auth.setup.ts
|
||
|
|
import { test as setup, expect } from '@playwright/test';
|
||
|
|
|
||
|
|
const authFile = 'playwright/.auth/user.json';
|
||
|
|
|
||
|
|
setup('authenticate via credentials', async ({ page }) => {
|
||
|
|
await page.goto('/login');
|
||
|
|
await page.getByLabel('Email').fill('test@example.com');
|
||
|
|
await page.getByLabel('Password').fill(process.env.TEST_PASSWORD!);
|
||
|
|
await page.getByRole('button', { name: 'Sign in' }).click();
|
||
|
|
|
||
|
|
// Wait for the redirect after successful login
|
||
|
|
await page.waitForURL('/dashboard');
|
||
|
|
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
|
||
|
|
|
||
|
|
// Save authentication state (cookies + localStorage)
|
||
|
|
await page.context().storageState({ path: authFile });
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// tests/dashboard.spec.ts
|
||
|
|
import { test, expect } from '@playwright/test';
|
||
|
|
|
||
|
|
// This test runs with the authenticated storageState from the setup project
|
||
|
|
test('authenticated user sees dashboard', async ({ page }) => {
|
||
|
|
await page.goto('/dashboard');
|
||
|
|
|
||
|
|
// No login redirect -- auth cookies are already set
|
||
|
|
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
|
||
|
|
await expect(page.getByText('test@example.com')).toBeVisible();
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
**JavaScript**
|
||
|
|
```javascript
|
||
|
|
// tests/auth.setup.js
|
||
|
|
const { test: setup, expect } = require('@playwright/test');
|
||
|
|
|
||
|
|
const authFile = 'playwright/.auth/user.json';
|
||
|
|
|
||
|
|
setup('authenticate via credentials', async ({ page }) => {
|
||
|
|
await page.goto('/login');
|
||
|
|
await page.getByLabel('Email').fill('test@example.com');
|
||
|
|
await page.getByLabel('Password').fill(process.env.TEST_PASSWORD);
|
||
|
|
await page.getByRole('button', { name: 'Sign in' }).click();
|
||
|
|
|
||
|
|
await page.waitForURL('/dashboard');
|
||
|
|
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
|
||
|
|
|
||
|
|
await page.context().storageState({ path: authFile });
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
```javascript
|
||
|
|
// tests/dashboard.spec.js
|
||
|
|
const { test, expect } = require('@playwright/test');
|
||
|
|
|
||
|
|
test('authenticated user sees dashboard', async ({ page }) => {
|
||
|
|
await page.goto('/dashboard');
|
||
|
|
|
||
|
|
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
|
||
|
|
await expect(page.getByText('test@example.com')).toBeVisible();
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
## Framework-Specific Tips
|
||
|
|
|
||
|
|
### Dev Server vs Production Build
|
||
|
|
|
||
|
|
| Scenario | Command | Trade-off |
|
||
|
|
|---|---|---|
|
||
|
|
| Local development | `npm run dev` | Hot reload, fast iteration, but does not test production behavior (minification, optimization, middleware edge runtime) |
|
||
|
|
| CI pipeline | `npm run build && npm run start` | Tests the real production bundle; catches build errors, middleware edge cases |
|
||
|
|
| Quick smoke test | `npm run dev` in CI with `reuseExistingServer: false` | Faster CI but misses production-only bugs |
|
||
|
|
|
||
|
|
**Recommendation**: Use `npm run dev` locally for fast feedback. Use `npm run build && npm run start` in CI to test the real production artifact.
|
||
|
|
|
||
|
|
### Server Components Cannot Be Tested in Isolation
|
||
|
|
|
||
|
|
Next.js server components run on the server and produce HTML. Playwright tests the rendered output. You cannot import and render a server component in a Playwright test. Instead:
|
||
|
|
|
||
|
|
1. Test the final rendered HTML through navigation (`page.goto`)
|
||
|
|
2. Verify that server-fetched data appears on the page
|
||
|
|
3. Use API route tests to validate the data layer separately
|
||
|
|
|
||
|
|
### Handling Next.js Redirects
|
||
|
|
|
||
|
|
Next.js redirects (configured in `next.config.js`, middleware, or `redirect()` in server actions) are transparent to Playwright. After `page.goto()`, check `page.url()` to verify the final destination.
|
||
|
|
|
||
|
|
### Turbopack Compatibility
|
||
|
|
|
||
|
|
If using Turbopack (`next dev --turbopack`), update your `webServer.command`:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
webServer: {
|
||
|
|
command: process.env.CI
|
||
|
|
? 'npm run build && npm run start'
|
||
|
|
: 'npx next dev --turbopack',
|
||
|
|
url: 'http://localhost:3000',
|
||
|
|
reuseExistingServer: !process.env.CI,
|
||
|
|
},
|
||
|
|
```
|
||
|
|
|
||
|
|
### Multiple webServer Entries (Next.js + API Backend)
|
||
|
|
|
||
|
|
If your Next.js app consumes a separate backend API:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
webServer: [
|
||
|
|
{
|
||
|
|
command: 'npm run dev:api',
|
||
|
|
url: 'http://localhost:4000/health',
|
||
|
|
reuseExistingServer: !process.env.CI,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
command: 'npm run dev',
|
||
|
|
url: 'http://localhost:3000',
|
||
|
|
reuseExistingServer: !process.env.CI,
|
||
|
|
},
|
||
|
|
],
|
||
|
|
```
|
||
|
|
|
||
|
|
## Anti-Patterns
|
||
|
|
|
||
|
|
| Don't Do This | Problem | Do This Instead |
|
||
|
|
|---|---|---|
|
||
|
|
| `await page.waitForTimeout(3000)` after navigation | Next.js client-side transitions are fast; arbitrary waits are wasteful and fragile | `await page.waitForURL('/expected-path')` or `await expect(locator).toBeVisible()` |
|
||
|
|
| Test `getServerSideProps` by importing and calling it directly | It depends on `context` (req/res) that Playwright cannot provide; it is a unit test concern | Navigate to the page and verify the rendered output |
|
||
|
|
| Mock your own API routes with `page.route()` | You are testing a fiction; your API handler may have bugs the mock hides | Let the real API route handle requests; mock only external services |
|
||
|
|
| Use `page.goto('http://localhost:3000/path')` with full URL | Breaks when port or host changes; ignores `baseURL` | Use `page.goto('/path')` and configure `baseURL` in config |
|
||
|
|
| Run `npm run build && npm run start` locally for every test run | Extremely slow feedback loop during development | Use `npm run dev` locally with `reuseExistingServer: true`; reserve production builds for CI |
|
||
|
|
| Test `next/image` by checking exact URL paths | `next/image` rewrites image URLs through `/_next/image`; paths change between dev and prod | Assert on `alt` text, visibility, `naturalWidth > 0`, and `srcset` existence |
|
||
|
|
| Skip `.env.test` and hardcode test values in config | Values scatter across config and test files; hard to maintain | Use `.env.test` for shared test values; `.env.test.local` for secrets |
|
||
|
|
| Test server actions by calling them as functions | Server actions are bound to the Next.js runtime; calling them outside a request context fails | Trigger server actions through their UI (form submissions, button clicks) |
|
||
|
|
| Ignore console errors during SSR tests | Hydration mismatches and server errors appear in the console and indicate real bugs | Listen for `page.on('console')` errors and fail the test if hydration warnings appear |
|
||
|
|
|
||
|
|
## Related
|
||
|
|
|
||
|
|
- [core/configuration.md](configuration.md) -- base Playwright configuration patterns including `webServer`
|
||
|
|
- [core/authentication.md](authentication.md) -- authentication setup projects and `storageState` reuse
|
||
|
|
- [core/api-testing.md](api-testing.md) -- testing API routes directly with `request` context
|
||
|
|
- [core/network-mocking.md](network-mocking.md) -- mocking external APIs that Next.js API routes call
|
||
|
|
- [core/when-to-mock.md](when-to-mock.md) -- when to mock vs hit real services
|
||
|
|
- [core/react.md](react.md) -- React-specific patterns that apply to Next.js client components
|
||
|
|
- [ci/ci-github-actions.md](../ci/ci-github-actions.md) -- CI setup with `npm run build` caching for Next.js
|