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. Playwright's webServer option can be configured 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, so instead of repeating raw selectors across every test you call methods on a dedicated page class. When a selector changes, you update one file rather than every test that uses it. For an ERP dashboard with 50+ tests across 10 user flows, POM is the difference between a maintainable suite and a brittle mess.
By logging in once in a setup project and saving the session to a file, every subsequent test can load that session instead of performing a full login flow. Without this, each test that requires authentication adds 2–3 seconds for the login step. In a 100-test suite, using saved auth state can save 3–5 minutes of CI time.
The primary culprits are hard-coded waits like sleep(1000), selectors tied to CSS classes that change during refactoring, tests that depend on execution order, and tests that share state. Playwright best practice is to use semantic selectors (getByRole, getByLabel, getByText), rely on auto-waiting assertions instead of page.waitForTimeout(), and reset test state in beforeEach so tests are order-independent.
React Testing Library runs in jsdom and is faster and simpler for most component tests. Playwright component testing mounts the component in a real browser, making it the right choice for scenarios that require actual browser behavior — such as drag-and-drop, canvas rendering, complex animations, or components that depend on browser APIs not available in jsdom.
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.
Playwright tests run against a real Chromium, Firefox, or WebKit browser — not a simulated environment. For Next.js apps with server-side rendering, this matters: Playwright sees exactly what the user sees, including the initial server-rendered HTML before hydration. It handles dynamic content, loading states, and navigation correctly through its built-in auto-waiting — `page.getByRole('button', {name: 'Submit'})` waits for the button to be visible and enabled before clicking. No more `waitForTimeout(1000)` hacks that make tests slow and brittle.
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('[email protected]')` scattered through every test, you write `loginPage.fillEmail('[email protected]')`. 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('[email protected]')
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.