Skip to content

End-to-End Testing with Playwright

Playwright is Microsoft’s browser automation library. It drives real browsers (Chromium, Firefox, WebKit) to test your application as a user would.

Terminal window
npm init playwright@latest
# Or manually
npm install -D @playwright/test
npx playwright install # download browser binaries
tests/login.spec.ts
import { test, expect } from '@playwright/test';
test('user can log in', async ({ page }) => {
await page.goto('http://localhost:3000/login');
await page.fill('[name="email"]', 'alice@example.com');
await page.fill('[name="password"]', 'secret123');
await page.click('button[type="submit"]');
await expect(page).toHaveURL('/dashboard');
await expect(page.getByRole('heading')).toContainText('Welcome, Alice');
});
playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
reporter: 'html',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'Mobile Safari', use: { ...devices['iPhone 14'] } },
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});

Playwright uses locators that are resilient to DOM changes:

// By role (best — matches ARIA semantics)
page.getByRole('button', { name: 'Submit' })
page.getByRole('heading', { level: 1 })
page.getByRole('textbox', { name: 'Email' })
// By label
page.getByLabel('Password')
// By placeholder
page.getByPlaceholder('Search...')
// By text
page.getByText('Welcome back')
// By test ID (data-testid attribute)
page.getByTestId('submit-button')
// CSS / XPath (fallback — prefer role-based locators)
page.locator('.submit-btn')
page.locator('input[name="email"]')
await page.goto('/login');
await page.fill('[name="email"]', 'alice@example.com');
await page.click('button[type="submit"]');
await page.check('[name="remember-me"]');
await page.selectOption('select[name="country"]', 'GB');
await page.keyboard.press('Enter');
await page.hover('.tooltip-trigger');
// Wait for navigation
await page.waitForURL('/dashboard');
// Upload a file
await page.setInputFiles('input[type="file"]', './test-file.pdf');
await expect(page).toHaveURL('/dashboard');
await expect(page).toHaveTitle('Dashboard | MyApp');
await expect(page.getByRole('heading')).toContainText('Welcome');
await expect(page.getByRole('button', { name: 'Submit' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled();
await expect(page.getByTestId('error-msg')).toHaveText('Email is required');
await expect(page.locator('.spinner')).toBeHidden();

Page Object Model encapsulates page interaction logic:

tests/pages/LoginPage.ts
import { Page, Locator } from '@playwright/test';
export class LoginPage {
readonly email: Locator;
readonly password: Locator;
readonly submitButton: Locator;
constructor(private page: Page) {
this.email = page.getByLabel('Email');
this.password = page.getByLabel('Password');
this.submitButton = page.getByRole('button', { name: 'Log in' });
}
async login(email: string, password: string) {
await this.page.goto('/login');
await this.email.fill(email);
await this.password.fill(password);
await this.submitButton.click();
}
}
// tests/login.spec.ts
test('logs in successfully', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.login('alice@example.com', 'secret123');
await expect(page).toHaveURL('/dashboard');
});
test('shows error when API fails', async ({ page }) => {
await page.route('/api/users', route =>
route.fulfill({ status: 500, body: 'Server error' })
);
await page.goto('/users');
await expect(page.getByText('Something went wrong')).toBeVisible();
});
Terminal window
npx playwright test # all tests, headless
npx playwright test --headed # with browser visible
npx playwright test login.spec.ts # specific file
npx playwright test --project=chromium # specific browser
npx playwright test --debug # step through with DevTools
npx playwright test --ui # interactive UI mode
npx playwright show-report # view HTML report
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps
- name: Run Playwright tests
run: npx playwright test
- name: Upload report
uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 30