DEV Community

Cover image for Why Your Playwright Tests Randomly Fail on CI and How to Fix It
Imran Ahmed
Imran Ahmed

Posted on

Why Your Playwright Tests Randomly Fail on CI and How to Fix It

Why Your Playwright Tests Randomly Fail on CI and How to Fix It

Flaky end-to-end tests are one of the most frustrating experiences in CI/CD. They pass on your machine, fail on the pipeline, and the error messages rarely point you toward the actual problem. Most developers do the intuitive thing: increase the timeout. Sometimes that works. Most of the time, it delays the failure rather than preventing it.

The real culprit is usually a race condition between page navigation, asynchronous network requests, and DOM updates.

The Root Cause: Unwaited Network Activity

When you click a button that triggers an AJAX call, the browser doesn't wait for that request to complete before moving to the next line of your test. If your test then asserts on data that arrives via that AJAX call, you're gambling on network latency.

Locally, your machine is fast and close to the API. The race condition almost always resolves in your favor. On CI, especially in containerized environments, latency is variable and unpredictable. Now that same test fails intermittently, and it feels random.

It's not random. It's deterministic once you understand what's happening.

The Wrong Solution: Arbitrary Waits

// This is guessing, not waiting
await Task.Delay(3000);
await page.ClickAsync("#submit-btn");
Enter fullscreen mode Exit fullscreen mode

Bumping delays to 5 seconds, 10 seconds, or higher might reduce flakiness, but it won't eliminate it. You're still guessing. And you're slowing down your test suite significantly.

The Right Solution: Event-Based Waits

Playwright provides explicit wait mechanisms that pause execution until a specific condition is met.

Wait for a Specific Response

If your test triggers a known API call, wait for that response explicitly:

await page.ClickAsync("#submit-btn");

// Wait for the specific network call to complete
var responseTask = page.WaitForResponseAsync("**/api/orders");
await responseTask;

var response = await responseTask;
Assert.Equals(200, response.Status);

// Now it's safe to check the DOM
await page.WaitForSelectorAsync(".order-confirmation");
Enter fullscreen mode Exit fullscreen mode

Wait for Network Idle

If you're not sure which calls a click triggers, or if there are multiple concurrent requests:

await page.ClickAsync("#load-dashboard-btn");
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);

// At this point, all network activity has settled
var dashboardContent = await page.TextContentAsync(".dashboard");
Assert.Contains("Welcome", dashboardContent);
Enter fullscreen mode Exit fullscreen mode

Wait for DOM Changes

For dynamic content that doesn't necessarily involve network calls:

await page.ClickAsync("#filter-btn");
await page.WaitForSelectorAsync(".filtered-results", 
    new PageWaitForSelectorOptions { State = WaitForSelectorState.Visible });

var results = await page.Locator(".filtered-results li").Count();
Assert.True(results > 0);
Enter fullscreen mode Exit fullscreen mode

Mock External APIs

Third-party APIs introduce uncontrollable latency and occasional failures. In CI, you want deterministic behavior. Use Playwright's API mocking:

// In your test setup or a beforeEach hook
await page.RouteAsync("**/api/external-rate-limit", route => 
{
    route.FulfillAsync(new RouteFulfillOptions
    {
        Status = 200,
        ContentType = "application/json",
        Body = "{\"rate\": 0.95, \"currency\": \"USD\"}"
    });
});
Enter fullscreen mode Exit fullscreen mode

This ensures your tests aren't affected by external service availability or performance.

Isolate State Between Tests

Shared state between tests is a silent flakiness generator. One test sets something in localStorage, another test depends on a clean slate, and they interfere with each other.

// Use a fresh context for each test
private IBrowserContext _context;

[SetUp]
public async Task Setup()
{
    var browser = await Playwright.Chromium.LaunchAsync();
    _context = await browser.NewContextAsync();

    // Clear all storage for a clean state
    await _context.ClearCookiesAsync();
    await _context.ClearPermissionsAsync();
}

// Or if using storage state, clear explicitly
await _context.StorageStateAsync(new BrowserContextStorageStateOptions
{
    Path = null // Prevents state leakage between runs
});
Enter fullscreen mode Exit fullscreen mode

Instrument, Don't Speculate

When a test fails on CI but passes locally, resist the urge to just bump the timeout. Instead, add logging to understand the timing:

var stopwatch = Stopwatch.StartNew();
await page.ClickAsync("#submit-btn");
await page.WaitForResponseAsync("**/api/submit");
stopwatch.Stop();

Console.WriteLine($"Response arrived after {stopwatch.ElapsedMilliseconds}ms");

// If this consistently takes 2.8 seconds on CI, you have data.
// Bumping to 3 seconds might be legitimate, or you might have a scaling issue.
Enter fullscreen mode Exit fullscreen mode

This diagnostic approach tells you whether you need explicit waits, better mocking, or something else entirely.

Practical Takeaway

Replace arbitrary waits with explicit, event-based waits. waitForResponse and waitForLoadState are your primary tools. Mock external dependencies to eliminate variables. Clear browser context between tests to prevent state leakage.

Your test suite should be trustworthy across local dev, staging, and CI. When a test fails, it should be because something is actually broken, not because of timing luck.


Top comments (0)