Dealing with Flaky Jest Tests: Patterns, Anti-Patterns, and Solutions
Jest is the most widely used JavaScript testing framework, powering test suites for React, Node.js, and everything in between. Its speed, developer experience, and rich feature set have made it the default choice for millions of developers. But with great adoption comes a widespread problem: flaky Jest tests.
A flaky test is one that produces different results across multiple runs without any changes to the code under test. In Jest, flakiness often manifests as tests that pass locally but fail in CI, tests that fail only when run with the full suite, or tests that mysteriously break after an unrelated change.
This guide dissects every major category of Jest flakiness, explains why each happens at a technical level, and provides proven patterns to eliminate them from your codebase.
Understanding Jest's Execution Model
Before fixing flaky tests, you need to understand how Jest actually runs your code. This knowledge is fundamental to diagnosing most flakiness issues.
Worker Processes and Parallelism
Jest runs test files in parallel using worker processes. By default, it uses one worker per CPU core. Each test file gets its own isolated Node.js process, which means:
- Global state is not shared between test files (but is shared between tests within the same file)
- Module caches are separate per worker
- Environment setup (like JSDOM) is fresh per file
This isolation model is powerful, but it has implications for flakiness that many developers overlook.
The Test Lifecycle
Within a single test file, Jest follows this lifecycle:
- Execute all top-level code (module imports, variable declarations)
describe, it/test, beforeAll, beforeEach, afterEach, afterAll callbacksbeforeAll hooksbeforeEach, run the test, execute afterEachafterAll hooksUnderstanding this lifecycle is critical because flaky tests often result from assumptions about when code runs within this sequence.
Category 1: Async/Await Issues
Asynchronous code is the single most common source of flaky Jest tests. JavaScript's event loop and Jest's test runner interact in ways that can produce unpredictable results if you are not careful.
The Forgotten Return Statement
The most basic async mistake is forgetting to return a promise:
// FLAKY: Jest doesn't know about the async operation
test('fetches user data', () => {
fetchUser(1).then((user) => {
expect(user.name).toBe('John');
});
// Test passes immediately, before the promise resolves!
});
// CORRECT: Return the promise
test('fetches user data', () => {
return fetchUser(1).then((user) => {
expect(user.name).toBe('John');
});
});
// BETTER: Use async/await
test('fetches user data', async () => {
const user = await fetchUser(1);
expect(user.name).toBe('John');
});
When you forget to return the promise, Jest considers the test complete as soon as the synchronous code finishes. The assertion runs later, after Jest has already moved on. If the assertion fails, Jest might report the error in the wrong test or not at all.
Multiple Async Operations
When dealing with multiple async operations, the order of resolution matters:
// FLAKY: Race condition between parallel operations
test('updates user and sends notification', async () => {
updateUser(1, { name: 'Jane' });
sendNotification(1, 'Profile updated');
const user = await getUser(1);
const notifications = await getNotifications(1);
expect(user.name).toBe('Jane');
expect(notifications).toHaveLength(1);
});
// CORRECT: Await each operation or use Promise.all
test('updates user and sends notification', async () => {
await updateUser(1, { name: 'Jane' });
await sendNotification(1, 'Profile updated');
const user = await getUser(1);
const notifications = await getNotifications(1);
expect(user.name).toBe('Jane');
expect(notifications).toHaveLength(1);
});
// ALSO CORRECT: Parallel but properly awaited
test('updates user and sends notification', async () => {
await Promise.all([
updateUser(1, { name: 'Jane' }),
sendNotification(1, 'Profile updated'),
]);
const [user, notifications] = await Promise.all([
getUser(1),
getNotifications(1),
]);
expect(user.name).toBe('Jane');
expect(notifications).toHaveLength(1);
});
Unhandled Promise Rejections
Unhandled promise rejections can cause flaky test failures that appear in the wrong test:
// FLAKY: If someAsyncSetup() rejects, the error might surface in a different test
beforeEach(() => {
someAsyncSetup(); // Missing await!
});
// CORRECT: Always await async operations in hooks
beforeEach(async () => {
await someAsyncSetup();
});
Testing Code That Uses Callbacks
Legacy code using callbacks needs special handling:
// FLAKY: done() might not be called if the callback has an error
test('reads file content', (done) => {
readFile('/path/to/file', (err, data) => {
expect(data).toContain('expected content');
done();
});
});
// CORRECT: Handle errors and wrap in a promise
test('reads file content', () => {
return new Promise((resolve, reject) => {
readFile('/path/to/file', (err, data) => {
if (err) return reject(err);
expect(data).toContain('expected content');
resolve();
});
});
});
Event Emitter Testing
Testing event emitters is particularly prone to flakiness:
// FLAKY: Event might fire before listener is attached
test('emits data event', (done) => {
const stream = createStream();
stream.start(); // Might emit 'data' before the listener below is attached
stream.on('data', (chunk) => {
expect(chunk).toBeDefined();
done();
});
});
// CORRECT: Attach listener before starting
test('emits data event', (done) => {
const stream = createStream();
stream.on('data', (chunk) => {
expect(chunk).toBeDefined();
done();
});
stream.start(); // Now the listener is ready
});
Microtask and Macrotask Ordering
JavaScript has two task queues: microtasks (promises, queueMicrotask) and macrotasks (setTimeout, setInterval, I/O). Their ordering can cause flakiness:
// FLAKY: Depends on microtask vs macrotask ordering
test('processes queue items', async () => {
const results = [];
setTimeout(() => results.push('timeout'), 0);
Promise.resolve().then(() => results.push('promise'));
queueMicrotask(() => results.push('microtask'));
// Need to flush both queues
await new Promise((resolve) => setTimeout(resolve, 0));
expect(results).toEqual(['promise', 'microtask', 'timeout']);
});
To properly test code that mixes microtasks and macrotasks, use Jest's timer mocks (covered in the next section).
Category 2: Timer Mocks Gone Wrong
Jest's fake timer system (jest.useFakeTimers()) is essential for testing time-dependent code. But it is also a common source of flakiness when used incorrectly.
The Global Timer State Problem
Fake timers replace global timer functions (setTimeout, setInterval, Date). If you forget to restore them, subsequent tests break:
// FLAKY: Leaks fake timers to other tests
describe('debounce function', () => {
test('debounces calls', () => {
jest.useFakeTimers();
const fn = jest.fn();
const debounced = debounce(fn, 500);
debounced();
debounced();
debounced();
jest.advanceTimersByTime(500);
expect(fn).toHaveBeenCalledTimes(1);
// Missing jest.useRealTimers()!
});
});
// CORRECT: Clean up in afterEach
describe('debounce function', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
test('debounces calls', () => {
const fn = jest.fn();
const debounced = debounce(fn, 500);
debounced();
debounced();
debounced();
jest.advanceTimersByTime(500);
expect(fn).toHaveBeenCalledTimes(1);
});
});
Fake Timers and Promises
One of the trickiest aspects of fake timers is their interaction with promises. jest.advanceTimersByTime() runs timers synchronously, but promises resolve asynchronously:
// FLAKY: Promise resolution happens after advanceTimersByTime returns
test('shows message after delay', async () => {
jest.useFakeTimers();
const result = delayedMessage('hello', 1000);
// delayedMessage returns a promise that resolves after setTimeout
jest.advanceTimersByTime(1000);
// The promise might not have resolved yet!
const message = await result;
expect(message).toBe('hello');
jest.useRealTimers();
});
// CORRECT: Flush promises between timer advances
test('shows message after delay', async () => {
jest.useFakeTimers();
const result = delayedMessage('hello', 1000);
jest.advanceTimersByTime(1000);
// Flush the microtask queue
await Promise.resolve();
const message = await result;
expect(message).toBe('hello');
jest.useRealTimers();
});
For complex scenarios with multiple timer/promise interactions, use a helper function:
async function flushPromisesAndTimers(ms) {
jest.advanceTimersByTime(ms);
// Flush microtask queue multiple times to handle chained promises
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
}
The Modern vs Legacy Timer Distinction
Jest offers two fake timer implementations: "modern" (default since Jest 27) and "legacy". They behave differently:
// Modern timers (default) - mock Date, performance.now, etc.
jest.useFakeTimers();
// Legacy timers - only mock setTimeout, setInterval, etc.
jest.useFakeTimers({ legacyFakeTimers: true });
Mixing these in the same test suite can cause inconsistent behavior. Standardize on one approach across your project.
setInterval and Cleanup
setInterval is particularly dangerous because it runs indefinitely:
// FLAKY: Interval keeps firing after test ends
test('polls for updates', () => {
jest.useFakeTimers();
const callback = jest.fn();
startPolling(callback, 1000); // Internally uses setInterval
jest.advanceTimersByTime(3000);
expect(callback).toHaveBeenCalledTimes(3);
// Missing: stop the interval!
jest.useRealTimers();
});
// CORRECT: Clean up intervals
test('polls for updates', () => {
jest.useFakeTimers();
const callback = jest.fn();
const stopPolling = startPolling(callback, 1000);
jest.advanceTimersByTime(3000);
expect(callback).toHaveBeenCalledTimes(3);
stopPolling(); // Clean up the interval
jest.useRealTimers();
});
Category 3: Module Mocking Pitfalls
Jest's module mocking system (jest.mock(), jest.spyOn()) is powerful but full of subtle traps that cause flakiness.
Mock Hoisting Surprises
jest.mock() calls are hoisted to the top of the file by Jest's transform. This means they run before any imports:
// This looks like it should work, but the mock is hoisted above the import
import { fetchData } from './api';
import { processData } from './processor';
// This mock is hoisted to BEFORE the imports above
jest.mock('./api', () => ({
fetchData: jest.fn(),
}));
// PROBLEM: If processData imports fetchData internally,
// the mock might not be applied correctly
Understanding hoisting is essential. If you need to use variables in your mock factory, use jest.mock() with a factory that references only variables prefixed with mock:
// Variables starting with 'mock' can be used in hoisted jest.mock() calls
const mockFetchData = jest.fn();
jest.mock('./api', () => ({
fetchData: mockFetchData,
}));
Spy Cleanup
jest.spyOn() modifies the original object. If you do not restore it, the spy persists:
// FLAKY: Spy accumulates calls across tests
describe('logger', () => {
test('logs info messages', () => {
const spy = jest.spyOn(console, 'log');
logger.info('test message');
expect(spy).toHaveBeenCalledWith('[INFO]', 'test message');
// Missing spy.mockRestore()!
});
test('logs warning messages', () => {
const spy = jest.spyOn(console, 'log');
logger.warn('warning');
// This might see calls from the previous test!
expect(spy).toHaveBeenCalledTimes(1);
});
});
// CORRECT: Restore spies
describe('logger', () => {
let consoleSpy;
beforeEach(() => {
consoleSpy = jest.spyOn(console, 'log').mockImplementation();
});
afterEach(() => {
consoleSpy.mockRestore();
});
test('logs info messages', () => {
logger.info('test message');
expect(consoleSpy).toHaveBeenCalledWith('[INFO]', 'test message');
});
test('logs warning messages', () => {
logger.warn('warning');
expect(consoleSpy).toHaveBeenCalledTimes(1);
});
});
Manual Mocks and __mocks__ Directory
Jest's __mocks__ directory provides automatic mocking, but it can cause confusion when some test files expect the mock and others expect the real module:
// __mocks__/database.js
module.exports = {
query: jest.fn().mockResolvedValue([]),
connect: jest.fn().mockResolvedValue(true),
};
// test-a.test.js - Expects the mock
jest.mock('./database'); // Uses __mocks__/database.js
test('handles empty results', async () => {
const results = await database.query('SELECT * FROM users');
expect(results).toEqual([]);
});
// test-b.test.js - Wants the real module but gets the mock
// if jest.mock() is accidentally present or if automock is enabled
Use explicit mock implementations when the default mock behavior matters for your test.
Partial Mocking
Sometimes you want to mock one function but keep the rest of a module real:
// Mock only one export, keep the rest real
jest.mock('./utils', () => ({
...jest.requireActual('./utils'),
fetchFromNetwork: jest.fn().mockResolvedValue({ data: 'mocked' }),
}));
This pattern is reliable but be aware that jest.requireActual() returns the original module at import time. If the original module has side effects, they will run during the mock setup.
Clearing vs Resetting vs Restoring Mocks
These three functions do different things, and using the wrong one causes flakiness:
jest.clearAllMocks() -- Clears mock call history but keeps the implementationjest.resetAllMocks() -- Clears history AND resets to default (no) implementationjest.restoreAllMocks() -- Restores spied-on methods to their original implementation// Using the wrong cleanup function
afterEach(() => {
jest.clearAllMocks(); // Only clears call counts, not implementations!
});
// If a test set mockReturnValue, it persists after clearAllMocks():
test('test A', () => {
myMock.mockReturnValue(42);
expect(myMock()).toBe(42);
});
test('test B', () => {
// myMock STILL returns 42! clearAllMocks didn't reset the implementation
expect(myMock()).toBe(42); // This "passes" but is a hidden dependency on test A
});
The safest approach for most codebases is to configure Jest to reset mocks automatically:
// jest.config.js
module.exports = {
clearMocks: true, // Automatically clear mock calls and instances
restoreMocks: true, // Automatically restore spied methods
};
Category 4: Test Isolation and Shared State
Test isolation failures are the hardest type of flakiness to debug because they only manifest when tests run in a specific order.
Global Variables and Singletons
Singletons and global variables are the most common source of shared state:
// singleton.js
class Database {
constructor() {
this.connected = false;
this.data = {};
}
connect() {
this.connected = true;
}
set(key, value) {
this.data[key] = value;
}
}
module.exports = new Database(); // Singleton!
// FLAKY: Tests share the singleton instance within the same file
test('connects to database', () => {
const db = require('./singleton');
db.connect();
expect(db.connected).toBe(true);
});
test('starts disconnected', () => {
const db = require('./singleton');
expect(db.connected).toBe(false); // FAILS! Previous test connected it
});
Fix by resetting singletons or using jest.isolateModules():
// CORRECT: Isolate module instances
test('starts disconnected', () => {
jest.isolateModules(() => {
const db = require('./singleton');
expect(db.connected).toBe(false); // Fresh instance
});
});
// OR: Reset in beforeEach
beforeEach(() => {
jest.resetModules(); // Clear the module cache
});
Environment Variable Leakage
Tests that modify process.env can affect other tests:
// FLAKY: Modifies process.env without cleanup
test('uses production API URL in production', () => {
process.env.NODE_ENV = 'production';
const config = getConfig();
expect(config.apiUrl).toBe('https://api.example.com');
// process.env.NODE_ENV is still 'production'!
});
// CORRECT: Save and restore environment variables
test('uses production API URL in production', () => {
const originalEnv = process.env.NODE_ENV;
try {
process.env.NODE_ENV = 'production';
const config = getConfig();
expect(config.apiUrl).toBe('https://api.example.com');
} finally {
process.env.NODE_ENV = originalEnv;
}
});
// EVEN BETTER: Use a helper
function withEnv(vars, fn) {
const originals = {};
Object.keys(vars).forEach((key) => {
originals[key] = process.env[key];
process.env[key] = vars[key];
});
try {
return fn();
} finally {
Object.keys(originals).forEach((key) => {
if (originals[key] === undefined) {
delete process.env[key];
} else {
process.env[key] = originals[key];
}
});
}
}
test('uses production API URL', () => {
withEnv({ NODE_ENV: 'production' }, () => {
const config = getConfig();
expect(config.apiUrl).toBe('https://api.example.com');
});
});
DOM Manipulation Leftovers (JSDOM)
When using JSDOM, DOM changes persist across tests within the same file:
// FLAKY: DOM changes leak between tests
test('adds a modal to the page', () => {
const modal = document.createElement('div');
modal.id = 'modal';
modal.innerHTML = '
Modal content
';
document.body.appendChild(modal);
expect(document.getElementById('modal')).toBeTruthy();
});
test('page has no modal initially', () => {
// FAILS: The modal from the previous test is still in the DOM
expect(document.getElementById('modal')).toBeNull();
});
// CORRECT: Clean up the DOM
afterEach(() => {
document.body.innerHTML = '';
});
Category 5: Memory Leaks and Resource Exhaustion
Jest tests that leak memory can cause failures in later tests as the worker process runs out of resources.
Identifying Memory Leaks
Run Jest with the --logHeapUsage flag to see memory consumption per test:
npx jest --logHeapUsage
If you see memory climbing steadily across test files, you have a leak.
Common Leak Sources
Event listeners not removed:// LEAKS: Event listener never removed
test('listens for resize', () => {
const handler = jest.fn();
window.addEventListener('resize', handler);
window.dispatchEvent(new Event('resize'));
expect(handler).toHaveBeenCalled();
// handler is never removed from window
});
// CORRECT: Remove listeners
test('listens for resize', () => {
const handler = jest.fn();
window.addEventListener('resize', handler);
try {
window.dispatchEvent(new Event('resize'));
expect(handler).toHaveBeenCalled();
} finally {
window.removeEventListener('resize', handler);
}
});
Unclosed connections and streams:
// LEAKS: Connection never closed
test('connects to WebSocket', async () => {
const ws = new WebSocket('ws://localhost:8080');
await waitForConnection(ws);
ws.send('ping');
const response = await waitForMessage(ws);
expect(response).toBe('pong');
// ws is never closed!
});
// CORRECT: Close connections
afterEach(async () => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.close();
await waitForClose(ws);
}
});
Large data structures in closures:
// LEAKS: Closure holds reference to large array
let cachedData;
test('processes large dataset', () => {
cachedData = generateLargeArray(1000000);
const result = processData(cachedData);
expect(result).toBeDefined();
// cachedData is never released
});
// CORRECT: Clean up large allocations
afterEach(() => {
cachedData = null;
});
Worker Configuration for Memory
Configure Jest's worker pool to handle memory issues:
// jest.config.js
module.exports = {
// Restart workers after a number of tests to prevent memory accumulation
workerIdleMemoryLimit: '512MB',
// Limit parallel workers
maxWorkers: '50%',
};
Category 6: Parallel Execution Issues
Jest runs test files in parallel by default. While this speeds up execution, it introduces potential for flakiness.
Shared External Resources
Tests that use the same database, file, or port will conflict:
// FLAKY: Two test files use the same port
// test-a.test.js
let server;
beforeAll(() => {
server = app.listen(3000); // What if test-b also uses port 3000?
});
// CORRECT: Use dynamic ports
beforeAll((done) => {
server = app.listen(0, () => { // Port 0 = random available port
const port = server.address().port;
process.env.TEST_PORT = port;
done();
});
});
File System Conflicts
Tests that read/write the same files will interfere:
// FLAKY: Both test files write to /tmp/test-output.json
test('writes results', async () => {
await writeResults('/tmp/test-output.json', results);
const data = await readFile('/tmp/test-output.json');
expect(JSON.parse(data)).toEqual(results);
});
// CORRECT: Use unique file paths
test('writes results', async () => {
const uniquePath = /tmp/test-output-${process.pid}-${Date.now()}.json;
try {
await writeResults(uniquePath, results);
const data = await readFile(uniquePath);
expect(JSON.parse(data)).toEqual(results);
} finally {
await unlink(uniquePath);
}
});
Database Isolation in Parallel Tests
For tests that hit a real database:
// Use transactions for isolation
beforeEach(async () => {
// Start a transaction that will be rolled back
await db.query('BEGIN');
});
afterEach(async () => {
// Roll back all changes
await db.query('ROLLBACK');
});
// OR: Use a unique schema per worker
const schemaName = test_worker_${process.env.JEST_WORKER_ID};
beforeAll(async () => {
await db.query(CREATE SCHEMA IF NOT EXISTS ${schemaName});
await db.query(SET search_path TO ${schemaName});
await runMigrations(schemaName);
});
afterAll(async () => {
await db.query(DROP SCHEMA ${schemaName} CASCADE);
});
Category 7: React Testing Library Specific Issues
If you use React Testing Library with Jest (the most common combination), there are additional flakiness traps.
Not Waiting for State Updates
// FLAKY: Asserting before state update completes
test('increments counter', () => {
render( );
fireEvent.click(screen.getByRole('button', { name: 'Increment' }));
// React state update might not be reflected yet
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});
// CORRECT: Use waitFor or findBy queries
test('increments counter', async () => {
render( );
fireEvent.click(screen.getByRole('button', { name: 'Increment' }));
await waitFor(() => {
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});
});
// ALSO CORRECT: Use findBy which waits automatically
test('increments counter', async () => {
render( );
fireEvent.click(screen.getByRole('button', { name: 'Increment' }));
expect(await screen.findByText('Count: 1')).toBeInTheDocument();
});
Act Warnings and Their Meaning
The infamous "not wrapped in act" warning is a sign of potential flakiness:
// CAUSES ACT WARNING: State update happens outside of act()
test('loads data on mount', async () => {
render( );
// Component fetches data on mount and updates state
// The state update happens after render, outside of act()
await screen.findByText('Data loaded');
});
// CORRECT: Ensure all updates are captured
test('loads data on mount', async () => {
render( );
// findBy already handles act() wrapping internally
expect(await screen.findByText('Data loaded')).toBeInTheDocument();
});
Cleanup Between Tests
React Testing Library automatically unmounts components after each test (if you use @testing-library/react version 9+). But custom rendered elements might not be cleaned up:
// Ensure cleanup runs
afterEach(() => {
cleanup(); // Usually automatic, but call explicitly if needed
});
Detecting Flaky Jest Tests with DeFlaky
Finding flaky tests manually is tedious. You can run your test suite repeatedly and diff the results, but that is slow and does not scale.
DeFlaky automates flaky test detection for Jest projects. It runs your tests multiple times, identifies non-deterministic tests, and calculates a FlakeScore that tells you how severe the flakiness is:
# Detect flaky tests in your Jest project
deflaky analyze --framework jest --runs 20
See detailed results
deflaky dashboard
DeFlaky goes beyond simple pass/fail tracking. It analyzes failure patterns to identify the root cause category (timing, isolation, resource) and suggests specific fixes. For Jest projects, it can detect:
- Tests that only fail when run after specific other tests (ordering dependency)
- Tests that fail more often under high parallelism (resource contention)
- Tests that fail at specific times (time-dependent logic)
- Tests that fail when worker memory exceeds a threshold (memory leaks)
This diagnostic information saves hours of manual debugging and helps you prioritize fixes by impact.
Building a Flaky-Resistant Jest Configuration
Here is a Jest configuration that minimizes flakiness:
// jest.config.js
module.exports = {
// Use a consistent test environment
testEnvironment: 'jsdom', // or 'node' for backend tests
// Automatically clear and restore mocks
clearMocks: true,
restoreMocks: true,
// Reset module registry between tests
resetModules: false, // Set to true if you have singleton issues
// Limit parallelism in CI to reduce resource contention
maxWorkers: process.env.CI ? '50%' : '75%',
// Restart workers periodically to prevent memory leaks
workerIdleMemoryLimit: '512MB',
// Fail tests that take too long (likely hanging)
testTimeout: 10000,
// Report slow tests
slowTestThreshold: 5,
// Run tests in a deterministic order (helps debug ordering issues)
// Use --randomize flag in CI to catch ordering dependencies
// Setup files
setupFilesAfterFramework: ['./jest.setup.js'],
// Collect coverage (useful for identifying untested code paths)
collectCoverageFrom: [
'src/*/.{js,jsx,ts,tsx}',
'!src/*/.d.ts',
'!src//__tests__/',
],
};
And the setup file:
// jest.setup.js
// Global error handler to catch unhandled promise rejections
process.on('unhandledRejection', (reason) => {
console.error('Unhandled promise rejection in test:', reason);
throw reason;
});
// Fail on console.error (catches React warnings, etc.)
const originalError = console.error;
beforeAll(() => {
console.error = (...args) => {
originalError.apply(console, args);
// Uncomment to make console.error fail tests:
// throw new Error(console.error called: ${args.join(' ')});
};
});
afterAll(() => {
console.error = originalError;
});
// Global timeout for async operations
jest.setTimeout(10000);
Patterns for Writing Reliable Jest Tests
Pattern 1: The AAA Pattern (Arrange, Act, Assert)
Structure every test clearly:
test('calculates total with discount', () => {
// Arrange
const cart = createCart();
cart.addItem({ name: 'Widget', price: 100, quantity: 2 });
const discount = { type: 'percentage', value: 10 };
// Act
const total = cart.calculateTotal(discount);
// Assert
expect(total).toBe(180);
});
Pattern 2: Factory Functions for Test Data
Never share mutable test data between tests:
// BAD: Shared mutable data
const testUser = { name: 'John', age: 30 };
test('updates user name', () => {
testUser.name = 'Jane'; // Modifies shared data!
expect(updateUser(testUser).name).toBe('Jane');
});
test('user has correct name', () => {
expect(testUser.name).toBe('John'); // FAILS: previous test changed it
});
// GOOD: Factory function
function createTestUser(overrides = {}) {
return {
name: 'John',
age: 30,
email: 'john@example.com',
...overrides,
};
}
test('updates user name', () => {
const user = createTestUser();
user.name = 'Jane';
expect(updateUser(user).name).toBe('Jane');
});
test('user has correct name', () => {
const user = createTestUser();
expect(user.name).toBe('John'); // Works!
});
Pattern 3: Deterministic UUIDs and Random Values
// FLAKY: Random values make assertions unpredictable
test('creates user with ID', () => {
const user = createUser('John');
expect(user.id).toBeDefined(); // Weak assertion
});
// RELIABLE: Mock random generators
test('creates user with ID', () => {
jest.spyOn(crypto, 'randomUUID').mockReturnValue('test-uuid-123');
const user = createUser('John');
expect(user.id).toBe('test-uuid-123');
crypto.randomUUID.mockRestore();
});
Pattern 4: Snapshot Testing Done Right
Snapshots can be flaky if they include dynamic values:
// FLAKY: Snapshot includes timestamp
test('renders component', () => {
const { container } = render( );
expect(container).toMatchSnapshot();
// Snapshot includes: Last updated: 2026-04-07T10:30:00Z
// Fails when run at a different time!
});
// RELIABLE: Mock dynamic values or use inline snapshots with matchers
test('renders component', () => {
jest.spyOn(Date, 'now').mockReturnValue(new Date('2026-01-01').getTime());
const { container } = render( );
expect(container).toMatchSnapshot();
Date.now.mockRestore();
});
Pattern 5: Error Boundary Testing
Test error paths thoroughly, not just happy paths:
test('handles API errors gracefully', async () => {
// Mock the API to fail
server.use(
rest.get('/api/data', (req, res, ctx) => {
return res(ctx.status(500), ctx.json({ error: 'Server error' }));
})
);
render( );
// Wait for error state
expect(await screen.findByText('Something went wrong')).toBeInTheDocument();
// Verify retry button works
server.use(
rest.get('/api/data', (req, res, ctx) => {
return res(ctx.json({ items: ['item1'] }));
})
);
fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
expect(await screen.findByText('item1')).toBeInTheDocument();
});
Debugging Flaky Jest Tests: A Systematic Approach
When you encounter a flaky Jest test, follow this debugging protocol:
Step 1: Reproduce Reliably
# Run the specific test file many times
for i in {1..50}; do
npx jest path/to/test.test.js 2>&1 | tail -1
done
If it never fails alone, run with the full suite
npx jest --verbose 2>&1 | grep -E "(PASS|FAIL)"
Try running with --runInBand to eliminate parallelism
npx jest --runInBand
Try randomizing test order
npx jest --randomize
Step 2: Isolate the Cause
# Find which other test causes the failure
Run the flaky test after each other test file
for file in $(find src -name "*.test.js"); do
echo "Running after: $file"
npx jest --runInBand "$file" "path/to/flaky.test.js" 2>&1 | tail -1
done
Step 3: Fix and Verify
After applying a fix, verify it:
# Run 50+ times to confirm the fix
for i in {1..50}; do
npx jest path/to/fixed.test.js --runInBand 2>&1 | tail -1
done | sort | uniq -c
Step 4: Automate Detection
Set up automated flaky test detection in CI with DeFlaky to catch regressions:
# GitHub Actions
- name: Flaky Test Detection
run: |
deflaky analyze --framework jest \
--runs 10 \
--fail-on-flake \
--report github-pr-comment
Measuring and Tracking Test Reliability
Define and track these metrics for your Jest test suite:
DeFlaky provides a dashboard that tracks all of these metrics over time, giving your team visibility into test suite health and helping you make data-driven decisions about where to invest in reliability improvements.
Conclusion
Flaky Jest tests are not a fact of life. They are symptoms of specific, identifiable problems: async operations without proper awaiting, timer mocks without cleanup, module mocks that leak state, inadequate test isolation, memory leaks, and parallel execution conflicts.
By understanding these root causes and applying the patterns described in this guide, you can build a Jest test suite that runs deterministically across local development, CI environments, and different operating systems. Pair these practices with automated detection tools like DeFlaky, and you will catch flakiness before it reaches your main branch.
The investment in test reliability pays compound returns. Every hour spent fixing a flaky test saves dozens of hours of wasted developer time investigating false failures, waiting for retries, and losing confidence in the test suite. Start with the highest-impact changes -- mock cleanup, async hygiene, and test data isolation -- and work your way toward a zero-flake policy.