End-to-end (E2E) testing verifies an application's full workflow, from what the user sees in the browser down through backend services and databases, checking that all layers work together correctly. Unit and integration tests catch bugs in isolated components, but they can miss problems that only appear when the whole system runs together. E2E tests fill that gap.
Playwright is a good choice for this. It's open-source, backed by Microsoft, supports Chromium, Firefox, and WebKit, and has first-class TypeScript support. This post covers the basics of E2E testing and how to implement a working suite with Playwright.
The Significance of End-to-End Testing
Unit tests verify that a function returns the right value. Integration tests verify that two modules exchange data correctly. Neither of those tells you whether a user can actually log in, complete a checkout, or submit a form without something breaking along the way.
E2E tests simulate real user behavior: clicking buttons, filling forms, navigating between pages, and checking that the right content appears. They catch regressions that cross service boundaries, where a change to a backend API breaks a frontend flow that no unit test would catch.
The tradeoff is speed and maintenance cost. E2E tests run slower than unit tests and are more sensitive to UI changes. Run them on critical user paths, not on every minor component variation.
Why Choose Playwright?
Playwright runs tests in Chromium, Firefox, and WebKit from a single configuration, which catches browser-specific rendering differences. Its automatic waiting means you rarely write explicit sleep calls. It captures screenshots, records traces, and generates videos on failure, which makes debugging a failed CI run much faster than trying to reproduce it locally.
TypeScript support is built in. Tests run in parallel across browsers. It integrates cleanly with GitHub Actions, GitLab CI, and similar platforms.
Setting Up Your Playwright Environment
Install Playwright in an existing or new Node.js project:
mkdir playwright-e2e-tests
cd playwright-e2e-tests
npm init -y
npm install --save-dev @playwright/test
npx playwright install
npx playwright install downloads the browser binaries for Chromium, Firefox, and WebKit. For TypeScript projects, initialize the config:
npx tsc --init
A minimal project structure:
playwright-e2e-tests/
├── tests/
│ └── example.spec.ts
├── package.json
├── tsconfig.json
└── playwright.config.ts
A Basic Example Test
import { test, expect } from '@playwright/test';
test('homepage has expected title and welcome message', async ({ page }) => {
await page.goto('https://example.com');
// Check the page title
await expect(page).toHaveTitle(/Example Domain/);
// Check if 'Example Domain' text is present
const heading = await page.locator('h1');
await expect(heading).toContainText('Example Domain');
});
Playwright automatically waits for the page to load and for elements to be ready before acting on them. The test navigates to a URL, checks the title, and checks the heading. Run it with:
npx playwright test
Advanced Playwright Configuration
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: 'tests',
timeout: 30000,
expect: {
timeout: 5000,
},
use: {
headless: true,
screenshot: 'only-on-failure',
trace: 'on-first-retry',
},
projects: [
{
name: 'Chromium',
use: { browserName: 'chromium' },
},
{
name: 'Firefox',
use: { browserName: 'firefox' },
},
{
name: 'WebKit',
use: { browserName: 'webkit' },
},
],
});
testDir sets where your test files live. timeout caps each test at 30 seconds. screenshot: 'only-on-failure' and trace: 'on-first-retry' give you debugging artifacts without cluttering successful runs. The projects array runs the same tests in all three browser engines in parallel.
Testing Dynamic Applications and Authentication
Single-page applications often require a logged-in session before any meaningful test can run. Logging in before every test is slow. Playwright solves this with storage state: log in once in a global setup step, save the session, and reuse it across tests.
// global-setup.ts
import { test as setup, expect } from '@playwright/test';
setup('authenticate once', async ({ page }) => {
await page.goto('https://my-app.com/login');
await page.fill('#username', 'testuser');
await page.fill('#password', 'testpassword');
await page.click('button[type="submit"]');
// Wait for a known element after login
await expect(page.locator('#dashboard')).toBeVisible();
// Save state
await page.context().storageState({ path: 'authState.json' });
});
Then in playwright.config.ts:
import { defineConfig } from '@playwright/test';
export default defineConfig({
globalSetup: require.resolve('./global-setup'),
use: {
storageState: 'authState.json',
},
// ... other configurations
});
All subsequent tests start with the saved session. No repeated login flows, no added test time for authentication.
Debugging Failed E2E Tests
When a test fails in CI, you need information. Playwright's built-in tools cover the main scenarios:
Screenshots on failure show you exactly what the browser saw when the test died. Traces capture a full timeline of every action, network request, and console message. Running with --headed locally lets you watch the test execute in a real browser window and inspect the DOM.
test('debugging example', async ({ page }) => {
await page.goto('https://example.com');
// Intentionally failing step
await expect(page.locator('.non-existent')).toBeVisible();
});
To run with tracing enabled:
npx playwright test --headed --trace=on
Integrating with CI/CD and DevOps
A basic GitHub Actions workflow:
name: Playwright E2E Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Node
uses: actions/setup-node@v2
with:
node-version: '16'
- name: Install dependencies
run: npm install
- name: Install browsers
run: npx playwright install
- name: Run tests
run: npx playwright test
Every push runs the full E2E suite. Failed tests produce screenshots and traces as CI artifacts you can download and inspect without reproducing locally.
Best Practices for Playwright and E2E Testing
Write tests that don't depend on execution order. Each test should set up its own state independently. A test that relies on a previous test having run is fragile and hard to debug when it fails.
Use data-test-id attributes on elements instead of CSS selectors tied to class names. CSS classes change when designs update. Test attributes are explicit contracts between the test and the UI.
Cover your most important user flows: login, key business actions, checkout, form submission. Don't try to E2E-test every minor UI variation. Keep E2E tests focused on paths that touch multiple layers.
Clean up test data after each run if your tests write to a database. Ephemeral test environments are easier to reason about than environments with accumulated state.
Expanding Beyond Basic Scenarios
Playwright handles more than simple page checks. You can emulate mobile devices and test responsive layouts. You can intercept network requests and mock third-party API responses to avoid triggering real payments or emails during tests. Visual regression testing with snapshots catches unintended styling changes. For API testing, you can use Playwright's request context to hit endpoints directly while reusing the same authentication state.
Conclusion
E2E tests with Playwright give you confidence that the most important user flows in your application work end to end, across browsers, and in conditions close to production. They're not a replacement for unit or integration tests, but they catch a category of bugs those tests cannot. With parallel execution across Chromium, Firefox, and WebKit, good failure artifacts, and clean CI integration, Playwright makes E2E testing practical rather than painful.