Playwright enables reliable end-to-end testing across browsers. This guide covers patterns for maintainable E2E tests.
Basic Test Structure#
1import { test, expect } from '@playwright/test';
2
3test.describe('User Authentication', () => {
4 test('should login successfully', async ({ page }) => {
5 await page.goto('/login');
6
7 await page.fill('[name="email"]', 'user@example.com');
8 await page.fill('[name="password"]', 'password123');
9 await page.click('button[type="submit"]');
10
11 await expect(page).toHaveURL('/dashboard');
12 await expect(page.locator('h1')).toHaveText('Welcome back');
13 });
14
15 test('should show error for invalid credentials', async ({ page }) => {
16 await page.goto('/login');
17
18 await page.fill('[name="email"]', 'user@example.com');
19 await page.fill('[name="password"]', 'wrongpassword');
20 await page.click('button[type="submit"]');
21
22 await expect(page.locator('.error-message')).toBeVisible();
23 await expect(page.locator('.error-message')).toHaveText('Invalid credentials');
24 });
25});Page Object Model#
1// pages/LoginPage.ts
2import { Page, Locator } 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.locator('[name="email"]');
14 this.passwordInput = page.locator('[name="password"]');
15 this.submitButton = page.locator('button[type="submit"]');
16 this.errorMessage = page.locator('.error-message');
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
30// pages/DashboardPage.ts
31export class DashboardPage {
32 readonly page: Page;
33 readonly welcomeHeading: Locator;
34 readonly userMenu: Locator;
35
36 constructor(page: Page) {
37 this.page = page;
38 this.welcomeHeading = page.locator('h1');
39 this.userMenu = page.locator('[data-testid="user-menu"]');
40 }
41
42 async logout() {
43 await this.userMenu.click();
44 await this.page.click('text=Logout');
45 }
46}
47
48// tests/auth.spec.ts
49import { test, expect } from '@playwright/test';
50import { LoginPage } from '../pages/LoginPage';
51import { DashboardPage } from '../pages/DashboardPage';
52
53test('complete login flow', async ({ page }) => {
54 const loginPage = new LoginPage(page);
55 const dashboardPage = new DashboardPage(page);
56
57 await loginPage.goto();
58 await loginPage.login('user@example.com', 'password123');
59
60 await expect(dashboardPage.welcomeHeading).toHaveText('Welcome back');
61});Fixtures for Reusable Setup#
1// fixtures.ts
2import { test as base } from '@playwright/test';
3import { LoginPage } from './pages/LoginPage';
4import { DashboardPage } from './pages/DashboardPage';
5
6type Fixtures = {
7 loginPage: LoginPage;
8 dashboardPage: DashboardPage;
9 authenticatedPage: Page;
10};
11
12export const test = base.extend<Fixtures>({
13 loginPage: async ({ page }, use) => {
14 await use(new LoginPage(page));
15 },
16
17 dashboardPage: async ({ page }, use) => {
18 await use(new DashboardPage(page));
19 },
20
21 authenticatedPage: async ({ page }, use) => {
22 // Login before test
23 await page.goto('/login');
24 await page.fill('[name="email"]', 'test@example.com');
25 await page.fill('[name="password"]', 'password123');
26 await page.click('button[type="submit"]');
27 await page.waitForURL('/dashboard');
28
29 await use(page);
30 },
31});
32
33// Usage
34test('should access protected content', async ({ authenticatedPage }) => {
35 await authenticatedPage.goto('/settings');
36 await expect(authenticatedPage.locator('h1')).toHaveText('Settings');
37});API Mocking#
1test('should display products from API', async ({ page }) => {
2 // Mock API response
3 await page.route('**/api/products', async (route) => {
4 await route.fulfill({
5 status: 200,
6 contentType: 'application/json',
7 body: JSON.stringify([
8 { id: 1, name: 'Product A', price: 29.99 },
9 { id: 2, name: 'Product B', price: 49.99 },
10 ]),
11 });
12 });
13
14 await page.goto('/products');
15
16 await expect(page.locator('.product-card')).toHaveCount(2);
17 await expect(page.locator('.product-card').first()).toContainText('Product A');
18});
19
20test('should handle API errors gracefully', async ({ page }) => {
21 await page.route('**/api/products', async (route) => {
22 await route.fulfill({
23 status: 500,
24 body: JSON.stringify({ error: 'Server error' }),
25 });
26 });
27
28 await page.goto('/products');
29
30 await expect(page.locator('.error-state')).toBeVisible();
31 await expect(page.locator('.error-state')).toContainText('Unable to load products');
32});Visual Testing#
1test('homepage should match snapshot', async ({ page }) => {
2 await page.goto('/');
3
4 // Full page screenshot
5 await expect(page).toHaveScreenshot('homepage.png', {
6 fullPage: true,
7 maxDiffPixels: 100,
8 });
9});
10
11test('button states', async ({ page }) => {
12 await page.goto('/components');
13
14 const button = page.locator('.primary-button');
15
16 // Default state
17 await expect(button).toHaveScreenshot('button-default.png');
18
19 // Hover state
20 await button.hover();
21 await expect(button).toHaveScreenshot('button-hover.png');
22
23 // Focus state
24 await button.focus();
25 await expect(button).toHaveScreenshot('button-focus.png');
26});Mobile Testing#
1import { devices } from '@playwright/test';
2
3test.use({ ...devices['iPhone 13'] });
4
5test('mobile navigation', async ({ page }) => {
6 await page.goto('/');
7
8 // Mobile menu should be collapsed
9 await expect(page.locator('nav ul')).not.toBeVisible();
10
11 // Open mobile menu
12 await page.click('[aria-label="Open menu"]');
13 await expect(page.locator('nav ul')).toBeVisible();
14
15 // Navigate
16 await page.click('text=Products');
17 await expect(page).toHaveURL('/products');
18});Testing Forms#
1test('should validate form fields', async ({ page }) => {
2 await page.goto('/contact');
3
4 // Submit empty form
5 await page.click('button[type="submit"]');
6
7 // Check validation errors
8 await expect(page.locator('#name-error')).toHaveText('Name is required');
9 await expect(page.locator('#email-error')).toHaveText('Email is required');
10
11 // Fill invalid email
12 await page.fill('[name="email"]', 'invalid-email');
13 await page.click('button[type="submit"]');
14 await expect(page.locator('#email-error')).toHaveText('Invalid email format');
15
16 // Fill valid data
17 await page.fill('[name="name"]', 'John Doe');
18 await page.fill('[name="email"]', 'john@example.com');
19 await page.fill('[name="message"]', 'Hello!');
20 await page.click('button[type="submit"]');
21
22 await expect(page.locator('.success-message')).toBeVisible();
23});Configuration#
1// playwright.config.ts
2import { defineConfig, devices } from '@playwright/test';
3
4export default defineConfig({
5 testDir: './tests',
6 fullyParallel: true,
7 forbidOnly: !!process.env.CI,
8 retries: process.env.CI ? 2 : 0,
9 workers: process.env.CI ? 1 : undefined,
10
11 use: {
12 baseURL: 'http://localhost:3000',
13 trace: 'on-first-retry',
14 screenshot: 'only-on-failure',
15 video: 'retain-on-failure',
16 },
17
18 projects: [
19 { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
20 { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
21 { name: 'webkit', use: { ...devices['Desktop Safari'] } },
22 { name: 'mobile', use: { ...devices['iPhone 13'] } },
23 ],
24
25 webServer: {
26 command: 'npm run dev',
27 url: 'http://localhost:3000',
28 reuseExistingServer: !process.env.CI,
29 },
30});Best Practices#
- Use data-testid attributes: More stable than CSS selectors
- Wait for elements properly: Use
waitForand assertions - Keep tests independent: Don't rely on test order
- Use Page Object Model: Better maintainability
- Test critical user journeys: Focus on important flows
Conclusion#
Playwright provides powerful E2E testing capabilities. Use Page Object Model for maintainability, fixtures for reusable setup, and focus on testing critical user journeys rather than every edge case.