Root Causes of Flaky Tests: How to Identify and Fix Them
Flaky tests are the silent killers of CI/CD confidence. A test that passes sometimes and fails other times isn't just annoying—it erodes trust in your entire test suite. When developers start ignoring red builds because "it's probably just a flaky test," you've lost your safety net.
This guide breaks down the root causes of test flakiness and gives you concrete methods to identify which category each failure falls into.
What Makes a Test Flaky?
A flaky test is non-deterministic: given the same code, it produces different results on different runs. This non-determinism always has a cause. The goal is to find it.
There are roughly eight categories:
- Race conditions and timing dependencies
- Shared mutable state
- External service dependencies
- Order-dependent tests
- Resource contention
- Date/time sensitivity
- Random data
- Infrastructure variance
Race Conditions and Timing Issues
This is the most common cause, especially in end-to-end and integration tests.
// Flaky: assumes element appears within 1 second
await page.click('#submit');
await page.waitForTimeout(1000);
expect(await page.isVisible('#success-message')).toBe(true);The test passes when the server responds fast. It fails when the server is under load. Fix it by waiting for the condition, not for time:
// Stable: waits for the actual outcome
await page.click('#submit');
await page.waitForSelector('#success-message', { state: 'visible', timeout: 10000 });How to Identify Timing Issues
Look for:
sleep(),waitForTimeout(), orThread.sleep()in test code- Assertions that run immediately after async operations
- Tests that fail more frequently on CI (slower) than locally
Run the test 10 times in a loop to observe the failure rate:
for i in {1..10}; do
npx jest --testPathPattern="my-test" 2>&1 | tail -1
doneIf the failure rate is 20-40%, you likely have a timing issue.
Shared Mutable State
Tests that share state through global variables, databases, or file systems will interfere with each other.
# Flaky: test_b assumes test_a hasn't modified the user
def test_a():
user = get_user(1)
user.email = "changed@example.com"
save(user)
assert user.email == "changed@example.com"
def test_b():
user = get_user(1)
assert user.email == "original@example.com" # fails if test_a ran firstFix this by isolating state:
- Use transactions that roll back after each test
- Use unique identifiers per test run
- Create test fixtures that clean up after themselves
@pytest.fixture(autouse=True)
def fresh_user(db):
user = User.create(email="test@example.com")
yield user
user.delete()How to Identify Shared State Issues
Run your test suite in different orders:
# pytest: randomize order
pytest --randomly-seed=random
# Jest: run in band (sequential, but randomized)
jest --runInBand --testSequencer=./random-sequencer.jsIf different orderings produce different failures, you have state leakage.
External Service Dependencies
Tests that call real external services—APIs, databases on other machines, cloud services—will fail when those services are slow or unavailable.
// Flaky: depends on external API response time
it('fetches user data', async () => {
const user = await externalApi.getUser(123);
expect(user.name).toBe('Alice');
});Solutions:
- Mock external dependencies in unit tests
- Use contract testing (see Pact) for integration boundaries
- Use VCR-style recording/playback (nock, ruby-vcr)
- Separate tests that require external services and run them less frequently
// Stable: mocked external dependency
jest.mock('../api/external');
externalApi.getUser.mockResolvedValue({ name: 'Alice' });
it('fetches user data', async () => {
const user = await externalApi.getUser(123);
expect(user.name).toBe('Alice');
});Order-Dependent Tests
Some tests only pass when run after a specific other test. This usually happens when:
- One test creates data another test reads
- One test sets global configuration another test depends on
- Setup logic is embedded in test bodies instead of fixtures
describe('shopping cart', () => {
it('adds item to cart', () => {
cart.add(item);
// saves item to cart
});
it('removes item from cart', () => {
cart.remove(item); // fails if run independently—item was never added
});
});Fix by making each test independent:
describe('shopping cart', () => {
beforeEach(() => {
cart.clear();
});
it('adds item to cart', () => {
cart.add(item);
expect(cart.items).toContain(item);
});
it('removes item from cart', () => {
cart.add(item); // sets up its own precondition
cart.remove(item);
expect(cart.items).not.toContain(item);
});
});Date and Time Sensitivity
Tests that depend on the current date will fail as time passes.
# Flaky: fails after 2024
def test_user_is_not_expired():
user = User(expires_at=datetime(2024, 12, 31))
assert not user.is_expired() # fails in 2025Always control time in tests:
from freezegun import freeze_time
@freeze_time("2024-06-01")
def test_user_is_not_expired():
user = User(expires_at=datetime(2024, 12, 31))
assert not user.is_expired()In JavaScript, use jest.useFakeTimers() or libraries like sinon.
Random Data
Tests that generate random input will produce different results:
// Flaky: random input may not satisfy the condition
const price = Math.random() * 100;
expect(formatPrice(price)).toMatch(/^\$\d+\.\d{2}$/);
// Fails when price rounds to something like "$5.1" (only 1 decimal place)Fix: use deterministic test data, or seed your random number generator:
// Stable: fixed inputs test the actual cases
test.each([
[10.5, '$10.50'],
[100, '$100.00'],
[0.1 + 0.2, '$0.30'], // test floating point edge case
])('formatPrice(%f) === %s', (input, expected) => {
expect(formatPrice(input)).toBe(expected);
});Infrastructure Variance
Tests fail on CI but pass locally because the CI environment differs:
- Different OS (Windows vs Linux path separators)
- Different locale/timezone settings
- Different available memory
- Port conflicts from parallel test runs
For parallel test runs, ensure tests use unique ports:
// Use dynamic port allocation
const server = app.listen(0); // OS assigns available port
const port = server.address().port;For timezone issues:
# In CI, always set timezone explicitly
TZ=UTC npx jestA Systematic Approach to Identification
When you encounter a flaky test, work through this checklist:
- Run it 20 times locally — determine the failure rate
- Run it in isolation — remove test suite interference
- Run it with CI constraints — lower CPU, memory limits
- Check for async operations — any place that doesn't await
- Check for shared state — databases, files, globals, singletons
- Check for hardcoded values — dates, ports, file paths
- Check for external calls — network, filesystem, environment variables
Tools that help:
- jest-circus — better async error reporting
- pytest-rerunfailures — re-run and log flaky tests
- Playwright's retry — built-in retry with trace recording
- Test Impact Analysis — identify which tests change frequently
Using HelpMeTest to Detect Flakiness
HelpMeTest's continuous monitoring runs your tests repeatedly against your live application. When a test fails intermittently, HelpMeTest tracks the failure rate over time and surfaces patterns—so you're not manually running tests 20 times to diagnose flakiness.
The self-healing test feature also detects when a test is failing due to UI changes rather than application bugs, preventing false flakiness reports when your UI is intentionally updated.
# Run tests with monitoring enabled
helpmetest run --monitor --retry 3When flakiness is detected, HelpMeTest logs the full browser trace and network traffic for each failure, giving you the evidence you need to find the root cause.
Summary
| Root Cause | Detection Method | Fix |
|---|---|---|
| Race conditions | Run 10x in a loop | Replace sleep with condition waits |
| Shared state | Randomize test order | Use transactions, fixtures with cleanup |
| External services | Isolate from network | Mock or record/replay |
| Test ordering | Run tests independently | Self-contained setup/teardown |
| Date sensitivity | Check for hardcoded dates | Freeze time in tests |
| Random data | Look for Math.random() |
Use deterministic seeds or fixed inputs |
| Infrastructure | Run with CI constraints | Dynamic ports, explicit timezone |
Flaky tests are a symptom of non-deterministic design. Fix the design, and the flakiness goes away.