End-to-end tests verify your entire application works from the user's perspective. Playwright makes browser automation reliable and fast across all modern browsers.
Why Playwright?#
Advantages:
✓ Cross-browser (Chromium, Firefox, WebKit)
✓ Auto-wait for elements
✓ Fast and reliable
✓ Powerful selectors
✓ Network interception
✓ Visual comparison
✓ Mobile emulation
✓ Parallel execution
Setup#
1# Install Playwright
2npm init playwright@latest
3
4# Or add to existing project
5npm install -D @playwright/test
6npx playwright install1// 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 ? 1 : undefined,
10 reporter: 'html',
11 use: {
12 baseURL: 'http://localhost:3000',
13 trace: 'on-first-retry',
14 screenshot: 'only-on-failure',
15 },
16 projects: [
17 {
18 name: 'chromium',
19 use: { ...devices['Desktop Chrome'] },
20 },
21 {
22 name: 'firefox',
23 use: { ...devices['Desktop Firefox'] },
24 },
25 {
26 name: 'webkit',
27 use: { ...devices['Desktop Safari'] },
28 },
29 {
30 name: 'mobile',
31 use: { ...devices['iPhone 13'] },
32 },
33 ],
34 webServer: {
35 command: 'npm run dev',
36 url: 'http://localhost:3000',
37 reuseExistingServer: !process.env.CI,
38 },
39});Basic Tests#
1import { test, expect } from '@playwright/test';
2
3test.describe('Homepage', () => {
4 test('should display welcome message', async ({ page }) => {
5 await page.goto('/');
6
7 await expect(page.getByRole('heading', { level: 1 })).toHaveText(
8 'Welcome to Our App'
9 );
10 });
11
12 test('should navigate to features page', async ({ page }) => {
13 await page.goto('/');
14
15 await page.getByRole('link', { name: 'Features' }).click();
16
17 await expect(page).toHaveURL('/features');
18 await expect(page.getByRole('heading', { level: 1 })).toHaveText(
19 'Features'
20 );
21 });
22});Authentication Tests#
1import { test, expect } from '@playwright/test';
2
3test.describe('Authentication', () => {
4 test('should login successfully', async ({ page }) => {
5 await page.goto('/login');
6
7 await page.getByLabel('Email').fill('user@example.com');
8 await page.getByLabel('Password').fill('password123');
9 await page.getByRole('button', { name: 'Sign in' }).click();
10
11 await expect(page).toHaveURL('/dashboard');
12 await expect(page.getByText('Welcome, User')).toBeVisible();
13 });
14
15 test('should show error for invalid credentials', async ({ page }) => {
16 await page.goto('/login');
17
18 await page.getByLabel('Email').fill('user@example.com');
19 await page.getByLabel('Password').fill('wrongpassword');
20 await page.getByRole('button', { name: 'Sign in' }).click();
21
22 await expect(page.getByRole('alert')).toHaveText('Invalid credentials');
23 await expect(page).toHaveURL('/login');
24 });
25
26 test('should logout', async ({ page }) => {
27 // Login first
28 await page.goto('/login');
29 await page.getByLabel('Email').fill('user@example.com');
30 await page.getByLabel('Password').fill('password123');
31 await page.getByRole('button', { name: 'Sign in' }).click();
32 await expect(page).toHaveURL('/dashboard');
33
34 // Logout
35 await page.getByRole('button', { name: 'Logout' }).click();
36
37 await expect(page).toHaveURL('/');
38 await expect(page.getByRole('link', { name: 'Sign in' })).toBeVisible();
39 });
40});Page Object Model#
1// pages/login.page.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.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
30// pages/dashboard.page.ts
31export class DashboardPage {
32 readonly page: Page;
33 readonly welcomeMessage: Locator;
34 readonly logoutButton: Locator;
35
36 constructor(page: Page) {
37 this.page = page;
38 this.welcomeMessage = page.getByText(/Welcome,/);
39 this.logoutButton = page.getByRole('button', { name: 'Logout' });
40 }
41
42 async logout() {
43 await this.logoutButton.click();
44 }
45}
46
47// Usage in test
48test('should login and logout', async ({ page }) => {
49 const loginPage = new LoginPage(page);
50 const dashboardPage = new DashboardPage(page);
51
52 await loginPage.goto();
53 await loginPage.login('user@example.com', 'password123');
54
55 await expect(dashboardPage.welcomeMessage).toBeVisible();
56
57 await dashboardPage.logout();
58 await expect(page).toHaveURL('/');
59});API Mocking#
1test('should display products from API', async ({ page }) => {
2 // Mock API response
3 await page.route('**/api/products', (route) => {
4 route.fulfill({
5 status: 200,
6 contentType: 'application/json',
7 body: JSON.stringify([
8 { id: 1, name: 'Product A', price: 100 },
9 { id: 2, name: 'Product B', price: 200 },
10 ]),
11 });
12 });
13
14 await page.goto('/products');
15
16 await expect(page.getByText('Product A')).toBeVisible();
17 await expect(page.getByText('Product B')).toBeVisible();
18});
19
20test('should handle API errors', async ({ page }) => {
21 await page.route('**/api/products', (route) => {
22 route.fulfill({
23 status: 500,
24 contentType: 'application/json',
25 body: JSON.stringify({ error: 'Server error' }),
26 });
27 });
28
29 await page.goto('/products');
30
31 await expect(page.getByText('Failed to load products')).toBeVisible();
32});Visual Testing#
1test('homepage visual regression', async ({ page }) => {
2 await page.goto('/');
3
4 // Full page screenshot
5 await expect(page).toHaveScreenshot('homepage.png');
6});
7
8test('component visual regression', async ({ page }) => {
9 await page.goto('/components');
10
11 const button = page.getByRole('button', { name: 'Primary' });
12 await expect(button).toHaveScreenshot('primary-button.png');
13
14 // Hover state
15 await button.hover();
16 await expect(button).toHaveScreenshot('primary-button-hover.png');
17});
18
19// playwright.config.ts
20export default defineConfig({
21 expect: {
22 toHaveScreenshot: {
23 maxDiffPixels: 100,
24 threshold: 0.2,
25 },
26 },
27});Test Fixtures#
1// fixtures.ts
2import { test as base } from '@playwright/test';
3import { LoginPage } from './pages/login.page';
4import { DashboardPage } from './pages/dashboard.page';
5
6type Fixtures = {
7 loginPage: LoginPage;
8 dashboardPage: DashboardPage;
9 authenticatedPage: void;
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.getByLabel('Email').fill('user@example.com');
25 await page.getByLabel('Password').fill('password123');
26 await page.getByRole('button', { name: 'Sign in' }).click();
27 await page.waitForURL('/dashboard');
28
29 await use();
30 },
31});
32
33// Usage
34test('should access protected content', async ({
35 page,
36 authenticatedPage,
37 dashboardPage,
38}) => {
39 await expect(dashboardPage.welcomeMessage).toBeVisible();
40});Mobile Testing#
1import { devices } from '@playwright/test';
2
3test.use(devices['iPhone 13']);
4
5test('should work on mobile', async ({ page }) => {
6 await page.goto('/');
7
8 // Test mobile menu
9 await page.getByRole('button', { name: 'Menu' }).click();
10 await expect(page.getByRole('navigation')).toBeVisible();
11
12 await page.getByRole('link', { name: 'Products' }).click();
13 await expect(page).toHaveURL('/products');
14});
15
16// Touch gestures
17test('should support swipe', async ({ page }) => {
18 await page.goto('/gallery');
19
20 const gallery = page.locator('.gallery');
21 await gallery.evaluate((el) => {
22 el.dispatchEvent(
23 new TouchEvent('touchstart', {
24 touches: [new Touch({ identifier: 0, target: el, clientX: 300 })],
25 })
26 );
27 el.dispatchEvent(
28 new TouchEvent('touchend', {
29 changedTouches: [new Touch({ identifier: 0, target: el, clientX: 50 })],
30 })
31 );
32 });
33
34 await expect(page.locator('.slide-2')).toBeVisible();
35});CI Configuration#
1# .github/workflows/e2e.yml
2name: E2E Tests
3
4on: [push, pull_request]
5
6jobs:
7 e2e:
8 runs-on: ubuntu-latest
9
10 steps:
11 - uses: actions/checkout@v4
12
13 - name: Setup Node.js
14 uses: actions/setup-node@v4
15 with:
16 node-version: '20'
17
18 - name: Install dependencies
19 run: npm ci
20
21 - name: Install Playwright browsers
22 run: npx playwright install --with-deps
23
24 - name: Run E2E tests
25 run: npx playwright test
26
27 - name: Upload report
28 if: always()
29 uses: actions/upload-artifact@v4
30 with:
31 name: playwright-report
32 path: playwright-report/Conclusion#
Playwright provides reliable, fast E2E testing across browsers. Use page objects for maintainability, mock APIs for speed, and run tests in CI for confidence.
Start with critical user journeys, then expand coverage as your application grows.