Back to Blog
PlaywrightTestingE2EAutomation

End-to-End Testing with Playwright

Master Playwright for E2E testing. From basic tests to page objects to CI integration and visual testing.

B
Bootspring Team
Engineering
November 28, 2021
6 min read

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: 30

Best 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.

Share this article

Help spread the word about Bootspring