Jest tests are failing intermittently, and you’re pulling your hair out trying to figure out why. This usually means a test is making assumptions about shared state or timing that aren’t guaranteed.
Here’s how to track down and eliminate those flaky tests:
1. The "Too Fast" Test: Timing Issues
Diagnosis: A test is finishing before an asynchronous operation it depends on has completed. This is the most common culprit.
Command: Run your tests with --runInBand to force them to execute serially. If the flakiness disappears, you’ve got a timing issue.
npm test -- --runInBand
# or
yarn test --runInBand
Fix: Explicitly wait for asynchronous operations. Use async/await with promises or Jest’s built-in waitFor utility.
Example: If you’re waiting for a DOM update or a network request:
// Before (flaky)
test('should update UI', () => {
fireEvent.click(screen.getByRole('button'));
expect(screen.getByText('Updated!')).toBeInTheDocument(); // Might fail if update is async
});
// After (fixed)
test('should update UI', async () => {
fireEvent.click(screen.getByRole('button'));
await screen.findByText('Updated!'); // Waits for the element to appear
expect(screen.getByText('Updated!')).toBeInTheDocument();
});
Why it works: findByText (and other find* queries) return promises that resolve when the element is found or reject after a default timeout (usually 1000ms). async/await pauses test execution until the promise settles, ensuring the UI has had time to update.
2. The "Shared State" Mess: Global Variables and Mocks
Diagnosis: Tests are unintentionally modifying or relying on state left over from previous tests. This is common when tests don’t properly reset their environment.
Command: Look for beforeAll, afterAll, beforeEach, and afterEach blocks. Scrutinize any code that modifies global variables, singletons, or mocks that aren’t reset.
Fix: Ensure each test runs in a clean slate. Use beforeEach and afterEach to reset state and mocks.
Example: Mocking a module that maintains internal state:
// Mock setup (potentially flaky if not reset)
jest.mock('../api');
const api = require('../api');
test('fetches data', async () => {
api.fetchData.mockResolvedValue({ data: 'test' });
// ... test logic ...
});
test('posts data', async () => {
api.postData.mockResolvedValue({ success: true });
// ... test logic ...
// This test might fail if fetchData's mock is still active or if api object has unexpected state
});
// Fixed with beforeEach
jest.mock('../api');
const api = require('../api');
beforeEach(() => {
// Reset all mocks before each test
jest.clearAllMocks();
});
test('fetches data', async () => {
api.fetchData.mockResolvedValue({ data: 'test' });
// ... test logic ...
});
test('posts data', async () => {
api.postData.mockResolvedValue({ success: true });
// ... test logic ...
});
Why it works: jest.clearAllMocks() (or jest.resetAllMocks(), jest.restoreAllMocks()) ensures that mock implementations and call counts are reset before each test, preventing interference between tests.
3. The "External Dependency" Wobble: Unreliable APIs or Services
Diagnosis: Your tests rely on external services (databases, APIs) that are sometimes slow, unavailable, or return inconsistent data.
Command: Check your test setup for direct calls to external services. Look for setTimeout or manual delays in your test code that might be compensating for slow external calls.
Fix: Mock external dependencies. Use libraries like msw (Mock Service Worker) or Jest’s built-in jest.mock to simulate API responses.
Example: Mocking a REST API call:
// test.js (flaky if API is down)
import axios from 'axios';
test('renders user data', async () => {
const response = await axios.get('/api/users/1');
expect(screen.getByText(response.data.name)).toBeInTheDocument();
});
// test.js (fixed with jest.mock)
import axios from 'axios';
jest.mock('axios'); // Mock the entire axios module
test('renders user data', async () => {
const mockUserData = { id: 1, name: 'Alice' };
axios.get.mockResolvedValue({ data: mockUserData }); // Mock a specific GET request
// Now, when axios.get('/api/users/1') is called in your component,
// it will return the mock data instead of making a real network request.
// ... your component rendering logic ...
expect(screen.getByText('Alice')).toBeInTheDocument();
});
Why it works: Mocking replaces real network requests or service calls with controlled, predictable responses, making your tests deterministic and independent of external factors.
4. The "Randomness" Factor: Unseeded Randomness
Diagnosis: Tests use Math.random() or other sources of randomness without seeding them, leading to different execution paths or outcomes on different runs.
Command: Search your test files for Math.random(), Math.floor(Math.random() * ...), or any custom random number generation.
Fix: Seed the random number generator at the start of your test suite or for specific tests.
Example:
// jest.config.js
module.exports = {
// ... other config
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
};
// jest.setup.js
// Seed Math.random for deterministic results
const seed = 12345;
Math.random = jest.fn(() => {
const random = Math.sin(seed++) * 10000; // A simple pseudo-random generator
return random - Math.floor(random);
});
// Or, for a specific test:
test('uses predictable random numbers', () => {
const originalMathRandom = Math.random;
const mockMathRandom = jest.fn()
.mockReturnValueOnce(0.1)
.mockReturnValueOnce(0.5);
Math.random = mockMathRandom;
// ... your test logic that uses Math.random ...
expect(someFunctionThatUsesRandom(10)).toBe(someExpectedValueBasedOn0_1);
expect(anotherFunctionThatUsesRandom()).toBe(someExpectedValueBasedOn0_5);
Math.random = originalMathRandom; // Restore original Math.random
});
Why it works: By providing a fixed sequence of "random" numbers, you ensure that any logic dependent on randomness will behave the same way every time the test runs.
5. The "Browser Environment" Sneak: DOM Manipulation Races
Diagnosis: Tests that manipulate the DOM or rely on browser APIs (like localStorage, setTimeout in a browser context) might behave differently if the simulated browser environment (like JSDOM) has subtle timing differences or implementation quirks.
Command: If your tests involve direct DOM manipulation or browser-specific APIs outside of React’s declarative rendering, these could be suspects.
Fix: Use Jest’s built-in JSDOM environment carefully. Prefer declarative rendering (like in React) over imperative DOM manipulation in tests. For browser APIs, use Jest’s mock utilities.
Example: Mocking localStorage:
// component.js
export function saveItem(key, value) {
localStorage.setItem(key, value);
}
// component.test.js (flaky if localStorage isn't properly mocked/reset)
// In a real browser, localStorage is persistent. In JSDOM, it's in-memory but needs setup.
// Fixed by ensuring JSDOM is clean or mocking explicitly
test('saves item to localStorage', () => {
// JSDOM usually provides a basic localStorage implementation,
// but it's good practice to clear it if tests might interfere.
localStorage.clear();
saveItem('user', 'testUser');
expect(localStorage.getItem('user')).toBe('testUser');
});
// Alternative: Mocking localStorage directly if needed
const mockLocalStorage = {
getItem: jest.fn(),
setItem: jest.fn(),
clear: jest.fn(),
};
Object.defineProperty(window, 'localStorage', { value: mockLocalStorage });
test('saves item using mock localStorage', () => {
saveItem('user', 'testUser');
expect(mockLocalStorage.setItem).toHaveBeenCalledWith('user', 'testUser');
});
Why it works: Explicitly clearing or mocking browser APIs like localStorage ensures that each test starts with a predictable state, preventing data from one test leaking into another.
6. The "Concurrency Confusion": Node.js Event Loop
Diagnosis: When not using --runInBand, Node.js runs tests concurrently, and asynchronous operations from different tests can interleave in unexpected ways, especially if they rely on shared resources or timers.
Command: If --runInBand fixes your flaky tests, concurrency is the likely culprit.
Fix: The primary fix is to ensure each test is truly independent and doesn’t rely on shared mutable state or timing assumptions that are broken by concurrency. This often means more rigorous use of beforeEach/afterEach to reset mocks and state, and async/await to manage asynchronous operations properly.
Why it works: By making each test self-contained and explicitly managing its asynchronous parts, you eliminate the possibility of other concurrently running tests interfering with its execution.
After fixing these, the next error you’ll likely encounter is a genuine logic bug you’ve been hiding with test flakiness.