One flaky checkout test, a shared staging database, and a debounce test that only fails in company
Your Playwright suite has 212 tests. checkout.spec.ts fails about 1 run in 40 on CI, always on the same assertion: after page.click('text=Save') the test waits and then expects the toast "Saved". It has never failed on a developer laptop. The code is await page.click('text=Save'); await page.waitForTimeout(500); expect(await page.locator('.toast').isVisible()).toBe(true);. CI runs 4 workers against one shared staging database seeded once per pipeline, and every test creates records named "Test Customer". CI machines run in UTC; laptops run in UTC+2. A separate Jest unit test for a 300 ms debounced search passes alone but fails when the whole file runs: it fires five keystrokes with fake timers installed and immediately asserts the mock was called once. Retries are set to 2, so the pipeline is usually green and nobody investigates.
1. Explain each reason the current e2e assertion is unreliable, and rewrite it so it is deterministic. Include where the wait should actually be anchored.
Read the worked answer
Three separate defects:
waitForTimeout(500)is a fixed guess. It is simultaneously too slow on every green run and too short whenever the 1.8 s API plus rendering exceeds it - and under 4 parallel workers on a loaded CI box, 500 ms is often not enough while a laptop idles at 80 ms. Arbitrary sleeps produce exactly this 1-in-40 signature: usually fine, occasionally not, with no diagnostic.locator.isVisible()does not auto-wait. Unlikeexpect(locator).toBeVisible(), the bare method returns the current state immediately, so the assertion is a point-in-time sample, not a condition. Wrapping it inawaitchanges nothing; theexpect(await ...)order makes it look like it waits when it does not.text=Saveis an ambiguous, presentation-coupled selector. Once a second Save control exists, or the label is localised or a button gains an aria-label, Playwright either resolves to a different element or throws strict-mode violations that look like timing failures.
The wait should be anchored to the event that actually determines the outcome - the network response - not to time or to the UI:
const save = page.getByRole('button', { name: 'Save' });
await Promise.all([
page.waitForResponse(r => r.url().endsWith('/api/customer') && r.request().method() === 'PATCH' && r.status() === 200),
save.click(),
]);
await expect(page.getByRole('status')).toHaveText('Saved'); // auto-retries
Use a role-based locator, assert on a status region the way a screen reader would see it, and only then check any downstream UI. If the toast is the contract, assert it with toBeVisible(), which polls. Never waitForTimeout in a suite that must be trusted.
2. The shared database and the timezone difference are both isolation failures. Explain how each produces flakiness and what you would change, and take a position on the retry setting.
Read the worked answer
Shared database, 4 workers. Each worker is an independent process creating records with the same literal name "Test Customer". Any test that queries a list, counts rows, asserts "the newest customer", or relies on a unique constraint is racing the other three workers. Classic symptoms are order-dependent passes, tests that fail only in the full run, and a suite that goes green when run singly. Fix: give each worker its own namespace - a per-worker schema or database, or a per-test unique key such as a UUID suffix plus a workerIndex prefix - and tear down or ignore by key rather than truncating shared tables. Seed data must be a fixture created by the test that needs it, not a global pipeline step that another test can mutate. If the stack makes per-worker databases impossible, run the mutating specs serially and keep the parallel pool for read-only ones - a small, explicit serial group beats a suite that is 4× faster and untrustworthy.
Timezone. UTC on CI and UTC+2 on laptops changes any behaviour that formats or buckets by local date: "today's orders", month boundaries, new Date('2024-03-01') versus new Date('2024-03-01T00:00:00'), and DST transitions. A test that passed at 14:00 local can fail between 00:00 and 02:00 on a UTC runner. Fix: set TZ=UTC and a fixed locale in the test config for all environments, and inject a clock rather than reading the real one. Note that setting TZ at runtime in Node is unreliable once the process has started; set it in the environment or use fake timers.
Retries. Retries are for detecting flakiness, not for hiding it. Keep them, but treat every retry that succeeds as a failure with a lower priority: record it, alert on the rate, and require the flake to be fixed. Set retries to 0 on the main branch for a period to make the cost visible, and 2 only on a nightly or pre-release job.
3. Diagnose the debounce unit test. Explain why it passes alone and fails in the file, and describe the fix plus how you would investigate the CI-only e2e failure.
Read the worked answer
The debounce test. A debounce by definition does not call the mock synchronously; the assertion "called once immediately after five keystrokes" is only valid if the timer has been advanced. Alone it may pass for an unrelated reason - a leaked real timer from a previous state, an unreset module registry, or the mock being shared and already holding calls from an earlier test in the same file. In the file run, something resets mocks or the fake-timer state is inherited: if jest.useFakeTimers() was installed by an earlier test and not restored, or useRealTimers() was called mid-sequence, advanceTimersByTime no longer drives the debounce and the call never happens.
Fix: make the test self-contained and explicit.
beforeEach(() => { jest.useFakeTimers(); });
afterEach(() => { jest.useRealTimers(); jest.clearAllMocks(); });
it('debounces to one call', () => {
for (const ch of 'hello') fireInput(ch);
expect(search).not.toHaveBeenCalled(); // correct: still pending
jest.advanceTimersByTime(299);
expect(search).not.toHaveBeenCalled();
jest.advanceTimersByTime(1);
expect(search).toHaveBeenCalledTimes(1);
expect(search).toHaveBeenCalledWith('hello');
});
Asserting the negative first is what actually proves debouncing; the original test would pass for an implementation with no debounce at all.
CI-only e2e failure. Do not guess - capture evidence. Enable trace, video, and screenshot on first retry, and upload the Playwright HTML report and server logs as artefacts; the trace shows the DOM snapshot, network waterfall, and console at the failure. Reproduce the CI conditions locally: 4 workers, TZ=UTC, a cold cache, and CPU throttling. Use --repeat-each=20 on the single spec to get a fast failure rate, and git bisect if a date range can be identified. Then check the network tab for the PATCH request that returns 5xx under load, or a race where the test navigates away before the response arrives - both invisible with a 500 ms sleep, both obvious in a trace.