Jest’s default behavior is to reset the module registry between test files, but it doesn’t automatically clean up global state or side effects that might be introduced within a test file and persist across test runs. This is particularly problematic when tests modify shared resources or global variables, leading to unpredictable failures and making it hard to pinpoint the root cause of an issue.

Here’s how to effectively isolate your Jest tests to prevent state leakage:

1. The beforeEach and afterEach Hooks

These are your primary tools for setting up and tearing down test-specific state.

Diagnosis: If one test fails and then subsequent, unrelated tests also start failing with seemingly random errors, it’s a strong indicator of state leakage. Look for tests that modify global variables, mock objects that aren’t restored, or manipulate the DOM in ways that aren’t cleaned up.

Common Causes and Fixes:

  • Global Variable Modification:

    • Diagnosis: If a test sets global.myVariable = 'someValue' and a later test fails because myVariable isn’t what it expects.
    • Fix:
      // In your test file
      describe('My Feature', () => {
        let originalGlobalValue;
      
        beforeEach(() => {
          originalGlobalValue = global.myVariable; // Store original value
          global.myVariable = 'initialValueForThisTest'; // Set test-specific value
        });
      
        afterEach(() => {
          global.myVariable = originalGlobalValue; // Restore original value
        });
      
        test('should do something with myVariable', () => {
          expect(global.myVariable).toBe('initialValueForThisTest');
        });
      
        // ... other tests
      });
      
    • Why it works: beforeEach ensures every test starts with a clean, predictable global state, and afterEach reverts any changes made during the test, preventing them from affecting subsequent tests.
  • Mocking Not Restored:

    • Diagnosis: If you use jest.spyOn or jest.mock and forget to restore the original implementation, subsequent tests might be running against your mocks.
    • Fix:
      // In your test file
      describe('API Calls', () => {
        let fetchSpy;
      
        beforeEach(() => {
          fetchSpy = jest.spyOn(global, 'fetch');
        });
      
        afterEach(() => {
          fetchSpy.mockRestore(); // Crucial for restoring original implementation
        });
      
        test('should call fetch correctly', async () => {
          fetchSpy.mockResolvedValue({ ok: true, json: async () => ({ data: 'test' }) });
          await someFunctionThatFetches();
          expect(fetchSpy).toHaveBeenCalledWith('/api/data');
        });
      
        // ... other tests
      });
      
    • Why it works: mockRestore() precisely undoes the jest.spyOn operation, returning the spied-upon function to its original behavior for the next test.
  • Timers Not Cleared:

    • Diagnosis: If your tests use setTimeout or setInterval and don’t properly advance or clear them, pending timer callbacks can interfere with later tests.
    • Fix:
      // In your test file
      describe('Debounced Function', () => {
        beforeEach(() => {
          jest.useFakeTimers(); // Enable fake timers
        });
      
        afterEach(() => {
          jest.useRealTimers(); // Restore real timers
        });
      
        test('should debounce correctly', () => {
          const func = jest.fn();
          const debouncedFunc = debounce(func, 100);
      
          debouncedFunc();
          expect(func).not.toHaveBeenCalled();
      
          jest.advanceTimersByTime(100); // Advance time to trigger the debounce
          expect(func).toHaveBeenCalledTimes(1);
        });
      });
      
    • Why it works: jest.useFakeTimers() allows you to control time, and jest.advanceTimersByTime() executes all pending timer callbacks. jest.useRealTimers() ensures subsequent tests don’t accidentally use fake timers.
  • DOM Manipulation:

    • Diagnosis: If tests add elements to document.body or modify styles without cleaning up, these changes can affect other tests, especially those that rely on specific DOM structures or styles.
    • Fix:
      // In your test file
      describe('DOM Manipulation', () => {
        let element;
      
        beforeEach(() => {
          element = document.createElement('div');
          document.body.appendChild(element);
        });
      
        afterEach(() => {
          if (element && element.parentNode) {
            element.parentNode.removeChild(element); // Clean up the DOM
          }
        });
      
        test('should add a class to the element', () => {
          element.classList.add('active');
          expect(element.classList.contains('active')).toBe(true);
        });
      });
      
    • Why it works: Explicitly removing any elements added to the DOM during a test ensures a clean slate for the next test.
  • Module Cache Manipulation:

    • Diagnosis: While Jest resets the module cache by default between files, if you manually clear or modify require.cache within a test, it can cause issues.
    • Fix: Avoid directly manipulating require.cache. If you need to re-evaluate a module, use jest.resetModules().
      // In your test file
      test('should re-evaluate module after change', () => {
        jest.resetModules(); // Clears the module cache and re-runs modules
        // Now require the module again
        const myModule = require('./myModule');
        // ... test myModule's new behavior
      });
      
    • Why it works: jest.resetModules() is Jest’s designated way to ensure a fresh import of modules, mimicking a clean environment without manual cache tampering.
  • Database or External Service State:

    • Diagnosis: Tests that interact with a database or external API might leave data behind or change its state, affecting subsequent tests.
    • Fix: Use beforeAll/afterAll for setting up test databases (e.g., creating a temporary database or schema) and beforeEach/afterEach for cleaning up or resetting specific records/data.
      // In your test file
      describe('Database Operations', () => {
        beforeAll(async () => {
          // Setup test database connection or schema
          await setupTestDatabase();
        });
      
        afterAll(async () => {
          // Teardown test database
          await teardownTestDatabase();
        });
      
        beforeEach(async () => {
          // Clear specific tables or insert test data before each test
          await clearDatabaseTable('users');
          await insertUserData({ id: 1, name: 'Test User' });
        });
      
        afterEach(async () => {
          // Optional: Clean up any lingering data if beforeEach isn't enough
        });
      
        test('should create a user', async () => {
          await createUser({ id: 2, name: 'Another User' });
          const user = await getUser(2);
          expect(user.name).toBe('Another User');
        });
      });
      
    • Why it works: beforeAll/afterAll handle the heavy lifting of test environment setup/teardown, while beforeEach/afterEach ensure each test starts with a known, consistent data state.

2. jest.isolateModules

For more complex scenarios where you need to ensure a module is evaluated in a completely isolated state, use jest.isolateModules.

Diagnosis: When you suspect a module’s internal state is being mutated by one test and affecting another, and beforeEach/afterEach on global state or mocks isn’t sufficient. This often happens with modules that have complex internal caches or singletons.

Fix:

// In your test file
describe('Singleton Module', () => {
  test('should initialize correctly independent of other tests', () => {
    jest.isolateModules(() => {
      // Any require() calls within this callback will be in a fresh module cache context.
      const Singleton = require('../src/Singleton').default;
      const instance1 = new Singleton();
      expect(instance1.getProperty()).toBe(undefined); // Assuming default is undefined
    });
  });

  test('should also initialize correctly, proving isolation', () => {
    jest.isolateModules(() => {
      const Singleton = require('../src/Singleton').default;
      const instance2 = new Singleton();
      expect(instance2.getProperty()).toBe(undefined); // Should not be affected by the previous test
    });
  });
});

Why it works: jest.isolateModules provides a temporary, clean module cache for the code executed within its callback. Any require calls inside it will resolve modules as if they were being imported for the very first time, effectively bypassing any cached state from previous tests.

The next error you’ll encounter after fixing state leakage is likely related to asynchronous operations not completing before assertions, often manifesting as "undefined is not a function" or timeouts.

Want structured learning?

Take the full Jest course →