Playwright E2E Testing Deep Dive: From Test Cases to CI Integration
Why Playwright Over Cypress or Selenium
I've used Selenium, Cypress, and Playwright for E2E testing across three projects. Selenium was the most painful — WebDriver protocol is slow, waiting logic relies on sleep, and browser version matching feels like defusing a bomb. Cypress ran fine in a mid-sized project for half a year, until we hit a page with three window.open calls and Cypress just stopped — it doesn't support multiple tabs.
Playwright solves these problems. Built by the same team behind Puppeteer (who left Google), it natively supports Chromium, Firefox, and WebKit with a consistently designed API. Its killer feature is auto-waiting — it automatically waits for elements to be visible and actionable before clicking, so you never write waitForSelector by hand.
This article isn't an API translation. I'll focus on the patterns that matter in real projects and what we learned going from 0 to 50 test cases.
Setup and Project Structure
npm init playwright@latest
Generated project structure:
e2e/
├── tests/
│ ├── example.spec.ts
│ ├── login.spec.ts
│ └── dashboard.spec.ts
├── fixtures/
│ └── auth.setup.ts
├── pages/
│ ├── LoginPage.ts
│ └── DashboardPage.ts
├── utils/
│ └── helpers.ts
└── playwright.config.ts
Core Configuration
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e/tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 3 : undefined,
reporter: [
['html', { outputFolder: 'playwright-report' }],
['json', { outputFile: 'test-results.json' }],
],
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
{ name: 'Mobile Chrome', use: { ...devices['Pixel 5'] } },
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});
fullyParallel: true— all files and tests run in parallel; 50 cases complete in 2-3 minutes in CItrace: 'on-first-retry'— auto-records trace on failure, replayable like a video recordingwebServer— auto-starts dev server, no manualnpm run dev
Locator Strategy: Your First Line of Defense
Unstable locators are the number one killer of E2E tests. Change a DOM structure and everything goes red. Playwright offers a layered locator strategy, ranked by stability:
| Priority | Locator | Example | Stability |
|---|---|---|---|
| Highest | getByRole |
page.getByRole('button', { name: 'Submit' }) |
✅ Excellent |
| High | getByLabel |
page.getByLabel('Email') |
✅ Stable |
| High | getByPlaceholder |
page.getByPlaceholder('Enter email') |
✅ Stable |
| High | getByText |
page.getByText('Order Details') |
✅ Stable |
| Medium | getByTestId |
page.getByTestId('submit-btn') |
✅ Stable |
| Medium | locator (CSS) |
page.locator('.login-form .submit') |
⚠️ Fragile |
| Low | locator (XPath) |
page.locator('//div[@class="btn"]') |
❌ Very fragile |
In Practice
// ❌ Bad: CSS class changes break the test
await page.locator('#app > div > form > button.btn-primary').click();
// ⚠️ Okay: testId is stable but requires adding data-testid to source
await page.getByTestId('login-submit').click();
// ✅ Good: based on accessibility semantics, framework-agnostic
await page.getByRole('button', { name: 'Login' }).click();
// ✅ Good: based on accessible labels
await page.getByLabel('Email Address').fill('test@example.com');
await page.getByPlaceholder('Enter password').fill('Secret123');
getByRole matches based on WAI-ARIA semantics. As long as your HTML uses semantic tags or correct role attributes, it finds them. This is the most stable approach — Tailwind can change every class and the role stays the same.
Filtering and Chaining
const item = page.getByRole('listitem').filter({ hasText: 'Order #2024' });
const row = page.getByRole('row').filter({ has: page.getByText('Completed') });
const card = page.getByTestId('user-card');
await card.getByRole('button', { name: 'Edit' }).click();
page.getByText('Delete', { exact: true }); // exact match only
Assertion Strategy: Don't Assert Everything
The most common mistake in E2E tests is over-assertion. Asserting every intermediate state makes tests unreadable and high-maintenance.
Recommended: Only Assert What Users Perceive
test('user logs in and lands on dashboard', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('admin@example.com');
await page.getByLabel('Password').fill('password123');
await page.getByRole('button', { name: 'Login' }).click();
await expect(page).toHaveURL('/dashboard');
await expect(page.getByText('Welcome back, Admin')).toBeVisible();
await expect(page.getByText('Login failed')).not.toBeVisible();
});
Quick Reference
await expect(page).toHaveTitle(/Dashboard/);
await expect(page).toHaveURL(/\/dashboard$/);
await expect(page.getByText('Saved successfully')).toBeVisible();
await expect(page.getByRole('listitem')).toHaveCount(5);
await expect(page.getByLabel('Email')).toHaveValue('admin@example.com');
await expect(page.getByRole('button', { name: 'Submit' })).toBeDisabled();
await expect(page).toHaveScreenshot('homepage.png');
Network Interception and Mocking
E2E tests shouldn't fail because the backend is down. Playwright's page.route() intercepts at the network level:
Basic Interception
test('display user list — mock backend data', async ({ page }) => {
await page.route('**/api/users**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
data: [
{ id: 1, name: 'Alice', email: 'alice@example.com' },
{ id: 2, name: 'Bob', email: 'bob@example.com' },
],
total: 2,
}),
});
});
await page.goto('/users');
await expect(page.getByText('Alice')).toBeVisible();
await expect(page.getByText('Bob')).toBeVisible();
});
Conditional Interception
await page.route('**/api/orders**', async (route, request) => {
const url = new URL(request.url());
const status = url.searchParams.get('status');
if (status === 'pending') {
await route.fulfill({ body: JSON.stringify({ data: pendingOrders }) });
} else {
await route.fallback(); // Pass through to real server
}
});
Simulating Failures
// 500 error
await page.route('**/api/submit', async (route) => {
await route.fulfill({
status: 500,
body: JSON.stringify({ error: 'Internal Server Error' }),
});
});
// Network timeout
await page.route('**/api/slow-endpoint', async (route) => {
await route.abort('timedout');
});
Authentication Reuse
Don't log in fresh for every test. storageState reuses authentication state:
// playwright.config.ts
export default defineConfig({
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: 'e2e/.auth/user.json',
},
},
{
name: 'setup',
testMatch: /.*\.setup\.ts/,
},
],
});
// e2e/tests/auth.setup.ts
import { test as setup } from '@playwright/test';
import path from 'path';
const authFile = path.join(__dirname, '../.auth/user.json');
setup('authenticate and save state', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('admin@example.com');
await page.getByLabel('Password').fill('password123');
await page.getByRole('button', { name: 'Login' }).click();
await expect(page).toHaveURL('/dashboard');
await page.context().storageState({ path: authFile });
});
Subsequent tests load login state instantly from user.json.
Page Object Pattern
// e2e/pages/LoginPage.ts
import { type Page, type Locator, expect } from '@playwright/test';
export class LoginPage {
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
readonly errorMessage: Locator;
constructor(readonly page: Page) {
this.emailInput = page.getByLabel('Email');
this.passwordInput = page.getByLabel('Password');
this.submitButton = page.getByRole('button', { name: 'Login' });
this.errorMessage = page.getByTestId('login-error');
}
async goto() { await this.page.goto('/login'); }
async login(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.submitButton.click();
}
async expectError(message: string) {
await expect(this.errorMessage).toContainText(message);
}
}
test('wrong password shows error', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('admin@example.com', 'wrong-password');
await loginPage.expectError('Invalid email or password');
});
Visual Regression Testing
test('homepage screenshot comparison', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveScreenshot('homepage.png', {
fullPage: true,
maxDiffPixelRatio: 0.01,
});
});
test('button component dark mode', async ({ page }) => {
await page.emulateMedia({ colorScheme: 'dark' });
await page.goto('/components/button');
await expect(page.getByTestId('primary-btn')).toHaveScreenshot(
'button-primary-dark.png'
);
});
Screenshots are stored in e2e/tests/__screenshots__/. First run generates baselines; subsequent runs compare against them. Use Docker in CI for consistent rendering.
CI/CD Integration
GitHub Actions
name: E2E Tests
on:
pull_request:
branches: [main]
jobs:
e2e:
timeout-minutes: 15
runs-on: ubuntu-latest
strategy:
matrix:
shard: [1, 2, 3]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Run E2E tests
run: npx playwright test --shard=${{ matrix.shard }}/3
- name: Upload test report
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report-${{ matrix.shard }}
path: playwright-report/
retention-days: 7
Debugging Guide
Trace Viewer
npx playwright test --trace on
npx playwright show-trace test-results/example-LoginPage/trace.zip
Trace replays every action, network request, console log, DOM snapshot — a complete video recording of the test.
Useful Commands
npx playwright test --last-failed # only re-run failures
npx playwright test tests/login.spec.ts:42 # specific line
npx playwright test --headed # see the browser
npx playwright test --headed --debug # step through slowly
FAQ
Q: Playwright vs Cypress — which should I pick?
A: For new projects, go with Playwright. Cypress has a mature community and Dashboard service, but technically — multi-tab support, multi-browser, auto-waiting, parallel execution — Playwright is superior. If your team already uses Cypress heavily and migration is costly, sticking with it is fine.
Q: How much should E2E tests cover?
A: The testing pyramid ratio — 70% unit, 20% integration, 10% E2E. E2E should only cover core user flows: registration/login, core business workflows, payment. Edge cases and error paths belong to unit and integration tests.
Q: What if backend API changes break all frontend tests?
A: Use real APIs for critical flows (true end-to-end verification) and page.route() mocks for non-critical flows. Store mock data centrally in a fixtures directory so API changes only require a single update.
Related Tools
- JSON Formatter — Format API mock data
- Text Diff — Compare test output against expected values
- Base64 Encode/Decode — Handle screenshot and file upload testing
Try these browser-local tools — no sign-up required →