Back to Blog
TestingE2EPlaywrightAutomation

End-to-End Testing with Playwright

Write reliable E2E tests with Playwright. Learn page objects, test patterns, and best practices for browser automation.

B
Bootspring Team
Engineering
February 27, 2026
5 min read

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#

  1. Use data-testid attributes: More stable than CSS selectors
  2. Wait for elements properly: Use waitFor and assertions
  3. Keep tests independent: Don't rely on test order
  4. Use Page Object Model: Better maintainability
  5. 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.

Share this article

Help spread the word about Bootspring