Skip to content
ExponaBLOG
PLAYWRIGHT & BROWSER AUTOMATION · DAY 041 / 100

Testing Next.js apps with Playwright: a practical guide

Practical setup for App Router projects.

Expona Admin9 min read

When you deploy a Next.js application to production, your users interact with a fully built and running server. Your end-to-end tests should do the same. Testing Next.js apps with Playwright: a practical guide means setting up realistic conditions—starting the production build, handling server components properly, mocking at the right layer, and preserving authentication state across test runs.

In this guide, we'll walk through the key decisions and configurations that let your team run confident, maintainable tests against App Router projects without flaking or lengthy debug cycles.

webServer — Start next build && next start for realistic runs

The most critical difference between testing Next.js in development and production is how the framework renders and serves pages. When you run next dev, hot module replacement and incremental builds mask real bottlenecks and bugs that only surface under next build && next start.

Playwright's webServer configuration starts your production build once before all tests run, keeping tests fast and your local machine responsive.

typescript
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests/e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: 'html',
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
  },
  webServer: {
    command: 'npm run build && npm start',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
    timeout: 120000,
  },
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
  ],
});

Set reuseExistingServer: true during local development so you don't rebuild on every test run. In CI, set it to false to ensure a clean build each time. The timeout: 120000 gives your build pipeline breathing room; adjust based on your app's complexity.

This approach catches real issues: middleware bugs, environment variable misconfigurations, or static generation problems that dev mode skips over.

Server components — Test rendered output, not implementation

Next.js 13+ App Router heavily favors server components by default. Your tests should verify what users see in the DOM, not how data fetching happens internally. This distinction keeps tests stable as you refactor implementations.

When testing a page built with server components, you're testing the final HTML and any interactive client-side JavaScript. Playwright runs in the browser, so it naturally works with rendered output.

typescript
// tests/e2e/products.spec.ts
import { test, expect } from '@playwright/test';

test('products page displays list with prices', async ({ page }) => {
  // Navigate to the page built and served by Next.js
  await page.goto('/products');
  
  // Wait for and verify the rendered list
  await expect(page.locator('[data-testid="product-list"]')).toBeVisible();
  
  // Check that product cards exist and contain expected content
  const productCards = page.locator('[data-testid="product-card"]');
  await expect(productCards).toHaveCount(12);
  
  // Verify a specific product shows price and description
  const firstCard = productCards.first();
  await expect(firstCard.locator('[data-testid="product-price"]')).toContainText('$');
  await expect(firstCard.locator('[data-testid="product-name"]')).toBeVisible();
});

test('products can be sorted by price', async ({ page }) => {
  await page.goto('/products');
  
  // Interact with client-side sort control
  await page.selectOption('[data-testid="sort-select"]', 'price-asc');
  
  // Verify the DOM reflects the new order
  const prices = await page.locator('[data-testid="product-price"]').allTextContents();
  const numericPrices = prices.map(p => parseFloat(p.replace('$', '')));
  
  // Simple check: each price is <= the next
  for (let i = 0; i < numericPrices.length - 1; i++) {
    expect(numericPrices[i]).toBeLessThanOrEqual(numericPrices[i + 1]);
  }
});

Focus on user-facing behavior: visible text, clickable elements, form submissions, and navigation. Avoid testing internal state management or hook logic—those belong in unit tests. This keeps your E2E suite lean and resilient to refactors.

Mock external APIs — Route requests from the browser; mock server calls separately

Next.js server components often fetch from external APIs directly. You need to mock those calls, but at the right layer. Mocking at the HTTP level (via Playwright's network interception) only intercepts browser requests. Server-side fetches bypass the browser entirely.

The solution: mock server-side API calls using environment variables or a test-mode build, and use Playwright's route interception for any client-side fetches.

typescript

// app/products/page.tsx (server component)
async function getProducts() {
  // Uses environment variable; in tests, this points to mock/test data
  const apiUrl = process.env.API_URL || 'https://api.example.com';
  const res = await fetch(`${apiUrl}/products`, {
    headers: { Authorization: `Bearer ${process.env.API_KEY}` },
  });
  const products = await res.json();
  return products;
}

export default async function ProductsPage() {
  const products = await getProducts();
  return (
    <div data-testid="product-list">
      {products.map(p => (
        <ProductCard key={p.id} product={p} />
      ))}
    </div>
  );
}
typescript

// playwright.config.ts — mock server calls via environment variables
export default defineConfig({
  use: {
    baseURL: 'http://localhost:3000',
  },
  webServer: {
    command: 'npm run build && npm start',
    env: {
      // Point server-side fetches to a local mock or test API
      API_URL: 'http://localhost:3001',
      API_KEY: 'test-key-12345',
      NODE_ENV: 'production',
    },
  },
});
typescript

// tests/e2e/products.spec.ts — mock client-side requests via route interception
import { test, expect } from '@playwright/test';

test('client-side search mocks API responses', async ({ page }) => {
  // Intercept client-side API calls
  await page.route('**/api/search**', route => {
    route.abort('blockedbyclient');
  });
  
  // Or return a mock response
  await page.route('**/api/search**', route => {
    route.respond({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({
        results: [
          { id: 1, name: 'Widget Pro', price: 99.99 },
          { id: 2, name: 'Widget Lite', price: 49.99 },
        ],
      }),
    });
  });
  
  await page.goto('/products');
  await page.fill('[data-testid="search-input"]', 'widget');
  await page.click('[data-testid="search-button"]');
  
  await expect(page.locator('text=Widget Pro')).toBeVisible();
});

Keep your test server lightweight. If your real API is complex, stand up a small Node.js mock server on port 3001 that your tests point to, or use a service like Prism that auto-generates OpenAPI mocks.

Auth — Save storage state after login

Many test suites need to run as an authenticated user. Rather than logging in before every test, save the authenticated session state once and reuse it across tests. This speeds up your suite significantly and reduces flakiness from login failures.

typescript

// tests/e2e/auth.setup.ts — global setup that runs once before all tests
import { test as setup } from '@playwright/test';

const authFile = 'playwright/.auth/user.json';

setup('authenticate', async ({ page }) => {
  // Perform login flow
  await page.goto('/login');
  await page.fill('[data-testid="email-input"]', 'test@example.com');
  await page.fill('[data-testid="password-input"]', 'test-password-123');
  await page.click('[data-testid="login-button"]');
  
  // Wait for redirect to authenticated page
  await page.waitForURL('/dashboard');
  
  // Save storage state (cookies, localStorage, sessionStorage)
  await page.context().storageState({ path: authFile });
});
typescript

// playwright.config.ts — use saved auth state in all tests
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests/e2e',
  fullyParallel: true,
  webServer: {
    command: 'npm run build && npm start',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
  projects: [
    {
      name: 'chromium',
      use: {
        ...devices['Desktop Chrome'],
        // Load storage state before each test in this project
        storageState: 'playwright/.auth/user.json',
      },
    },
    {
      name: 'auth setup',
      testMatch: /.*\.setup\.ts/,
    },
  ],
  // Run auth setup before other tests
  fullyParallel: false,
});
typescript

// tests/e2e/dashboard.spec.ts — tests automatically use saved auth
import { test, expect } from '@playwright/test';

test('dashboard loads user data', async ({ page }) => {
  // Page context already has auth cookies/storage
  await page.goto('/dashboard');
  
  await expect(page.locator('[data-testid="user-name"]')).toContainText('Test User');
  await expect(page.locator('[data-testid="user-email"]')).toContainText('test@example.com');
});

test('user can navigate to settings', async ({ page }) => {
  await page.goto('/dashboard');
  await page.click('[data-testid="settings-link"]');
  
  await expect(page).toHaveURL('/dashboard/settings');
});

Keep your auth setup file separate and mark it so Playwright runs it as a prerequisite. This pattern scales: create multiple auth states (admin, user, guest) and reference them in different test projects.

Common mistakes

  • Using next dev for tests: Development mode hides real bugs. Always test against next build && next start.
  • Mocking server-side fetches at the browser level: Server components run before the page reaches the browser. Mock via environment variables or a local test API instead.
  • Testing implementation details: Avoid checking internal state or hook calls. Focus on what the user sees and interacts with.
  • Re-logging in for each test: Save and reuse auth state. Logging in repeatedly multiplies test time and failure points.
  • Ignoring the build step: Skipping next build during local development tempts you to test broken code. Include it in your config and workflow.
  • Not handling Next.js redirects: Server components and middleware often redirect. Use page.waitForURL() to wait for the final destination.

Key takeaways

  • Configure webServer to run next build && npm start so tests run against production-like conditions.
  • Test the rendered DOM output of server components; don't attempt to test their internals.
  • Mock server-side API calls via environment variables pointing to a test API; use route interception only for client-side fetches.
  • Save authentication state after login and reuse it across tests to speed up your suite and reduce flakiness.
  • Always verify your app builds cleanly before running tests; catch config errors early.
  • Use data-testid attributes to write tests that survive styling and markup changes.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles