32Flakiness: Local vs CI — diagnostic playbook
What you will master here
- The 10 most common reasons a test passes locally and fails on CI
- A step-by-step triage playbook (use this in the interview answer)
- Tools and commands to bisect quickly
- Patterns to make tests deterministic by design
32.1 The 10 root causes (memorise the list)
- Timing / race conditions — Faster local machine; slow CI exposes races. Fix: web-first assertions, no
waitForTimeout. - OS/font/rendering differences — Pixel-perfect snapshot fails on Linux CI because baseline was generated on macOS.
- Browser/OS resource limits — CI worker has less RAM/CPU; Chromium tab crashes mid-test.
- Network differences — CI may not reach localhost-only services; DNS resolution differs; corporate proxies.
- Env vars missing/different — Local has
.env, CI uses secrets; one wasn't migrated. - Data state — Local DB has data your test assumes; CI's fresh DB doesn't. (Module 31)
- Auth tokens expired or wrong — Local cached token still works; CI's fresh fetch hits a different IDP.
- Parallelism — Local single-worker; CI runs 4× in parallel, exposing data collisions.
- Time zone / locale — Local in IST; CI in UTC; date assertions drift by 5.5 hours.
- Animations / fonts / lazy resources — Local browser cached them; CI's fresh browser hasn't.
32.2 Triage playbook (the interview answer)
- Pull the trace from the failing CI run.
npx playwright show-trace ci-trace.zip. - Read the failure step — exact action, locator, timing, error text.
- Compare network / console with a green local run — what's different?
- Check env var diff:
echo $BASE_URLlocally vs CI; verify all required vars are set. - Reproduce CI conditions locally:
- Use the Playwright Docker image:
docker run -it --rm -v $PWD:/work mcr.microsoft.com/playwright:v1.50.0-jammy - Set
CI=trueenvironment variable - Run with the CI's worker count:
--workers=4 - Throttle CPU/network if the CI worker is slower
- Use the Playwright Docker image:
- Run repeated —
--repeat-each=50. If 1/50 fails locally, you've reproduced the race. - Bisect changes if it's a recent regression:
git bisectbetween last known green and current red. - Fix the root cause — not the symptom. Add a wait? No — find the missing assertion or fix the locator.
- Lift quarantine once
--repeat-each=100passes on CI for two consecutive runs.
32.3 Quick deterministic-by-design patterns
| Pattern | What it does |
|---|---|
| Web-first assertions on locators | Auto-retries until pass — no timing assumptions |
| Promise-first on events | Listener registered before action — no missed events |
expect.poll for async writes | Polls until expected state — survives webhook delay |
Frozen clock (test endpoint + page.clock) | Deterministic time-based behaviour |
| Fixed timezone + locale in config | Removes locale-dependent assertion flakes |
| Mock unstable upstreams | Removes third-party flakiness |
| Per-test fresh user/tenant | Removes data collisions |
| Block image/font/ad requests | Faster, fewer external timing dependencies |
32.4 Specific recipes for the top causes
(a) Timing race
// BAD
await page.click('#load-orders');
await page.waitForTimeout(2000);
expect(await page.textContent('#count')).toBe('3');
// GOOD
await page.click('#load-orders');
await expect(page.locator('#count')).toHaveText('3'); // auto-retries
(b) OS rendering on visual tests
# Generate baselines on the same OS image CI uses docker run --rm -it -v $PWD:/work -w /work mcr.microsoft.com/playwright:v1.50.0-jammy \ npx playwright test --update-snapshots
(c) Resource constraint
// playwright.config.ts — limit workers on CI if RAM is tight
export default defineConfig({
workers: process.env.CI ? 2 : 4,
/* or scale to CPU count: workers: '50%' */
});
(d) Timezone
use: {
timezoneId: 'UTC', // every project uses UTC
locale: 'en-US',
}
(e) Parallel data collision
// Use a unique email per test
const email = `qa+${Date.now()}-${faker.string.alphanumeric(6)}@test.com`;
32.5 The classic interview trap question
"How would you debug a test that passes locally but fails on CI?"
Answer in this exact structure:
(1) "First, pull the trace from CI and read what step actually failed."
(2) "Then I look for env differences — vars, timezone, OS image, browser binary version, CI vs local."
(3) "Reproduce CI conditions locally — Playwright Docker image, CI worker count, throttled CPU. Run with
(4) "If it's a recent regression,
(5) "Fix the root cause — typically replacing
(6) "Quarantine if the fix needs research; lift once
Answer in this exact structure:
(1) "First, pull the trace from CI and read what step actually failed."
(2) "Then I look for env differences — vars, timezone, OS image, browser binary version, CI vs local."
(3) "Reproduce CI conditions locally — Playwright Docker image, CI worker count, throttled CPU. Run with
--repeat-each=50."(4) "If it's a recent regression,
git bisect."(5) "Fix the root cause — typically replacing
waitForTimeout with a web-first assertion, fixing a race, or eliminating shared data."(6) "Quarantine if the fix needs research; lift once
--repeat-each=100 is green on CI."Module 32 — Flakiness Q&A bank
Why do tests pass locally but fail on CI?
Ten common reasons: timing races (CI slower), OS rendering differences (fonts/AA), resource limits (less RAM), network/DNS, missing env vars, data state, expired auth, parallel collisions, timezone/locale, missing animations/fonts in fresh browser. Triage by pulling the trace, diffing env, then reproducing CI conditions in a Docker image.
How do you reproduce a CI failure locally?
Use the Playwright Docker image (
mcr.microsoft.com/playwright), set CI=true, run with the CI worker count, throttle CPU/network, run --repeat-each=50. Most timing races appear within 10–20 repeats.How do you eliminate timing flakiness?
Replace
page.waitForTimeout with web-first assertions (expect(locator).toBeVisible/toHaveText/toBeEnabled). Use expect.poll for backend-driven state. Use the promise-first pattern for events. Never assert on extracted values — always on locators.How do you handle visual flake across OSes?
Generate baselines on the same OS image CI uses (Linux via Playwright Docker image). Loosen with
maxDiffPixelRatio: 0.01 if AA noise is unavoidable. Per-project baselines (Playwright auto-names by browser + OS).What's the right retry strategy?
0 locally (flake = real bug). 1–2 on CI to absorb infra noise. Trace on first retry. Track retry rate as a quality metric — high retry rate means you're hiding flakiness with patience.
How do you bisect a recently introduced flake?
git bisect start, mark the last known green and current red, run the test (or a repeat-each loop) at each commit. Bisect identifies the offending commit; from there it's a focused review.If tests pass on retry, is the flake "fixed"?
No — it's hidden. The underlying race or data leak still exists. Retry buys time, doesn't solve. Track retry rate; quarantine tests that need retries to pass and fix them properly.
How do timezones cause flakes?
Asserting on "today's date" or "X days ago" depends on the runner's timezone. Local runner in IST, CI in UTC, 5.5-hour drift = different date string. Fix: configure a fixed timezone in
use globally (UTC is common).What's the danger of shared QA accounts in parallel CI?
Two tests in parallel both modify the same user's data, one observes the other's intermediate state and fails. Per-test factory + tenant isolation eliminates this.
How would you investigate a 1-in-100 flake?
Run
--repeat-each=200 overnight with traces always on. When it fails, scrutinise the failing trace's network and console — usually a specific upstream call's timing or order is different. Compare with a green trace. Patterns of difference reveal the race.What's expect.poll and why?
Custom polling assertion for asynchronous state:
expect.poll(async () => (await getStatus()).value).toBe('ready'). Retries the function for up to a timeout. Use for backend-driven changes (webhook, async worker) instead of arbitrary sleeps.One-sentence pitch on flakiness?
"A flaky test is a test that's lying to you — either passing falsely or failing falsely. Treat every flake as a real bug in the test or the system; never just hide it with retries."