QA AutomationDevOps

Diagnosing and Eliminating Flaky End-to-End Tests in CI/CD

Diagnosing and stabilizing flaky automated browser tests in CI delivery pipelines

Nothing destroys engineering velocity faster than a flaky test suite.

When a pull request fails a browser test run, a developer clicks "Re-run failed jobs". The second run passes. The PR gets merged. Two weeks later, tests are failing 30% of the time, developers ignore red CI builds, and genuine production bugs slip directly through to customers.

Flaky tests are not an inevitable reality of browser automation. They are technical debt caused by specific, identifiable engineering patterns: race conditions, hardcoded sleeps, dynamic element animations, and contaminated database state.

Here is how senior QA automation architects diagnose and permanently fix flaky tests.

The Flakiness Hierarchy of Sins

Flakiness Anti-PatternWhy It Fails in CIThe Architectural Fix
await page.waitForTimeout(3000)CI runners are slower and more variable than dev laptopsReplace with locator auto-waiting (toBeVisible())
Shared Database FixturesParallel test workers overwrite each other's test stateIsolated tenant IDs or database transactions per worker
Premature Button ClicksButton is in the DOM but still animating or transitioningAssert actionable state before triggering interaction
Unmocked Third-Party CDNsStripe, Intercom, or Google Analytics network flakiness fails testsRoute-block non-essential external third-party domains

Modern Playwright Auto-Waiting Patterns

Legacy Selenium-style scripts required manual thread sleeping. Modern frameworks like Playwright automatically wait for elements to be actionable (visible, stable, enabled, and receiving events).

Notice the difference between fragile and robust test logic:

// BAD: Fragile, prone to CI latency timeouts
test('User profile update (fragile)', async ({ page }) => {
  await page.click('#edit-profile');
  await page.waitForTimeout(2000); // Guarantees flakiness under CPU load
  await page.fill('#username', 'new_name');
  await page.click('#save-button');
  expect(await page.textContent('.alert-banner')).toContain('Success');
});

// GOOD: Deterministic, web-first assertions
test('User profile update (resilient)', async ({ page }) => {
  await page.getByRole('button', { name: 'Edit Profile' }).click();
  
  // Auto-waits for visible and editable state
  const usernameInput = page.getByLabel('Username');
  await usernameInput.fill('new_name');

  // Intercept network response rather than relying on UI sleep
  const responsePromise = page.waitForResponse(resp => 
    resp.url().includes('/api/v1/profile') && resp.status() === 200
  );
  await page.getByRole('button', { name: 'Save Changes' }).click();
  await responsePromise;

  // Assert user-facing notification
  await expect(page.getByRole('alert')).toHaveText(/Profile saved successfully/i);
});

Eliminating State Bleed with Isolated Test Contexts

Tests that pass when run in isolation but fail when run as part of the full suite suffer from state pollution. To prevent tests from corrupting each other:

  • Generate randomized UUIDs for all test emails, usernames, and invoices (e.g. test_user_${Date.now()}@domain.com).
  • Spin up isolated browser contexts with fresh local storage and cookies for every test.
  • Block non-deterministic external scripts (analytics, chat widgets) in your CI test harness using page.route().

To learn how our team builds reliable, high-speed automated testing pipelines, explore our QA automation services and read our guide on which tests to automate first.

Related Articles

Let's grow your business together.

Book a free consultation — no obligation, just honest advice.

Get a Free Quote