Why E2E Testing Gets Skipped — and What It Costs You
Many teams write solid unit tests, but then deploy to staging only to discover something’s broken: the “Checkout” button doesn’t work on mobile, or the login form fails on Safari. I ran into exactly this situation — a React project with 82% unit test coverage, but a completely broken checkout flow on iPhone because a CSS z-index was hiding the submit button.
Unit tests can’t catch that kind of bug because they test individual functions in isolation. E2E tests are different — they simulate real user behavior: open a browser, fill out a form, click a button, verify the result.
Selenium used to be the go-to choice, but the setup is cumbersome, WebDriver version mismatches are common, and it’s slow. I once had a suite of 80 Selenium tests that took 12 minutes to run on CI. Playwright, released in 2020 by Microsoft, addresses nearly all of those problems: a modern API, support for Chromium/Firefox/WebKit, and parallelism by default — the equivalent suite now runs in about 2–3 minutes.
This guide walks through everything from installation to running real tests against a web application — a great starting point if you’re writing E2E tests for the first time.
Installing Playwright
Requirements
- Node.js 18+ (check with
node -v) - npm or yarn
Initializing the Project
The fastest way is to use Playwright’s built-in init command:
npm init playwright@latest
The command will ask a few questions:
- TypeScript or JavaScript? → choose TypeScript if your project already uses TS
- Where to put tests? → defaults to
tests/ - Add a GitHub Actions workflow? → Yes if you’re using CI
- Install browsers? → Yes
Once complete, your project structure will look like this:
my-project/
├── tests/
│ └── example.spec.ts
├── playwright.config.ts
├── package.json
└── node_modules/
Installing Browsers Manually (if needed)
# Install all browsers
npx playwright install
# Or install Chromium only
npx playwright install chromium
Detailed Configuration
The playwright.config.ts File
playwright.config.ts is where you control the entire behavior of your test suite — timeouts, browsers, retries, and reporting. Here’s the config I use as a baseline:
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
// Directory containing test files
testDir: './tests',
// Run up to 4 tests in parallel
workers: 4,
// Retry once on failure (reduces flakiness)
retries: 1,
// Timeout per test: 30 seconds
timeout: 30_000,
// Reporters: HTML report + terminal output
reporter: [['html', { open: 'never' }], ['list']],
use: {
// Base URL — use baseURL instead of hardcoding
baseURL: 'http://localhost:3000',
// Take a screenshot on test failure
screenshot: 'only-on-failure',
// Record video on test failure
video: 'retain-on-failure',
// Enable trace for debugging (replay step by step)
trace: 'on-first-retry',
},
// Test across multiple browsers
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'mobile-chrome',
use: { ...devices['Pixel 5'] },
},
],
// Automatically start the dev server before running tests
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});
Writing Your First Test
Now let’s write a real test. Create the file tests/login.spec.ts:
import { test, expect } from '@playwright/test';
test.describe('Login Page', () => {
test('successful login with valid credentials', async ({ page }) => {
// Navigate to the login page
await page.goto('/login');
// Fill in the form
await page.fill('[name="email"]', '[email protected]');
await page.fill('[name="password"]', 'secret123');
// Click the submit button
await page.click('button[type="submit"]');
// Verify redirect to dashboard
await expect(page).toHaveURL('/dashboard');
await expect(page.locator('h1')).toContainText('Welcome');
});
test('shows error message with wrong password', async ({ page }) => {
await page.goto('/login');
await page.fill('[name="email"]', '[email protected]');
await page.fill('[name="password"]', 'wrongpassword');
await page.click('button[type="submit"]');
// Verify the error message is displayed
await expect(page.locator('.error-message')).toBeVisible();
await expect(page.locator('.error-message')).toContainText('Incorrect password');
});
});
Using Page Object Model for Reusability
As you write more tests, you’ll notice yourself copy-pasting selectors all over the place. Page Object Model (POM) solves this by grouping all selectors for a given page into a dedicated class:
// pages/LoginPage.ts
import { Page, Locator } from '@playwright/test';
export class LoginPage {
readonly page: Page;
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
readonly errorMessage: Locator;
constructor(page: Page) {
this.page = page;
this.emailInput = page.locator('[name="email"]');
this.passwordInput = page.locator('[name="password"]');
this.submitButton = page.locator('button[type="submit"]');
this.errorMessage = page.locator('.error-message');
}
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();
}
}
Using it in a test:
// tests/login.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
test('successful login', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('[email protected]', 'secret123');
await expect(page).toHaveURL('/dashboard');
});
Running Tests and Reviewing Results
Basic Test Commands
# Run all tests
npx playwright test
# Run a specific file
npx playwright test tests/login.spec.ts
# Run only on Chrome
npx playwright test --project=chromium
# Run in headed mode (watch the browser)
npx playwright test --headed
# Run a specific test by name
npx playwright test -g "successful login"
# Open the HTML report
npx playwright show-report
Using Trace Viewer for Debugging
Test failing and you don’t know where to start? That’s when Trace Viewer earns its keep. It records every step like a video, complete with a DOM snapshot, network requests, and console logs at each point in time — no console.log statements required.
# Run with tracing enabled
npx playwright test --trace on
# Open the trace from the most recent run
npx playwright show-trace test-results/login-chromium/trace.zip
My debugging workflow: run the failing test → open the trace → inspect the screenshot at the failing step → check the DOM to see if the selector is correct. Much faster than re-running tests repeatedly with scattered log statements.
CI/CD Integration with GitHub Actions
If you chose “Yes” during init, Playwright already generated a GitHub Actions workflow file. Here’s the template I typically use:
# .github/workflows/playwright.yml
name: Playwright E2E Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps
- name: Run E2E tests
run: npx playwright test
env:
CI: true
- name: Upload test report
uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 7
Practical Tips from the Field
For selectors, my priority order is: data-testid → ARIA role → label → text → CSS class. CSS classes and XPath selectors are brittle during UI refactors — rename one class and dozens of tests go red immediately.
// Good — stable, unaffected by styling changes
await page.click('[data-testid="submit-btn"]');
await page.click('role=button[name="Sign in"]');
// Avoid — breaks easily
await page.click('.btn.btn-primary.login-form__submit');
Network mocking is another area where Playwright outperforms most other tools. Need to test a “No products found” screen without deleting real data? Use page.route() to intercept the REST API request:
// Mock the API to return an empty list
await page.route('**/api/products', route => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([]),
});
});
await page.goto('/products');
await expect(page.locator('.empty-state')).toBeVisible();
Another tool I regularly use when preparing test data: toolcraft.app — quickly format API JSON responses to understand the structure before writing assertions, much more convenient than installing a browser extension.
Wrapping Up
Playwright scales well from small to large projects — simple API, excellent debugging, fast execution. Start by writing tests for your most critical flows (login, checkout, form submission) and expand from there.
Compared to Selenium: no separate WebDriver needed, a far smarter selector API, and Trace Viewer is unmatched for debugging. Using Cypress? Playwright is worth trying — it supports more browsers out of the box and runs tests in parallel by default, no extra configuration required.

