Understand what E2E tests cover, when to write them, how Playwright automates real browsers, and how to install and configure Playwright for a Next.js project.
Unit tests verify logic. Integration tests verify module wiring and API contracts. End-to-end tests verify that a real user can accomplish a real goal in a real browser. They navigate to a URL, fill in forms, click buttons, and assert on what appears on screen — using the same browser engines your users use.
Playwright is the modern standard for E2E testing: it supports Chromium, Firefox, and WebKit from a single API, runs tests in parallel, has first-class TypeScript support, and includes a test recorder, trace viewer, and screenshot comparator. This lesson gets you from zero to your first passing Playwright test against a Next.js application.
E2E tests are the only tests that exercise the complete system: browser rendering, client-side JavaScript, network requests, server responses, and database reads all in one flow.
1What only E2E tests verify:
2- CSS-dependent interactions (dropdown opens when hover, button disabled state)
3- Browser navigation (history.pushState, back button, URL updates)
4- Multi-page flows (register → verify email → login → dashboard)
5- Client-side form validation (HTML5 required, pattern, custom validators)
6- Real network timing (loading spinners, optimistic updates)
7- Cross-browser layout differences (flex/grid rendering)
8- Third-party widget integration (Stripe Elements, Google reCAPTCHA)
9- Accessibility via real keyboard navigation
10
11What E2E tests should NOT replace:
12- Branch-level business logic (use unit tests)
13- API request/response contracts (use integration tests)
14- Every error state (too slow — only the critical error paths)The E2E test rule of thumb: write an E2E test for every user-facing flow that, if broken, would cause a user to be unable to accomplish a core task. Register, login, purchase, complete a lesson — these are E2E tests. Whether a discount calculation rounds to 2 decimal places — that is a unit test.
1// GOOD E2E test scope:
2test('user can register, verify email, and access dashboard', async ({ page }) => {
3 await page.goto('/register');
4 await page.fill('[name="email"]', '[email protected]');
5 await page.fill('[name="password"]', 'SecurePass1!');
6 await page.click('button[type="submit"]');
7 await expect(page).toHaveURL('/verify-email-sent');
8 // ... etc
9});
10
11// WRONG E2E test scope (should be a unit test):
12test('formats price with 2 decimal places', async ({ page }) => {
13 await page.goto('/courses');
14 await expect(page.locator('.price').first()).toContainText('$49.99');
15 // Testing pure formatting logic via a real browser is 1000x slower than a unit test
16});1# Install Playwright with all browsers
2npm init playwright@latest
3# OR for existing project:
4npm install --save-dev @playwright/test
5npx playwright install # downloads Chromium, Firefox, WebKit
6npx playwright install-deps # Linux only: install system dependencies1// playwright.config.ts
2import { defineConfig, devices } from '@playwright/test';
3
4export default defineConfig({
5 testDir: './e2e',
6 testMatch: '**/*.spec.ts',
7 fullyParallel: true,
8 forbidOnly: !!process.env['CI'], // fail if test.only left in code
9 retries: process.env['CI'] ? 2 : 0, // retry flaky tests in CI only
10 workers: process.env['CI'] ? 1 : undefined,
11 timeout: 30_000, // 30s per test
12 expect: { timeout: 5_000 }, // 5s for assertions
13
14 reporter: [
15 ['html', { outputFolder: 'playwright-report' }],
16 ['junit', { outputFile: 'test-results/e2e-results.xml' }],
17 ],
18
19 use: {
20 baseURL: 'http://localhost:3000',
21 trace: 'on-first-retry', // capture trace on retry
22 screenshot: 'only-on-failure',
23 video: 'on-first-retry',
24 },
25
26 projects: [
27 { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
28 { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
29 { name: 'webkit', use: { ...devices['Desktop Safari'] } },
30 { name: 'mobile-chrome', use: { ...devices['Pixel 5'] } },
31 { name: 'mobile-safari', use: { ...devices['iPhone 13'] } },
32 ],
33
34 // Auto-start the app before running tests
35 webServer: {
36 command: 'npm run dev',
37 url: 'http://localhost:3000',
38 reuseExistingServer: !process.env['CI'],
39 timeout: 120_000,
40 },
41});Playwright tests use the test and expect from @playwright/test. The page fixture provides browser automation.
1// e2e/home.spec.ts
2import { test, expect } from '@playwright/test';
3
4test('home page loads with correct heading', async ({ page }) => {
5 await page.goto('/');
6
7 // Assert on the page title (browser tab title)
8 await expect(page).toHaveTitle(/DeveloperBootstrap/);
9
10 // Assert on visible heading text
11 await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
12
13 // Assert on navigation links
14 await expect(page.getByRole('link', { name: 'Courses' })).toBeVisible();
15 await expect(page.getByRole('link', { name: 'Sign In' })).toBeVisible();
16});
17
18test('courses page lists available courses', async ({ page }) => {
19 await page.goto('/courses');
20
21 // Wait for courses to load (async data fetch)
22 await expect(page.getByRole('article')).toHaveCount({ minimum: 1 });
23
24 // Each course card has a title and enrol button
25 const firstCard = page.getByRole('article').first();
26 await expect(firstCard.getByRole('heading')).toBeVisible();
27 await expect(firstCard.getByRole('button', { name: /enrol/i })).toBeVisible();
28});
29
30test('navigation links are keyboard accessible', async ({ page }) => {
31 await page.goto('/');
32 await page.keyboard.press('Tab');
33 // First focusable element should be the skip-to-main-content link
34 await expect(page.getByText('Skip to main content')).toBeFocused();
35});1# Run all tests
2npx playwright test
3
4# Run in headed mode (see the browser)
5npx playwright test --headed
6
7# Run a specific file
8npx playwright test e2e/home.spec.ts
9
10# Run with UI mode (interactive)
11npx playwright test --ui
12
13# View the HTML report after a run
14npx playwright show-reportPlaywright's codegen tool opens a browser and records your interactions as test code.
1# Start the recorder against your app
2npx playwright codegen http://localhost:3000
3
4# Record against a specific browser
5npx playwright codegen --browser=firefox http://localhost:3000
6
7# Record and save directly to a file
8npx playwright codegen --output=e2e/recorded-login.spec.ts http://localhost:3000
9
10# Record mobile viewport
11npx playwright codegen --device='iPhone 13' http://localhost:3000Codegen generates code like this:
1import { test, expect } from '@playwright/test';
2
3test('test', async ({ page }) => {
4 await page.goto('http://localhost:3000/');
5 await page.getByRole('link', { name: 'Sign In' }).click();
6 await page.getByLabel('Email').fill('[email protected]');
7 await page.getByLabel('Password').fill('SecurePass1!');
8 await page.getByRole('button', { name: 'Sign in' }).click();
9 await expect(page).toHaveURL('http://localhost:3000/dashboard');
10});After recording, always:
baseURL-relative pathspage.goto('http://localhost:3000/...') with page.goto('/...')'test' to a descriptive behaviour statementbeforeEach or a helperCodegen is a starting point, not a finished test. Treat generated code as scaffolding that needs review and cleanup.
Understanding Playwright's test structure and built-in fixtures enables efficient, reliable tests.
1import { test, expect, type Page } from '@playwright/test';
2
3// test.describe — groups related tests (like Jest describe)
4test.describe('Authentication', () => {
5
6 // test.beforeEach — runs before each test in this describe block
7 test.beforeEach(async ({ page }) => {
8 await page.goto('/login');
9 });
10
11 // test — individual test case
12 // Fixtures (page, browser, context) are injected automatically
13 test('shows error for invalid credentials', async ({ page }) => {
14 await page.getByLabel('Email').fill('[email protected]');
15 await page.getByLabel('Password').fill('wrongpassword');
16 await page.getByRole('button', { name: 'Sign in' }).click();
17
18 await expect(page.getByRole('alert')).toContainText('Invalid credentials');
19 await expect(page).toHaveURL('/login'); // still on login page
20 });
21
22 // test.only — run only this test during debugging
23 // NEVER commit test.only to source control
24 test.only('debug this specific test', async ({ page }) => { ... });
25
26 // test.skip — skip this test
27 test.skip('pending implementation', async ({ page }) => { ... });
28
29 // test.fixme — marks as broken, will be fixed
30 test.fixme('known flaky test', async ({ page }) => { ... });
31});
32
33// Key Playwright built-in fixtures:
34// page — a fresh browser page (tab) for each test
35// browser — the browser instance (shared across tests in same worker)
36// context — a browser context (isolated cookie/storage scope)
37// request — for making direct API calls without a browser
38
39// Parallel test execution:
40// By default each test file runs in its own worker
41// test.describe.parallel({ ... }) runs tests in a describe in parallel
42// test.describe.serial({ ... }) forces sequential execution within a describeComplete Playwright installation, configuration for a Next.js 14 app, and three foundational tests covering navigation, heading verification, and keyboard accessibility.
1// playwright.config.ts
2import { defineConfig, devices } from '@playwright/test';
3
4export default defineConfig({
5 testDir: './e2e',
6 fullyParallel: true,
7 forbidOnly: !!process.env['CI'],
8 retries: process.env['CI'] ? 2 : 0,
9 workers: process.env['CI'] ? 2 : undefined,
10 timeout: 30_000,
11
12 reporter: [
13 ['list'],
14 ['html', { outputFolder: 'playwright-report', open: 'never' }],
15 ],
16
17 use: {
18 baseURL: process.env['PLAYWRIGHT_BASE_URL'] ?? 'http://localhost:3000',
19 trace: 'on-first-retry',
20 screenshot: 'only-on-failure',
21 },
22
23 projects: [
24 { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
25 {
26 name: 'mobile',
27 use: { ...devices['iPhone 13'] },
28 testMatch: '**/mobile.spec.ts',
29 },
30 ],
31
32 webServer: {
33 command: 'npm run build && npm run start',
34 url: 'http://localhost:3000',
35 reuseExistingServer: !process.env['CI'],
36 timeout: 180_000,
37 env: { NODE_ENV: 'test' },
38 },
39});
40
41// e2e/home.spec.ts
42import { test, expect } from '@playwright/test';
43
44test.describe('Home Page', () => {
45 test.beforeEach(async ({ page }) => {
46 await page.goto('/');
47 });
48
49 test('has correct page title', async ({ page }) => {
50 await expect(page).toHaveTitle(/DeveloperBootstrap/);
51 });
52
53 test('displays the main hero heading', async ({ page }) => {
54 const heading = page.getByRole('heading', { level: 1 });
55 await expect(heading).toBeVisible();
56 await expect(heading).not.toBeEmpty();
57 });
58
59 test('shows navigation with courses and sign-in links', async ({ page }) => {
60 await expect(page.getByRole('navigation')).toBeVisible();
61 await expect(page.getByRole('link', { name: /courses/i })).toBeVisible();
62 await expect(page.getByRole('link', { name: /sign in/i })).toBeVisible();
63 });
64
65 test('courses link navigates to the courses listing page', async ({ page }) => {
66 await page.getByRole('link', { name: /courses/i }).click();
67 await expect(page).toHaveURL('/courses');
68 await expect(page.getByRole('heading', { name: /courses/i })).toBeVisible();
69 });
70
71 test('page is keyboard navigable — first tab reaches skip link', async ({ page }) => {
72 await page.keyboard.press('Tab');
73 const focused = page.locator(':focus');
74 // The first focusable element should assist keyboard users
75 await expect(focused).toBeVisible();
76 });
77});
78
79// e2e/courses.spec.ts
80import { test, expect } from '@playwright/test';
81
82test.describe('Courses Page', () => {
83 test('loads course cards with title and enrol button', async ({ page }) => {
84 await page.goto('/courses');
85
86 // Wait for at least one course to appear
87 const cards = page.getByRole('article');
88 await expect(cards.first()).toBeVisible({ timeout: 10_000 });
89
90 const firstCard = cards.first();
91 await expect(firstCard.getByRole('heading')).toBeVisible();
92 });
93
94 test('filter by BEGINNER level shows only beginner courses', async ({ page }) => {
95 await page.goto('/courses');
96 await page.getByRole('button', { name: /filter/i }).click();
97 await page.getByRole('option', { name: 'Beginner' }).click();
98
99 // All visible course levels should be BEGINNER
100 const levels = page.getByTestId('course-level');
101 const count = await levels.count();
102 for (let i = 0; i < count; i++) {
103 await expect(levels.nth(i)).toContainText('Beginner');
104 }
105 });
106});The webServer config auto-starts the Next.js production build before tests run — using the production build (not dev) catches build-time errors. The forbidOnly: !!CI setting fails the CI run if anyone accidentally committed a test.only. Mobile tests are scoped to files matching mobile.spec.ts via testMatch to avoid running all tests on mobile unnecessarily. The courses level-filter test demonstrates iterating over multiple elements to assert a property holds for all of them.
The next lesson covers Playwright's locator API in depth: getByRole, getByLabel, getByText, getByTestId, and the semantic hierarchy that makes tests resilient to UI changes.
Your progress is saved in this browser, on this device only.