mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-12 17:22:38 +02:00
13 KiB
13 KiB
Next.js Testing Patterns
Table of Contents
- Setup
- App Router Patterns
- Pages Router Patterns
- Dynamic Routes
- API Routes
- Middleware Testing
- Hydration Testing
- next/image Testing
- NextAuth.js Authentication
- Tips
- Anti-Patterns
- Related
When to use: Testing Next.js applications with App Router, Pages Router, API routes, middleware, SSR, dynamic routes, and server components. Prerequisites: configuration.md, locators.md
Setup
Configuration with webServer
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
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
Next.js loads .env.test when NODE_ENV=test:
# .env.test (commit this)
NEXT_PUBLIC_API_URL=http://localhost:3000/api
DATABASE_URL=postgresql://localhost:5432/test_db
# .env.test.local (gitignored)
NEXTAUTH_SECRET=test-secret-local
App Router Patterns
Server Component Content
test('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();
});
Loading States with Streaming
test('loading state during data streaming', async ({ page }) => {
await page.route('**/api/stats', async (route) => {
await new Promise((r) => setTimeout(r, 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();
});
Nested Layouts
test('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();
});
Pages Router Patterns
SSR with getServerSideProps
test('page with getServerSideProps renders 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+/);
});
Static Generation with getStaticProps
test('static page 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();
});
Dynamic Routes
Slug Parameters
test('dynamic [slug] renders correct content', async ({ page }) => {
await page.goto('/blog/testing-guide');
await expect(page.getByRole('heading', { level: 1 })).toContainText('Testing Guide');
await expect(page.getByText('Page not found')).toBeHidden();
});
test('non-existent slug shows 404', async ({ page }) => {
const response = await page.goto('/blog/nonexistent-post');
expect(response?.status()).toBe(404);
await expect(page.getByRole('heading', { name: '404' })).toBeVisible();
});
Catch-All Routes
test('catch-all 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();
});
Query Parameters
test('query parameters filter content', 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));
});
API Routes
Direct API Testing
test('GET /api/products returns 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[0]).toHaveProperty('id');
expect(body.products[0]).toHaveProperty('name');
});
test('POST /api/products creates item', async ({ request }) => {
const response = await request.post('/api/products', {
data: { name: 'Test Product', price: 29.99 },
});
expect(response.status()).toBe(201);
const body = await response.json();
expect(body.product.name).toBe('Test Product');
});
test('POST /api/products validates 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' }));
});
API Through UI
test('form submission calls API', 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/**');
});
Middleware Testing
Auth Redirects
test('unauthenticated user redirected to login', async ({ page }) => {
await page.goto('/dashboard');
expect(page.url()).toContain('/login');
await expect(page.getByRole('heading', { name: 'Sign in' })).toBeVisible();
});
test('redirect preserves 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');
});
Security Headers
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');
});
Locale Rewrites
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();
});
Hydration Testing
Console Error Detection
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('/');
await page.getByRole('button', { name: 'Get started' }).click();
const hydrationErrors = consoleErrors.filter(
(e) =>
e.includes('Hydration') ||
e.includes('hydration') ||
e.includes('did not match')
);
expect(hydrationErrors).toEqual([]);
});
Interactive Elements After Hydration
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');
});
next/image Testing
test('hero image loads with srcset', 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', 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: HTMLImageElement) => img.naturalWidth
);
expect(naturalWidth).toBeGreaterThan(0);
});
NextAuth.js Authentication
Setup Project
// playwright.config.ts
export default defineConfig({
projects: [
{ name: 'setup', testMatch: /auth\.setup\.ts/ },
{
name: 'authenticated',
use: { storageState: 'playwright/.auth/user.json' },
dependencies: ['setup'],
},
{ name: 'unauthenticated', testMatch: '**/*.unauth.spec.ts' },
],
});
Auth Setup
// 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();
await page.waitForURL('/dashboard');
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
await page.context().storageState({ path: authFile });
});
Authenticated Tests
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();
});
Tips
Dev Server vs Production Build
| Scenario | Command | Trade-off |
|---|---|---|
| Local development | npm run dev |
Fast iteration, no production behavior |
| CI pipeline | npm run build && npm run start |
Tests real production bundle |
Turbopack
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
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) |
Arbitrary waits are fragile | await page.waitForURL('/path') or await expect(locator).toBeVisible() |
Test getServerSideProps directly |
Depends on req/res context | Navigate to page and verify rendered output |
| Mock your own API routes | Hides real API bugs | Let real API handle requests; mock only external services |
page.goto('http://localhost:3000/path') |
Breaks when port changes | Use page.goto('/path') with baseURL |
Run npm run build locally for every test |
Extremely slow | Use npm run dev locally with reuseExistingServer: true |
Test next/image by checking exact URLs |
Paths change between dev/prod | Assert on alt, visibility, naturalWidth > 0, srcset |
| Test server actions by calling as functions | Server actions need Next.js runtime | Trigger through UI (forms, buttons) |
Related
- configuration.md -- Playwright configuration including
webServer - authentication.md -- authentication setup and
storageState - api-testing.md -- testing API routes with
requestcontext - react.md -- React patterns for Next.js client components