End-to-End Testing With Playwright: A Production Approach
How we use Playwright for E2E testing in production applications — what to test, what to skip, page object patterns, and CI integration that doesn't flake.
End-to-end testing is the most expensive form of automated testing to write and maintain. It’s also the only form that validates your application as a user actually experiences it. The key is knowing precisely where E2E tests add value that unit and integration tests can’t provide.
What Belongs in E2E Tests
E2E tests are expensive — they spin up a browser, navigate an application, and verify visual and functional outcomes. Reserve them for:
Critical user journeys. The flows that, if broken, stop your business. For a SaaS product: signup, onboarding, core workflow, subscription management. For an e-commerce site: product discovery, cart, checkout, confirmation.
Cross-system integrations. Flows that touch external systems (Stripe, email, file uploads) are difficult to test otherwise. E2E tests with test credentials can verify the full flow.
Regression coverage for reported bugs. When a user finds a bug, write a failing E2E test first, then fix it. The test prevents regression better than code review alone.
What to not put in E2E tests: business logic, data transformations, utility functions, API response shapes. These belong in unit and integration tests that run faster and don’t require a browser.
The Page Object Pattern
Page objects encapsulate Playwright selectors and actions behind a semantic API. This keeps tests readable and selector changes isolated to one place.
class ContactFormPage {
constructor(private page: Page) {}
async navigate() {
await this.page.goto('/contact');
}
async fillForm(name: string, email: string, message: string) {
await this.page.getByLabel('Full Name').fill(name);
await this.page.getByLabel('Email Address').fill(email);
await this.page.getByLabel('Project Details').fill(message);
}
async submit() {
await this.page.getByRole('button', { name: 'Send Message' }).click();
}
async expectSuccess() {
await expect(this.page.getByRole('alert'))
.toContainText("I'll be in touch");
}
}
// In the test
test('contact form submits successfully', async ({ page }) => {
const form = new ContactFormPage(page);
await form.navigate();
await form.fillForm('Test User', '[email protected]', 'Test message');
await form.submit();
await form.expectSuccess();
});
Avoiding Flakiness
Flaky tests are worse than no tests. They erode trust in the entire test suite and cause teams to ignore failures.
The most common sources of flakiness:
Timing. Don’t use setTimeout or page.waitForTimeout. Use page.waitForSelector, expect(locator).toBeVisible(), or page.waitForResponse. Playwright’s auto-waiting handles most timing issues automatically.
Non-unique selectors. A selector that matches multiple elements is unpredictably flaky. Use getByRole, getByLabel, and getByTestId over CSS selectors. Roles and labels are semantic and stable.
Test interdependence. Each test should set up its own state. Tests that depend on previous test state fail unpredictably when run in isolation or reordered.
CI Integration
- name: Run Playwright E2E tests
run: npx playwright test --project=chromium
env:
BASE_URL: http://localhost:4321
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/
Upload the Playwright HTML report on failure. It contains screenshots and traces of every failed test — invaluable for debugging CI failures without reproducing them locally.
Run E2E tests against a preview deployment, not localhost in CI. Previews are closer to production and catch environment-specific issues.