Playwright provides reliable end-to-end testing. Here's how to write effective E2E tests.
Setup and Configuration#
1// playwright.config.ts
2import { defineConfig, devices } from '@playwright/test';
3
4export default defineConfig({
5 testDir: './tests/e2e',
6 fullyParallel: true,
7 forbidOnly: !!process.env.CI,
8 retries: process.env.CI ? 2 : 0,
9 workers: process.env.CI ? 1 : undefined,
10 reporter: [
11 ['html'],
12 ['junit', { outputFile: 'results.xml' }],
13 ],
14 use: {
15 baseURL: 'http://localhost:3000',
16 trace: 'on-first-retry',
17 screenshot: 'only-on-failure',
18 video: 'retain-on-failure',
19 },
20 projects: [
21 {
22 name: 'chromium',
23 use: { ...devices['Desktop Chrome'] },
24 },
25 {
26 name: 'firefox',
27 use: { ...devices['Desktop Firefox'] },
28 },
29 {
30 name: 'webkit',
31 use: { ...devices['Desktop Safari'] },
32 },
33 {
34 name: 'Mobile Chrome',
35 use: { ...devices['Pixel 5'] },
36 },
37 {
38 name: 'Mobile Safari',
39 use: { ...devices['iPhone 12'] },
40 },
41 ],
42 webServer: {
43 command: 'npm run dev',
44 url: 'http://localhost:3000',
45 reuseExistingServer: !process.env.CI,
46 },
47});Basic Tests#
1// tests/e2e/home.spec.ts
2import { test, expect } from '@playwright/test';
3
4test.describe('Home Page', () => {
5 test('has correct title', async ({ page }) => {
6 await page.goto('/');
7 await expect(page).toHaveTitle(/My App/);
8 });
9
10 test('navigation works', async ({ page }) => {
11 await page.goto('/');
12
13 await page.click('text=About');
14 await expect(page).toHaveURL('/about');
15
16 await page.click('text=Contact');
17 await expect(page).toHaveURL('/contact');
18 });
19
20 test('search functionality', async ({ page }) => {
21 await page.goto('/');
22
23 await page.fill('[placeholder="Search..."]', 'test query');
24 await page.press('[placeholder="Search..."]', 'Enter');
25
26 await expect(page).toHaveURL('/search?q=test+query');
27 await expect(page.locator('.search-results')).toBeVisible();
28 });
29});Page Object Model#
1// tests/e2e/pages/LoginPage.ts
2import { Page, Locator, expect } from '@playwright/test';
3
4export class LoginPage {
5 readonly page: Page;
6 readonly emailInput: Locator;
7 readonly passwordInput: Locator;
8 readonly submitButton: Locator;
9 readonly errorMessage: Locator;
10
11 constructor(page: Page) {
12 this.page = page;
13 this.emailInput = page.getByLabel('Email');
14 this.passwordInput = page.getByLabel('Password');
15 this.submitButton = page.getByRole('button', { name: 'Sign in' });
16 this.errorMessage = page.getByRole('alert');
17 }
18
19 async goto() {
20 await this.page.goto('/login');
21 }
22
23 async login(email: string, password: string) {
24 await this.emailInput.fill(email);
25 await this.passwordInput.fill(password);
26 await this.submitButton.click();
27 }
28
29 async expectError(message: string) {
30 await expect(this.errorMessage).toContainText(message);
31 }
32
33 async expectLoggedIn() {
34 await expect(this.page).toHaveURL('/dashboard');
35 }
36}
37
38// tests/e2e/pages/DashboardPage.ts
39export class DashboardPage {
40 readonly page: Page;
41 readonly userMenu: Locator;
42 readonly logoutButton: Locator;
43
44 constructor(page: Page) {
45 this.page = page;
46 this.userMenu = page.getByTestId('user-menu');
47 this.logoutButton = page.getByRole('button', { name: 'Logout' });
48 }
49
50 async logout() {
51 await this.userMenu.click();
52 await this.logoutButton.click();
53 }
54}
55
56// Usage in tests
57import { test } from '@playwright/test';
58import { LoginPage } from './pages/LoginPage';
59import { DashboardPage } from './pages/DashboardPage';
60
61test.describe('Authentication', () => {
62 test('successful login', async ({ page }) => {
63 const loginPage = new LoginPage(page);
64 await loginPage.goto();
65 await loginPage.login('user@example.com', 'password123');
66 await loginPage.expectLoggedIn();
67 });
68
69 test('failed login shows error', async ({ page }) => {
70 const loginPage = new LoginPage(page);
71 await loginPage.goto();
72 await loginPage.login('user@example.com', 'wrongpassword');
73 await loginPage.expectError('Invalid credentials');
74 });
75});Fixtures and Setup#
1// tests/e2e/fixtures.ts
2import { test as base, expect } from '@playwright/test';
3import { LoginPage } from './pages/LoginPage';
4import { DashboardPage } from './pages/DashboardPage';
5
6// Custom fixtures
7type MyFixtures = {
8 loginPage: LoginPage;
9 dashboardPage: DashboardPage;
10 authenticatedPage: Page;
11};
12
13export const test = base.extend<MyFixtures>({
14 loginPage: async ({ page }, use) => {
15 const loginPage = new LoginPage(page);
16 await use(loginPage);
17 },
18
19 dashboardPage: async ({ page }, use) => {
20 const dashboardPage = new DashboardPage(page);
21 await use(dashboardPage);
22 },
23
24 authenticatedPage: async ({ page }, use) => {
25 // Login before test
26 await page.goto('/login');
27 await page.fill('[name="email"]', 'test@example.com');
28 await page.fill('[name="password"]', 'password');
29 await page.click('button[type="submit"]');
30 await page.waitForURL('/dashboard');
31
32 await use(page);
33
34 // Cleanup after test
35 await page.goto('/logout');
36 },
37});
38
39export { expect };
40
41// Usage
42import { test, expect } from './fixtures';
43
44test('authenticated user can view dashboard', async ({ authenticatedPage }) => {
45 await expect(authenticatedPage.locator('h1')).toContainText('Dashboard');
46});API Mocking#
1// Mock API responses
2test('displays products from API', async ({ page }) => {
3 // Mock API before navigation
4 await page.route('/api/products', async (route) => {
5 await route.fulfill({
6 status: 200,
7 contentType: 'application/json',
8 body: JSON.stringify([
9 { id: 1, name: 'Product 1', price: 100 },
10 { id: 2, name: 'Product 2', price: 200 },
11 ]),
12 });
13 });
14
15 await page.goto('/products');
16
17 await expect(page.locator('.product-card')).toHaveCount(2);
18 await expect(page.locator('text=Product 1')).toBeVisible();
19});
20
21// Mock API error
22test('handles API error', async ({ page }) => {
23 await page.route('/api/products', async (route) => {
24 await route.fulfill({
25 status: 500,
26 body: JSON.stringify({ error: 'Server error' }),
27 });
28 });
29
30 await page.goto('/products');
31
32 await expect(page.locator('.error-message')).toContainText('Failed to load');
33});
34
35// Intercept and modify requests
36test('modifies request headers', async ({ page }) => {
37 await page.route('/api/**', async (route) => {
38 await route.continue({
39 headers: {
40 ...route.request().headers(),
41 'X-Test-Header': 'test-value',
42 },
43 });
44 });
45
46 await page.goto('/dashboard');
47});Visual Testing#
1// Screenshot comparison
2test('homepage looks correct', async ({ page }) => {
3 await page.goto('/');
4
5 // Full page screenshot
6 await expect(page).toHaveScreenshot('homepage.png', {
7 fullPage: true,
8 });
9});
10
11// Element screenshot
12test('header looks correct', async ({ page }) => {
13 await page.goto('/');
14
15 const header = page.locator('header');
16 await expect(header).toHaveScreenshot('header.png');
17});
18
19// With threshold
20test('chart renders correctly', async ({ page }) => {
21 await page.goto('/dashboard');
22
23 await expect(page.locator('.chart')).toHaveScreenshot('chart.png', {
24 maxDiffPixels: 100,
25 threshold: 0.2,
26 });
27});
28
29// Mask dynamic content
30test('page with dynamic content', async ({ page }) => {
31 await page.goto('/profile');
32
33 await expect(page).toHaveScreenshot('profile.png', {
34 mask: [
35 page.locator('.timestamp'),
36 page.locator('.avatar'),
37 ],
38 });
39});Accessibility Testing#
1import { test, expect } from '@playwright/test';
2import AxeBuilder from '@axe-core/playwright';
3
4test.describe('Accessibility', () => {
5 test('home page passes accessibility checks', async ({ page }) => {
6 await page.goto('/');
7
8 const results = await new AxeBuilder({ page }).analyze();
9
10 expect(results.violations).toEqual([]);
11 });
12
13 test('login form is accessible', async ({ page }) => {
14 await page.goto('/login');
15
16 const results = await new AxeBuilder({ page })
17 .include('form')
18 .withTags(['wcag2a', 'wcag2aa'])
19 .analyze();
20
21 expect(results.violations).toEqual([]);
22 });
23
24 // Check specific rules
25 test('images have alt text', async ({ page }) => {
26 await page.goto('/');
27
28 const results = await new AxeBuilder({ page })
29 .withRules(['image-alt'])
30 .analyze();
31
32 expect(results.violations).toEqual([]);
33 });
34});CI Integration#
1# .github/workflows/e2e.yml
2name: E2E Tests
3
4on:
5 push:
6 branches: [main]
7 pull_request:
8 branches: [main]
9
10jobs:
11 test:
12 runs-on: ubuntu-latest
13 steps:
14 - uses: actions/checkout@v4
15
16 - uses: actions/setup-node@v4
17 with:
18 node-version: 20
19 cache: 'npm'
20
21 - name: Install dependencies
22 run: npm ci
23
24 - name: Install Playwright browsers
25 run: npx playwright install --with-deps
26
27 - name: Run E2E tests
28 run: npx playwright test
29
30 - name: Upload report
31 uses: actions/upload-artifact@v4
32 if: always()
33 with:
34 name: playwright-report
35 path: playwright-report/
36 retention-days: 30Best Practices#
Selectors:
✓ Prefer user-visible locators
✓ Use getByRole, getByLabel, getByText
✓ Add data-testid for complex cases
✓ Avoid CSS selectors
Tests:
✓ Keep tests independent
✓ Use page objects for reusability
✓ Mock external dependencies
✓ Test critical user journeys
Performance:
✓ Run tests in parallel
✓ Use test.describe.parallel
✓ Share authentication state
✓ Minimize browser contexts
Conclusion#
Playwright provides reliable E2E testing with excellent developer experience. Use page objects for maintainability, fixtures for setup/teardown, and API mocking for isolation. Integrate visual and accessibility testing for comprehensive coverage, and run tests in CI for confidence in deployments.