Skip to content
ExponaBLOG
PLAYWRIGHT & BROWSER AUTOMATION · DAY 081 / 100

Common E2E testing mistakes: what developers should know

Habits that make suites painful.

Expona Admin7 min read

After months of running Playwright tests in production, you start noticing patterns: suites that take hours, tests that fail randomly on Wednesdays, selectors that break after a single design tweak. The problem rarely lies in Playwright itself—it's in how we structure and maintain our test strategy. Understanding common E2E testing mistakes: what developers should know is the difference between a test suite that protects your product and one that becomes a tax on every deployment.

Testing Everything End-to-End

The most seductive mistake is believing that E2E tests should verify every code path, every validation rule, and every edge case. We call this the "testing pyramid upside-down" problem. When you're building an authentication flow, the urge is strong: test successful login, failed login with wrong password, SQL injection attempts, account lockout after 5 attempts, password reset flow, social login, MFA challenges, session expiration. That's 8+ E2E tests for one feature.

Here's the reality: unit and integration tests excel at covering permutations cheaply and quickly. Unit tests validate that your password validation regex catches 100+ attack vectors. Integration tests confirm the auth service correctly handles all response codes. E2E tests should verify the happy path and critical sad paths that real users encounter.

Consider an e-commerce checkout flow. You might test:

  • User adds item to cart, completes checkout, order appears in dashboard (happy path)
  • User's payment is declined, error message displays, cart persists
  • User navigates away and returns; session preserves cart state

You should not use E2E tests to verify that every payment gateway error code maps to the correct message. That belongs in API integration tests. Your test suite stays lean, runs fast, and remains maintainable.

typescript
// ❌ Avoid: Testing every validation rule end-to-end
test('password must contain uppercase, lowercase, number, and symbol', async ({ page }) => {
  await page.goto('/register');
  await page.fill('input[name="password"]', 'lowercase123');
  await page.click('button:has-text("Register")');
  await expect(page.locator('text=Must contain uppercase')).toBeVisible();
  // ... 10 more test cases for each rule
});

// ✅ Prefer: Unit test for validation, E2E test for user flow
test('user can register with valid credentials and receives confirmation email', async ({ page }) => {
  await page.goto('/register');
  await page.fill('input[name="email"]', 'user@example.com');
  await page.fill('input[name="password"]', 'ValidPass123!');
  await page.click('button:has-text("Register")');
  await expect(page).toHaveURL('/register/confirm-email');
});

Shared Test Data

Sharing a database state across tests seems efficient at first. You create a user once, then 20 tests log in as that user. One test modifies the user's subscription tier, another test deletes their account, a third expects them to have a certain email. By test 15, the user is corrupted and tests fail in random order depending on which test suite ran first.

Shared state violates test isolation. It creates invisible dependencies, makes tests non-deterministic, and forces you to run tests serially rather than in parallel. When a test fails, debugging becomes detective work: was it the test logic, or did test #3 corrupt the data?

The solution is isolation by design. Each test creates its own data, runs independently, and cleans up after itself. Use Playwright's beforeEach and afterEach hooks, or better yet, use your API to spin up fresh test data:

typescript
test.beforeEach(async ({ request, page }) => {
  // Create fresh test user via API
  const user = await request.post('/api/test/users', {
    data: {
      email: `test-${Date.now()}@example.com`,
      password: 'TestPass123!',
    },
  });
  const userData = await user.json();
  
  // Log in via UI
  await page.goto('/login');
  await page.fill('input[name="email"]', userData.email);
  await page.fill('input[name="password"]', 'TestPass123!');
  await page.click('button:has-text("Sign In")');
  await page.waitForURL('/dashboard');
});

test('user can update profile', async ({ page }) => {
  await page.click('a:has-text("Settings")');
  await page.fill('input[name="displayName"]', 'Jane Doe');
  await page.click('button:has-text("Save")');
  await expect(page.locator('text=Profile updated')).toBeVisible();
});

test('user can change password', async ({ page }) => {
  await page.click('a:has-text("Settings")');
  await page.click('button:has-text("Change Password")');
  await page.fill('input[name="currentPassword"]', 'TestPass123!');
  await page.fill('input[name="newPassword"]', 'NewPass456!');
  await page.click('button:has-text("Update")');
  await expect(page.locator('text=Password changed')).toBeVisible();
});

Each test starts clean. Isolation guarantees that test order doesn't matter. You can run tests in parallel, speeding up your feedback loop dramatically.

CSS Selectors Tied to Layout

New to Playwright, we reach for CSS selectors based on structure: div.sidebar div.menu ul li:nth-child(3) button. This selector couples your tests to the DOM hierarchy. When a designer adds a wrapper div, reorders menu items, or changes the grid layout, the selector breaks even though the button still exists and works.

Playwright offers better tools. Locate elements by their semantic meaning: role, text, placeholder, or test IDs. These selectors survive design refactors.

typescript
// ❌ Avoid: Brittle structure-based selectors
await page.click('nav div.sidebar ul li:nth-child(2) a');

// ✅ Prefer: Semantic selectors
await page.click('a:has-text("Dashboard")');
await page.click('[data-testid="dashboard-link"]');
await page.click('a[role="menuitem"]:has-text("Dashboard")');

// ✅ Great: Role-based with fallback text
await page.click('button:has-text("Save Changes")');
await page.locator('role=button[name="Save Changes"]').click();

When you must use CSS, prefer .sidebar button:has-text("Edit") over `.sidebar > div:nth-child(1) button`. Target classes with semantic meaning. Better yet, add data-testid attributes to elements your tests depend on—it documents test expectations and decouples E2E tests from markup churn.

Ignoring Flaky Tests

A test passes 95% of the time and fails randomly. Your team's response: "It's flaky, rerun it." This is a slow poison. Flaky tests erode confidence in your test suite. Developers learn to ignore failures. You run tests multiple times to build, wasting CI time. Eventually, nobody trusts the tests and you stop running them.

Flaky tests usually stem from timing issues, race conditions, or improper waits. The test clicks a button too quickly before the UI is ready. A network request completes before the page finishes rendering. A background job finishes inconsistently.

When you spot a flaky test, treat it as a production bug. Use Playwright's built-in wait strategies:

typescript
// ❌ Avoid: Hard-coded waits (flaky and slow)
await page.click('button:has-text("Save")');
await page.waitForTimeout(2000);
await expect(page.locator('text=Saved')).toBeVisible();

// ✅ Prefer: Wait for the action to complete
await page.click('button:has-text("Save")');
await page.waitForURL(/\/api\/save/, { timeout: 5000 });
await expect(page.locator('text=Saved')).toBeVisible();

// ✅ Better: Use waitForLoadState or waitForFunction
await page.click('button:has-text("Save")');
await page.waitForLoadState('networkidle');
await expect(page.locator('text=Saved')).toBeVisible();

// ✅ Best: Combine with proper assertions
await page.click('button:has-text("Save")');
await expect(page.locator('text=Saved')).toBeVisible();
// Playwright auto-waits for the element

If tests still flake after fixing waits, consider:

  • Are you testing against a staging environment with real backends? Flake might signal real infrastructure issues.
  • Does the application have race conditions? Fix the app, not the test.
  • Are database operations slow or inconsistent? Mock external services in test mode.

Flaky tests are never acceptable. Fix them immediately or delete them.

Common Mistakes Summary

Mistake Impact Solution
Testing every code path in E2E Slow suite, maintenance burden, false sense of coverage Test happy and critical sad paths; delegate edge cases to unit tests
Sharing test data across tests Tests fail in random order, not parallelizable, hard to debug Create fresh data per test via API; clean up in afterEach
Selectors tied to DOM structure Tests break on design changes, brittle maintenance Use role, text, or data-testid selectors
Ignoring flaky tests Erodes confidence, increases CI time, culture of distrust Fix immediately; use proper wait strategies and isolate timing issues

Key Takeaways

  • E2E tests verify user workflows, not every validation rule. Use the testing pyramid: many unit tests, fewer integration tests, even fewer E2E tests.
  • Isolate test data. Each test should create its own state and run independently, enabling parallel execution and reliable debugging.
  • Select elements by semantic meaning—role, text, placeholders, or test IDs—not by DOM position. This survives design refactors and markup changes.
  • Flaky tests are bugs. Fix them by using Playwright's wait strategies, ensuring proper state isolation, and addressing timing issues in your application logic.
  • Treat your E2E test suite as a contract. It should be fast, reliable, and easy to maintain, or it becomes a barrier to shipping rather than a safeguard.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles