Every engineering team has that one test. You know the one. It fails every third run. Nobody wants to fix it because it "mostly works." But "mostly works" means "sometimes blocks your deploy" — and that's not good enough.
After analyzing thousands of flaky test patterns across CI pipelines, here are the 10 strategies that actually move the needle, ordered from quick wins to structural changes.
Strategy 1: Replace Hard Waits with Smart Assertions
The #1 cause of flakiness across all frameworks.
Hard waits (sleep, setTimeout, cy.wait(5000)) are a gamble. Sometimes 500ms is enough. Sometimes the API takes 800ms. You either wait too long (slow tests) or not long enough (flaky tests).
Before (Flaky):
await page.click('#submit');
await page.waitForTimeout(3000); // gambling
expect(await page.locator('.success').isVisible()).toBe(true);
After (Stable):
await page.click('#submit');
await expect(page.locator('.success')).toBeVisible(); // waits until it appears
Every modern test framework has assertion-based waiting:
-
Playwright:
await expect(locator).toBeVisible() -
Cypress:
cy.get('.success').should('be.visible') -
Jest + Testing Library:
await waitFor(() => expect(screen.getByText('Success')).toBeInTheDocument())
Strategy 2: Use Stable, User-Visible Selectors
Tests that rely on CSS structure break when the UI changes. A designer wraps content in a new div, and your nth-child(3) selector finds the wrong element.
Before (Flaky):
await page.click('.card > div > button:nth-child(2)');
After (Stable):
await page.getByRole('button', { name: 'Submit Order' });
await page.getByTestId('submit-order-button');
await page.getByLabel('Email address');
Prioritize selectors in this order:
-
Role + name (
getByRole) — most stable, most accessible -
Test ID (
getByTestId) — explicit, never changes with UI -
Label (
getByLabel) — tied to form semantics -
Text content (
getByText) — user-visible, but watch for duplicates - CSS selector — last resort, most brittle
Strategy 3: Isolate Test Data Per Test
Tests sharing data is a silent flakiness bomb. Test A creates a user. Test B assumes that user exists. When Test B runs before Test A (parallel execution), it fails.
The Fix:
- Create fresh data in
beforeEachor at the start of each test - Use unique identifiers (UUIDs, timestamps) to prevent collisions
- Clean up test data in
afterEach - For databases, use transactions that rollback after each test
beforeEach(async () => {
const uniqueEmail = `test-${Date.now()}@example.com`;
await createTestUser({ email: uniqueEmail });
});
Strategy 4: Mock External Dependencies at the Network Level
Tests that call real external APIs (Stripe, SendGrid, AWS) will fail when those services are slow, rate-limited, or down.
The Fix: Intercept network requests at the browser/driver level, not by modifying application code.
Playwright:
await page.route('**/api/stripe/charge', (route) => {
route.fulfill({
status: 200,
body: JSON.stringify({ id: 'ch_mock', status: 'succeeded' }),
});
});
Cypress:
cy.intercept('POST', '**/api/stripe/charge', {
statusCode: 200,
body: { id: 'ch_mock', status: 'succeeded' },
});
Strategy 5: Eliminate Test Ordering Dependencies
If testA.js must run before testB.js, you have a hidden dependency that will break in parallel mode.
Signs of order dependency:
- Tests pass sequentially but fail in parallel
- A specific test always fails when the suite runs but passes individually
- Flipping test file names changes which tests fail
Fixes:
- Each test must be independently runnable
- Use
beforeAll/beforeEachto set up prerequisites - Run tests in random order in CI (
--shuffleflag in Jest) - If you can't make a test independent, it's an integration test, not a unit test
Strategy 6: Handle Date and Time Properly
Time-dependent tests fail at boundaries: midnight, month-end, daylight saving, leap years.
Fixes:
- Inject a clock dependency instead of using
Date.now()directly - Use test-specific fixed dates
- Test across timezone boundaries if your app is multi-timezone
Jest:
jest.useFakeTimers().setSystemTime(new Date('2026-07-09T12:00:00Z'));
// ... test ...
jest.useRealTimers();
Strategy 7: Deduplicate and Consolidate Overlapping Tests
Some flakiness comes from test count. 2,000 tests running in parallel creates more resource contention than 500 well-structured tests. Many teams have redundant tests that cover the same code path 3-5 times.
Audit your test suite:
- Run tests with coverage reporting
- Identify code paths with 5+ tests covering them
- Keep the most specific test, delete the rest
- Replace 10 narrow unit tests with 1 integration test
This doesn't just reduce flakiness — it makes CI faster.
Strategy 8: Containerize Your Test Environment
"Different results on my machine vs CI" usually means environment differences. Different Node versions, different browser versions, different system libraries.
Fixes:
- Use Docker containers for CI test runs
- Pin exact versions in
package.json(not^ranges) - Use
package-lock.jsonand commit it - Run the same browser version locally and in CI
FROM mcr.microsoft.com/playwright:v1.45.0-jammy
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npx playwright install --with-deps
CMD ["npx", "playwright", "test"]
Strategy 9: Implement the 95/30 Rule
Before investing time in fixing a flaky test, prove it's actually flaky with data.
The 95/30 Rule: Run the test 30 times. If it doesn't pass at least 95% of the time (28+ passes), quarantine it.
This prevents spending hours "fixing" a test that's actually revealing a real intermittent bug.
# Quick flakiness check
for i in {1..30}; do
npx playwright test --grep "checkout flow" --reporter=line 2>&1 | tail -1
done | sort | uniq -c
Strategy 10: Add Real-Time Failure Classification to CI
The most impactful structural change: instead of treating every CI failure equally, classify each failure as "likely real" or "likely flaky" in real-time.
This means:
- Real failures → Block the merge, alert the team
- Likely flaky failures → Auto-flag, don't block, track for fixing later
This eliminates the diagnostic time cost — engineers no longer waste 15-20 minutes investigating failures that turn out to be flaky. They see a classified failure and immediately know whether to investigate or re-run.
The Reality: You Can't Fix Them All
Here's the uncomfortable truth: in any non-trivial test suite, you will always have some level of flakiness. New code introduces new race conditions, new async patterns create new timing issues, and new dependencies add new failure modes.
The most successful teams don't try to eliminate flakiness entirely. They:
- Fix the top offenders (strategies 1-8 above)
- Quarantine the chronic ones (strategy 9)
- Classify failures in real-time so flaky ones don't waste team time (strategy 10)
The combination of fixing what you can and intelligently handling what you can't is what separates teams with 5-minute CI feedback loops from teams stuck in "re-run and pray" mode.
Stop wasting engineering hours on false alarms. Poly classifies every CI failure in real-time — real bugs get fixed, flaky tests get tracked, and your team gets their time back.
Top comments (0)