78. Flakiness, debugging and CI: war stories from this suite
The first green run of Support Desk's suite was not trustworthy yet. A suite is trustworthy when it is green repeatedly, in any order, in parallel. The flake detector is one command:
npx playwright test --repeat-each=10 --workers=8 # each test 10×, in parallel
Here is what it found, in order. Each problem is common in real projects.
1. The a11y scan failed on the first run. The chat transcript was
<ol role="log">. An explicit role replaces the element's native role, so
the <ol> was no longer a list, and its <li> children were "list items
outside a list" (axe rule listitem). The fix was a <div role="log">
containing <div> messages. The test found a real problem for
screen-reader users before any human tester.
2. A refund test broke other tests, depending on order. The test
refunded A-1002, which other tests read, and a CI retry of the refund
test would itself fail with "already in progress". The fix was the
seedOrder fixture (§76): tests that write create their own data.
3. The suite rate-limited itself. All tests share Alice's session (via
storageState), and the server allows 20 chats per minute per session.
At --repeat-each=5 the 21st chat got a 429, and a dozen unrelated tests
failed with a 5-second timeout. The fix was to make the limits
configurable (CHAT_LIMIT in the webServer env) and give the rate-limit
tests their own keys. The production default stays at 20.
4. Successful logins counted toward the login limit. The same run
showed that the limiter counted every login. Six correct sign-ins in a
minute locked a user out, which is an availability bug in the app, not
the test. The fix was to count only failed attempts (check() before
verifying, hit() on failure), which is also the correct production
behavior.
5. Repeated wrong-password tests locked out Alice. After fix 4, the
"wrong password" test still burned Alice's failure budget at
--repeat-each=10, and later logins as Alice got 429s. This is the classic
lockout trade-off, met in a test. The test now uses a throwaway email per
run (nobody-${repeatEachIndex}-${retry}@example.com), and the
rate-limit test does the same (ratelimit-${workerIndex}-…). It still
proves what matters: the error message is generic.
6. Ownership assertions, not exact lists. "Alice has exactly
[A-1001, A-1002]" failed once other tests had seeded orders for her. The
fix asserts the property under test: Alice's list contains her orders and
not Bob's.
After those fixes: 251 passed at --repeat-each=10.
The general rules these stories teach
- No shared mutable state between tests. Seed per test, or isolate per
worker (
testInfo.workerIndex→ its own user or tenant). - Test-only knobs come from the environment, never from code paths
compiled into production by accident. Keep
/__test__/*behindNODE_ENV=testand test that it is absent in production. - Wait on states, never on time. Use
toHaveAttribute("aria-busy", "false"), notwaitForTimeout(500). - Use unique keys for anything with memory: rate limiters, caches, lockouts, idempotency keys.
- Run
--repeat-eachbefore trusting a new suite, and again whenever a test flakes in CI.
Debugging a failing test
| Tool | Command | Use it for |
|---|---|---|
| UI mode | npx playwright test --ui |
watch mode, time-travel through each step with DOM snapshots |
| Inspector | npx playwright test -g "refund" --debug |
step through actions, edit locators live |
| Trace viewer | npx playwright show-trace test-results/…/trace.zip |
CI failures: DOM, network, console and source for every step |
| Codegen | npx playwright codegen http://127.0.0.1:3100 |
record a flow and get suggested role-based locators |
| Last failed | npx playwright test --last-failed |
rerun only what just failed |
| Changed only | npx playwright test --only-changed=origin/main |
run tests affected by your branch |
A trace from CI is worth more than any log. trace: "on-first-retry"
records one automatically whenever a test needed a retry.
CI: GitHub Actions with sharding
# .github/workflows/e2e.yml
name: e2e
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix: { shard: [1, 2, 3, 4] }
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22, cache: npm }
- run: npm ci
- run: npm audit signatures # supply-chain check (§65 A03)
- run: npx playwright install --with-deps chromium
- run: npx playwright test --shard=${{ matrix.shard }}/4 --reporter=blob --grep-invert @visual
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with: { name: blob-report-${{ matrix.shard }}, path: blob-report, retention-days: 7 }
report:
needs: test
if: ${{ !cancelled() }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22, cache: npm }
- run: npm ci
- uses: actions/download-artifact@v4
with: { path: all-blob-reports, pattern: blob-report-*, merge-multiple: true }
- run: npx playwright merge-reports --reporter html ./all-blob-reports
- uses: actions/upload-artifact@v4
with: { name: html-report, path: playwright-report, retention-days: 14 }
- Sharding splits the suite across machines, and blob reports merge back into one HTML report with traces.
- Test preview deployments, not just localhost. Make
baseURLreadprocess.env.BASE_URLwhen it is set, skipwebServerthen, and run the same suite against every pull request's preview URL. - Gate merges on it. A required status check, plus a local pre-commit hook for fast feedback, keeps the suite trusted. frontendlabs.xyz, the site this guide is published on, runs its own Playwright suite on every commit in the same way.
🎯 Challenges. (1) Add Firefox and WebKit projects and find what differs. Hint:
<dialog>focus and CDP-only tests. (2) Write a test that runs the production build (NODE_ENV=production) and asserts/__test__/ordersreturns 404 and the cookie hasSecure. (3) Add a network-throttled project (page.routewith delays, or CDP'sNetwork.emulateNetworkConditions) and assert the UI still shows progress. (4) Turn one of §77's mocks into a HAR recording withpage.routeFromHARand compare the trade-offs.