Skip to content

Latest commit

 

History

History
1013 lines (791 loc) · 35.1 KB

File metadata and controls

1013 lines (791 loc) · 35.1 KB

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, core/locators.md

Quick Reference

# 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

// 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

// 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.

# .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
# .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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// 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',
    },
  ],
});
// 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 });
});
// 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

// 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 });
});
// 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:

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:

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