Unit tests verify components in isolation. E2E tests verify that your actual application works the way users experience it — with a real browser, real navigation, and real network requests. Playwright has become the dominant E2E testing tool for Next.js in 2025: it's fast (up to 2x faster than competitors in benchmarks), auto-waits for elements without manual sleep calls, and handles Next.js's server components, streaming, and hydration correctly. I've run Playwright tests on a Next.js ERP dashboard and reduced test suite time by 40% compared to Cypress by switching to Playwright's parallel execution.
blog.posts.e2eTestingPlaywright.content.section1Content
The most important Playwright setup decision: test against a production build, not the development server. The development server includes hot reloading middleware, source maps, and development-only error overlays that don't exist in production. Tests that pass in development can fail in production if a build-only optimization or different code path is triggered. Configure Playwright's `webServer` option to build and serve a production Next.js build before tests run.
The Page Object Model (POM) wraps Playwright's low-level API in a domain-specific interface. Instead of `page.getByLabel('Email').fill('user@example.com')` scattered through every test, you write `loginPage.fillEmail('user@example.com')`. When the email input's label changes, you update one file (the LoginPage class) instead of every test that uses it. For an ERP dashboard with 50+ tests across 10 user flows, POM is the difference between a maintainable test suite and a brittle mess.
Playwright E2E Test Architecture for Next.js:
────────────────────────────────────────────────────────────
playwright.config.ts
├── webServer: { command: 'next build && next start', port: 3000 }
│ ↑ test against PRODUCTION build, not dev server
├── workers: 4 (parallel contexts, each isolated)
├── reporter: ['html', 'github']
└── projects:
├── setup: authenticate once, save storageState
├── chromium: { use: { storageState: 'auth.json' } }
└── firefox: { use: { storageState: 'auth.json' } }
Test execution flow:
setup project runs login flow → saves auth.json
│
▼
chromium / firefox projects load auth.json (skip login)
│
├── worker 1: invoice.spec.ts
├── worker 2: customer.spec.ts
├── worker 3: dashboard.spec.ts
└── worker 4: auth.spec.ts
│
▼
All tests run in parallel (~40% faster than serial)
Failed tests: screenshot + trace recorded for debugging
Selector priority (most to least resilient):
1. getByRole() — semantic, survives CSS changes ✓
2. getByLabel() — form accessibility label ✓
3. getByText() — visible text content ✓
4. getByTestId() — data-testid attribute ✓
5. CSS selectors — breaks on style refactoring ✗
6. XPath — brittle, avoid ✗From my experience running Playwright on a Next.js ERP app: set up auth state persistence using Playwright's storageState feature. Log in once in a setup project, save the session to a file, and load that session in every test that needs authentication. Without this, every test that requires login (which is almost all of them in an ERP app) performs a full login flow — adding 2-3 seconds per test. With saved auth state, authenticated tests start with the session already active. In a 100-test suite, this saves 3-5 minutes of CI time.
Flaky tests are the main reason teams abandon E2E testing. The causes: hard-coded waits (sleep(1000)), selectors based on CSS classes that change during refactoring, tests that depend on execution order, and tests that share state. The Playwright best practices: prefer semantic selectors (getByRole, getByLabel, getByText) over CSS or XPath selectors that break on styling changes; use `expect(page).toHaveURL()` instead of checking URLs manually; never use `page.waitForTimeout()`; reset test state in `beforeEach` so tests are order-independent.
Next.js 13+ App Router with server components and Suspense streaming means page content loads progressively. Playwright handles this correctly because it uses real browser behavior — it will wait for network requests to complete and DOM to stabilize before assertions, provided you use auto-waiting assertions. The key: use `expect(page.getByText('Invoice List')).toBeVisible()` rather than checking the DOM immediately after navigation. Playwright's auto-waiter retries the assertion for up to 5 seconds by default, which covers most streaming scenarios.
// playwright.config.ts — production-ready config
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 ? 4 : undefined,
reporter: [['html'], ['github']],
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry', // record traces on failures
screenshot: 'only-on-failure',
},
projects: [
{ name: 'setup', testMatch: '**/auth.setup.ts' },
{ name: 'chromium', use: { ...devices['Desktop Chrome'],
storageState: 'playwright/.auth/user.json' },
dependencies: ['setup'] },
],
webServer: {
command: 'npm run build && npm start',
port: 3000,
reuseExistingServer: !process.env.CI,
},
})
// auth.setup.ts — login once, save state
import { test as setup } from '@playwright/test'
setup('authenticate', async ({ page }) => {
await page.goto('/login')
await page.getByLabel('Email').fill('admin@example.com')
await page.getByLabel('Password').fill(process.env.TEST_PASSWORD!)
await page.getByRole('button', { name: 'Sign in' }).click()
await page.waitForURL('/dashboard')
await page.context().storageState({ path: 'playwright/.auth/user.json' })
})
// invoice.spec.ts — Page Object Model pattern
class InvoicePage {
constructor(private readonly page: Page) {}
async goto() { await this.page.goto('/invoices') }
async createInvoice(amount: number) {
await this.page.getByRole('button', { name: 'New Invoice' }).click()
await this.page.getByLabel('Amount').fill(String(amount))
await this.page.getByRole('button', { name: 'Save' }).click()
}
async expectSuccess() {
await expect(this.page.getByText('Invoice created')).toBeVisible()
}
}
test('creates an invoice', async ({ page }) => {
const invoicePage = new InvoicePage(page)
await invoicePage.goto()
await invoicePage.createInvoice(5000000)
await invoicePage.expectSuccess()
})Playwright runs headlessly in CI out of the box. The GitHub Actions workflow: checkout code, install dependencies (including Playwright browsers with `npx playwright install --with-deps`), build the Next.js app, and run `npx playwright test`. Playwright reports failures as GitHub Actions annotations — failed tests appear inline in the PR diff. Enable Playwright's HTML reporter and upload the report as a GitHub Actions artifact — on failure, download and open locally to see screenshots and traces of each failure.
A common Playwright anti-pattern: testing that a button has a specific CSS class, that a React state variable has a certain value, or that a specific DOM node exists. These tests break whenever you refactor the implementation, even when the user experience is unchanged. Write tests from the user's perspective: 'When I click Submit, I see a success message' — not 'when the form's submit handler is called, the successState variable is true'. Tests that describe behavior are resilient to refactoring. Tests that describe implementation break on every non-functional change.
Playwright runs tests in parallel by default — each test file gets its own browser context, and multiple contexts run simultaneously across worker processes. Configure `workers` in playwright.config.ts to control parallelism. For a CI machine with 4 cores, `workers: 4` runs 4 browser contexts in parallel. Playwright's isolation guarantee: each test context has its own cookies, localStorage, and network state — no cross-test contamination. For tests that must run in a specific order (e.g., create, then edit, then delete), put them in the same spec file and use test.describe.serial.
Playwright also supports component testing — rendering individual React components in a real browser, in isolation. This is useful for UI components that are complex to test with jsdom (React Testing Library's environment): drag-and-drop, canvas rendering, complex animations, or components that depend on browser APIs. Component tests in Playwright mount the component with a fixture server, so you can test the actual rendered browser output. For most components, React Testing Library is faster and simpler — use Playwright component testing only when you need real browser behavior.