@_mdrj

Solving the MSW v2 and Fake Timers Conflict in Vitest and Jest

Testing setup with timers and network mocking

Mock Service Worker (MSW) v2 is an excellent tool for mocking API requests in tests, while fake timers help us control time-dependent behavior. However, there's a known compatibility issue when using both together that can cause tests to hang or fail unexpectedly. In this post, we'll explore this problem and how to solve it in both Vitest and Jest.

The Problem: MSW v2 and Fake Timers Conflict

When you use useFakeTimers() with MSW v2, you might encounter tests that never complete or hang indefinitely. Here's a typical scenario:

import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';

const server = setupServer(
  http.get('/api/data', () => {
    return HttpResponse.json({ message: 'Hello from MSW' });
  })
);

describe('Component with API call and timer', () => {
  beforeEach(() => {
    server.listen();
    vi.useFakeTimers(); // This causes the issue!
  });

  afterEach(() => {
    server.close();
    vi.useRealTimers();
  });

  it('should fetch data and update after delay', async () => {
    render(<MyComponent />);
    
    // This test might hang indefinitely
    await waitFor(() => {
      expect(screen.getByText('Hello from MSW')).toBeInTheDocument();
    });
  });
});

Why This Happens

The root cause is that fake timers mock the microtask queue (queueMicrotask), which MSW v2 relies on to process its internal promises and complete network requests. When the microtask queue is mocked, MSW's promises never resolve, causing tests to hang.

MSW v2 uses microtasks internally for:

  • Processing request handlers
  • Managing response lifecycles
  • Coordinating with service workers

When useFakeTimers() mocks queueMicrotask, these internal operations get stuck.

Solution 1: Vitest - Excluding queueMicrotask

Vitest provides a toFake option that allows you to specify which timer functions to mock:

import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';

describe('MSW with Fake Timers - Vitest', () => {
  beforeEach(() => {
    server.listen();
    
    // Only mock specific timers, exclude queueMicrotask
    vi.useFakeTimers({
      toFake: ['setTimeout', 'setInterval', 'clearTimeout', 'clearInterval']
    });
  });

  afterEach(() => {
    server.close();
    vi.useRealTimers();
  });

  it('should work with MSW and fake timers', async () => {
    render(<ComponentWithTimerAndAPI />);
    
    // MSW requests work normally
    await waitFor(() => {
      expect(screen.getByText('Data loaded')).toBeInTheDocument();
    });

    // Fake timers still work for your component logic
    vi.advanceTimersByTime(1000);
    
    expect(screen.getByText('Timer expired')).toBeInTheDocument();
  });
});

Solution 2: Jest - Using advanceTimersToNextTimer

Jest requires a different approach. You can use jest.advanceTimersToNextTimer() or configure fake timers more precisely:

import { jest, describe, it, expect, beforeEach, afterEach } from '@jest/globals';

describe('MSW with Fake Timers - Jest', () => {
  beforeEach(() => {
    server.listen();
    
    // Use legacy fake timers or configure modern ones carefully
    jest.useFakeTimers({
      doNotFake: ['queueMicrotask'],
    });
  });

  afterEach(() => {
    server.close();
    jest.useRealTimers();
  });

  it('should work with MSW and fake timers', async () => {
    render(<ComponentWithTimerAndAPI />);
    
    // Let MSW process its microtasks
    await waitFor(() => {
      expect(screen.getByText('Data loaded')).toBeInTheDocument();
    });

    // Advance timers for component logic
    jest.advanceTimersByTime(1000);
    
    expect(screen.getByText('Timer expired')).toBeInTheDocument();
  });
});

Alternative Jest Solution: Legacy Timers

For Jest, you can also use legacy timers which don't interfere with microtasks:

beforeEach(() => {
  server.listen();
  jest.useFakeTimers('legacy');
});

Complete Working Example

Here's a complete example showing how to test a component that uses both API calls and timers:

// TimerWithAPI.jsx
import React, { useState, useEffect } from 'react';

export function TimerWithAPI() {
  const [data, setData] = useState(null);
  const [timeLeft, setTimeLeft] = useState(5);

  useEffect(() => {
    // Fetch data from API
    fetch('/api/data')
      .then(res => res.json())
      .then(data => setData(data.message));

    // Start countdown timer
    const timer = setInterval(() => {
      setTimeLeft(prev => {
        if (prev <= 1) {
          clearInterval(timer);
          return 0;
        }
        return prev - 1;
      });
    }, 1000);

    return () => clearInterval(timer);
  }, []);

  return (
    <div>
      <div>{data ? `Data: ${data}` : 'Loading...'}</div>
      <div>Time left: {timeLeft}</div>
      {timeLeft === 0 && <div>Timer expired</div>}
    </div>
  );
}
// TimerWithAPI.test.jsx (Vitest)
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
import { TimerWithAPI } from './TimerWithAPI';

const server = setupServer(
  http.get('/api/data', () => {
    return HttpResponse.json({ message: 'Hello from API' });
  })
);

describe('TimerWithAPI', () => {
  beforeEach(() => {
    server.listen();
    // Exclude queueMicrotask from mocking
    vi.useFakeTimers({
      toFake: ['setTimeout', 'setInterval', 'clearTimeout', 'clearInterval']
    });
  });

  afterEach(() => {
    server.resetHandlers();
    server.close();
    vi.useRealTimers();
  });

  it('should load data and countdown timer', async () => {
    render(<TimerWithAPI />);

    // Wait for API data to load (MSW works normally)
    await waitFor(() => {
      expect(screen.getByText('Data: Hello from API')).toBeInTheDocument();
    });

    // Initial timer state
    expect(screen.getByText('Time left: 5')).toBeInTheDocument();

    // Advance timer by 3 seconds
    vi.advanceTimersByTime(3000);
    expect(screen.getByText('Time left: 2')).toBeInTheDocument();

    // Advance to completion
    vi.advanceTimersByTime(2000);
    expect(screen.getByText('Time left: 0')).toBeInTheDocument();
    expect(screen.getByText('Timer expired')).toBeInTheDocument();
  });

  it('should handle API errors', async () => {
    server.use(
      http.get('/api/data', () => {
        return new HttpResponse(null, { status: 500 });
      })
    );

    render(<TimerWithAPI />);

    // Timer should still work even if API fails
    vi.advanceTimersByTime(5000);
    expect(screen.getByText('Timer expired')).toBeInTheDocument();
  });
});

Best Practices

1. Be Specific with Timer Mocking

Only mock the timer functions you actually need:

// Vitest - Be explicit
vi.useFakeTimers({
  toFake: ['setTimeout', 'setInterval'] // Only what you need
});

// Jest - Exclude microtasks
jest.useFakeTimers({
  doNotFake: ['queueMicrotask', 'nextTick']
});

2. Test Setup Utilities

Create a utility function for consistent test setup:

// test-utils.js
export function setupTestWithMSWAndTimers() {
  beforeEach(() => {
    server.listen();
    vi.useFakeTimers({
      toFake: ['setTimeout', 'setInterval', 'clearTimeout', 'clearInterval']
    });
  });

  afterEach(() => {
    server.resetHandlers();
    server.close();
    vi.useRealTimers();
  });
}

3. Consider Real Timers for Some Tests

For tests that primarily focus on API interactions, consider using real timers:

it('should handle complex API flows', async () => {
  // Use real timers for this test
  vi.useRealTimers();
  
  render(<ComplexAPIComponent />);
  
  await waitFor(() => {
    expect(screen.getByText('All data loaded')).toBeInTheDocument();
  }, { timeout: 5000 });
});

Framework-Specific Configuration

Vitest Global Configuration

// vitest.config.js
export default defineConfig({
  test: {
    environment: 'jsdom',
    setupFiles: ['./src/test-setup.js'],
    // Global fake timer config
    fakeTimers: {
      toFake: ['setTimeout', 'setInterval', 'clearTimeout', 'clearInterval']
    }
  }
});

Jest Configuration

// jest.config.js
module.exports = {
  testEnvironment: 'jsdom',
  setupFilesAfterEnv: ['<rootDir>/src/test-setup.js'],
  fakeTimers: {
    enableGlobally: true,
    doNotFake: ['queueMicrotask']
  }
};

Common Pitfalls and Troubleshooting

1. Tests Hanging Indefinitely

Symptom: Tests never complete when using MSW with fake timers Solution: Exclude queueMicrotask from timer mocking

2. Inconsistent Test Results

Symptom: Tests pass sometimes but fail other times Solution: Ensure proper cleanup in afterEach hooks

3. MSW Requests Not Completing

Symptom: API mocks seem to work but responses never arrive Solution: Check that microtasks are not being mocked

Conclusion

The MSW v2 and fake timers compatibility issue is a known problem with a straightforward solution. By understanding that the conflict stems from mocking the microtask queue and configuring your test framework to exclude queueMicrotask from timer mocking, you can use both tools together effectively.

Remember:

  • Vitest: Use toFake option to specify exactly which timers to mock
  • Jest: Use doNotFake option or legacy timers
  • Always clean up: Reset handlers and restore real timers in afterEach
  • Be selective: Only mock the timers you actually need for your tests

With these approaches, you can enjoy the benefits of both controlled timing and reliable API mocking in your test suite! ⏰🔧