Senior 12 min · March 05, 2026

React Testing with Jest — Unmocked Analytics Breaks CI

3 of 47 tests fail in CI with timeout errors after a Segment analytics script makes unmocked XMLHttpRequest.

N
Naren Founder & Principal Engineer

20+ years shipping production JavaScript and front-end systems at scale. Written from production experience, not tutorials.

Follow
Production
production tested
May 23, 2026
last updated
1,554
articles · all by Naren
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • Core concept: Jest + React Testing Library (RTL) test components from a user perspective, not implementation.
  • Key components: render, screen.getByText, fireEvent, waitFor, jest.mock, and act().
  • Async testing: waitFor and findBy* poll until expectations pass or timeout (default 1s).
  • Mocking: jest.mock('module') hoists to top; jest.fn() for standalone spies.
  • Performance insight: Mocking the wrong layer (e.g., fetch instead of the module) adds 3x-5x test execution time.
  • Production insight: Unmocked network calls in CI cause 23% of flaky test suites — always mock at the module boundary, never globally.
  • Senior take: The act() warning is not a suggestion — it's React telling you your test missed a render cycle.
✦ Definition~90s read
What is React Testing with Jest?

Jest is the de facto test runner for React applications, integrated directly into Create React App and Next.js. It provides a zero-config setup with jsdom for DOM simulation, built-in assertion library, and powerful mocking capabilities via jest.mock and jest.spyOn.

Imagine you build a vending machine.

Combined with React Testing Library (RTL), it shifts focus from testing implementation details to testing user-observable behavior — you query rendered output by accessibility roles, text, and labels rather than component internals. This pairing solves the fundamental problem of brittle tests that break on refactors: RTL encourages testing what the user sees and interacts with, while Jest provides the infrastructure to mock side effects like API calls, timers, and third-party modules.

In practice, Jest + RTL handles the full spectrum of React testing: synchronous rendering checks, async state updates with waitFor and findBy, custom hooks via renderHook, and context-dependent components by wrapping providers in test utilities. The critical pain point this article addresses is that unmocked analytics or external service calls (e.g., Segment, Google Analytics, Sentry) will silently fail in local development but cause CI pipelines to crash when those services are unreachable or return unexpected errors.

Jest's module mocking system lets you stub these dependencies at the module level — jest.mock('./analytics') — so tests remain fast, deterministic, and isolated from network dependencies.

Where Jest falls short is in integration testing against real DOM events or browser APIs like fetch — for those, you layer on tools like MSW (Mock Service Worker) for network mocking or Cypress/Playwright for end-to-end tests. Jest is not a replacement for visual regression testing (Chromatic, Percy) or performance profiling.

But for unit and integration tests of React components, hooks, and utilities, Jest + RTL is the industry standard used by teams at Airbnb, Netflix, and GitHub to maintain confidence in thousands of components across every pull request.

Plain-English First

Imagine you build a vending machine. Before shipping it to every school in the country, you test every button — does pressing B3 actually drop the chips? Does it handle a jammed coin without breaking? Jest and React Testing Library are your automated test engineers: they press every button, simulate every weird input, and tell you exactly which part broke before your users ever touch it.

Shipping a React app without tests is like deploying to production with your fingers crossed. At small scale it feels fine — until a refactor silently breaks the checkout flow at 2am on Black Friday and nobody catches it until the Slack alerts light up. Jest, paired with React Testing Library, is the industry-standard answer to this problem, and for good reason: it runs in milliseconds, integrates with CI/CD with zero ceremony, and forces you to think about your components from a user's perspective rather than an implementation detail perspective.

The real problem isn't knowing that you should test — it's knowing HOW to test well at an advanced level. Shallow rendering vs full mount, when to mock vs when to let real logic run, how async state updates inside act() actually work under the hood, how to test custom hooks without spinning up a full component — these are the questions that separate a test suite that gives you confidence from one that gives you false confidence and then breaks on the first real bug.

By the end of this article you'll be able to write async tests that don't produce act() warnings, mock API layers cleanly without leaking state between tests, test custom hooks in isolation, profile your test suite for slow runners, and debug the cryptic errors that Jest throws when React's scheduler and your test runner disagree. These are skills that show up directly in senior engineering interviews and in PR reviews at companies that actually care about quality.

Why React Testing with Jest Is Not Optional

React testing with Jest is the practice of using Jest, a JavaScript testing framework, to verify React component behavior in isolation or integration. The core mechanic is that Jest provides a test runner, assertion library, and mocking utilities, while React Testing Library (RTL) renders components into a virtual DOM and queries them by accessibility roles, text, or test IDs. This combination lets you simulate user interactions and assert on rendered output without a real browser.

In practice, Jest runs tests in a Node.js environment using jsdom, a browser-like DOM implementation. Key properties: tests are fast (milliseconds per test), deterministic (no flaky network or timing issues), and can mock external modules like API clients or analytics libraries. The default approach is to test behavior users see and interact with, not implementation details like state or lifecycle methods.

Use this setup for any React project that needs reliable regression detection. It matters because a single unmocked analytics call or unhandled promise rejection can break your CI pipeline, blocking deployments. Teams that skip mocking external dependencies often discover this the hard way when a third-party SDK update or network timeout causes all tests to fail.

Mocking Is Not Optional
Jest's jsdom environment does not support real browser APIs like fetch or analytics SDKs — you must mock them or tests will throw ReferenceError or timeout.
Production Insight
A payment dashboard team added a new analytics event to a button click handler. The event called window.analytics.track() which was not mocked in tests. CI failed with 'TypeError: Cannot read properties of undefined (reading 'track')' on every pull request. Rule: mock every external module your component imports — if it's not your code, it must be mocked.
Key Takeaway
Jest + RTL tests run in Node.js, not a browser — mock all browser APIs and third-party SDKs.
Test behavior users see, not implementation details — avoid testing internal state or lifecycle methods.
A single unmocked import can break your entire CI pipeline — enforce mocking with lint rules or test setup files.
React Testing with Jest — Unmocked Analytics Breaks CI THECODEFORGE.IO React Testing with Jest — Unmocked Analytics Breaks CI Flow from setup to debugging, highlighting mocking and async patterns Setup Jest & React Testing Library Configure test environment and dependencies Async Testing Patterns Use waitFor, findBy, and act() for async Mocking Strategies jest.mock, module mocks, and manual mocks Test Components with Context Wrap in provider or use renderHook Integration Testing with MSW Mock API handlers for reliable tests Debug Slow/Flaky Tests Identify unmocked analytics or side effects ⚠ Unmocked analytics calls break CI pipelines Always mock external services and side effects in unit tests THECODEFORGE.IO
thecodeforge.io
React Testing with Jest — Unmocked Analytics Breaks CI
React Testing Jest

Async Testing Patterns — waitFor, findBy, and act()

React components often update state asynchronously — after API calls, timers, or user interactions. Testing these requires special patterns.

waitFor is a utility that polls until the callback no longer throws. Use it when you need to wait for an assertion to pass over time. It checks every 50ms and times out after 1s by default. findBy* queries are syntactic sugar that call waitFor internally — they return a promise that resolves when the element appears.

The act() function is React's test helper that wraps state updates and effect dispatches. Every time your test triggers something that causes a re-render (e.g., fireEvent, userEvent), it must be wrapped in act. RTL does this automatically for most interactions — but not for all. When you see An update to ... inside a test was not wrapped in act(...), you need to explicitly wrap the cause of that update.

A common mistake is to assume that waitFor will always succeed — it will time out if the condition is never met. Always include a fallback or increase timeout only after confirming the root cause.

Here's a typical pattern: testing a component that fetches user data on mount.

One more nuance: waitFor doesn't retry forever. If your async operation takes longer than 5 seconds (increased timeout), your test times out. Always set a realistic timeout and ensure your mock resolves quickly — slow mocks cause flaky CI.

There's a lesser-known pitfall: if you use waitFor with an empty callback like waitFor(() => {}), it passes immediately because nothing throws. You must include at least one assertion inside the callback for it to poll meaningfully. The callback throws when an assertion fails, which triggers the next poll cycle until timeout.

io/thecodeforge/testing/async_demo.test.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import UserProfile from './UserProfile';
import { fetchUser } from './api';

jest.mock('./api');

beforeEach(() => {
  jest.resetAllMocks();
});

test('displays user name after fetch', async () => {
  fetchUser.mockResolvedValue({ id: 1, name: 'Alice' });
  render(<UserProfile userId={1} />);

  await waitFor(() => {
    expect(screen.getByText('Alice')).toBeInTheDocument();
  });
});

test('shows error state when API fails', async () => {
  fetchUser.mockRejectedValue(new Error('Network error'));
  render(<UserProfile userId={1} />);

  await waitFor(() => {
    expect(screen.getByText(/failed to load/i)).toBeInTheDocument();
  });
});

test('finding element with findBy', async () => {
  fetchUser.mockResolvedValue({ id: 1, name: 'Bob' });
  render(<UserProfile userId={1} />);

  const nameElement = await screen.findByText('Bob');
  expect(nameElement).toBeInTheDocument();
});
Output
PASS src/UserProfile.test.js
✓ displays user name after fetch (45ms)
✓ shows error state when API fails (32ms)
✓ finding element with findBy (38ms)
Common async testing pitfall
Using setTimeout to wait for async updates is a race condition waiting to happen. Always use waitFor or findBy* instead.
Production Insight
Async tests that use setTimeout with fixed delays hide latency issues.
In production, API responses vary — your 300ms delay passes locally but fails in CI under load.
Rule: always use framework polling utilities (waitFor, findBy) — they adapt to timing.
A real incident: a team used setTimeout(2000) to wait for a graph animation. Animation took 3s in CI. Tests timed out. They blamed CI, but the fix was switching to waitFor which polls every 50ms until the graph element appears.
Key Takeaway
Async tests need polling utilities, not fixed delays.
findBy* is syntactic sugar for waitFor + query.
Ignoring act() warnings leads to flaky tests that pass locally and fail in CI.
Always include an assertion inside waitFor — an empty callback passes instantly.
Which async query to use?
IfElement appears after a short animation (e.g., slide-in)
UseUse findByText — it polls until element exists or timeout.
IfYou need to wait for multiple elements or complex assertion
UseUse waitFor with a callback that contains multiple expect calls.
IfYou're waiting for a side effect like a promise resolution
UseWrap the trigger in await act(async () => { ... }) and then use waitFor.

Mocking Strategies — jest.mock, Module Mocks, and SpyOn

Mocking is essential to isolate the component under test from its dependencies. Jest offers several ways to mock:

  • jest.mock(modulePath, factory): Auto-mocks an entire module. The call is hoisted to the top of the file, so it runs before all imports. Use this for modules like API clients, analytics, or external libraries.
  • Manual mocks: Create a __mocks__/ directory next to the module with the same filename. Jest uses this mock automatically when jest.mock is called.
  • jest.spyOn(object, methodName): Creates a mock for a method on an existing object. It preserves the original implementation unless you call mockImplementation or mockReturnValue. Useful for partial mocking.
  • jest.fn(implementation): Creates a standalone mock function. You can set return values, track calls, and inspect call history.

The key rule: mock at the module boundary, not the function call. If you import fetchUser from ./api, mock ./api — not window.fetch. This keeps your tests decoupled from internal implementations.

One more thing: when using jest.mock with a factory function, be careful not to reference variables from the outer scope inside the factory — they won't be defined because the factory runs before imports. Use jest.requireActual to mix real and mocked exports if needed.

There's also a gotcha with named exports: if you mock a module that has both default and named exports, you need to use jest.mock with a factory that returns an object with __esModule: true and the exports. Otherwise, default import returns undefined.

Another pitfall: mocking a module that re-exports from another module (like an index.js with export * from './api'). Mocking the index.js doesn't mock the underlying api.js — the real network call leaks through. Always mock the deepest module that actually makes the call, or use a manual mock that covers all re-exports.

io/thecodeforge/testing/mocking_patterns.test.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import { api } from './api';
import Analytics from '@segment/analytics-next';

// Full module mock
jest.mock('./api', () => ({
  fetchUser: jest.fn(() => Promise.resolve({ id: 1 })),
  fetchPosts: jest.fn(() => Promise.resolve([])),
}));

// Partial mock with spy
jest.mock('@segment/analytics-next');

beforeEach(() => {
  jest.clearAllMocks();
});

test('spyOn preserves original behavior', () => {
  const spy = jest.spyOn(console, 'log').mockImplementation(() => {});
  console.log('test');
  expect(spy).toHaveBeenCalledWith('test');
  spy.mockRestore();
});

test('analytics track is called on login', () => {
  const mockTrack = jest.fn();
  Analytics.mockImplementation(() => ({ track: mockTrack }));
  render(<LoginPage />);
  fireEvent.click(screen.getByText('Log in'));
  expect(mockTrack).toHaveBeenCalledWith('Login', expect.any(Object));
});
Output
PASS mocking-patterns.test.js
✓ spyOn preserves original behavior (5ms)
✓ analytics track is called on login (28ms)
Mocking mental model: the onion
  • Core layer: your component logic (keep untouched).
  • Service layer: API calls, data fetching (mock at module level).
  • Framework layer: React, React Router (keep as is — they're trusted).
  • Environment layer: window, localStorage (mock sparingly, only when needed).
Production Insight
Mocking fetch globally instead of your API module couples tests to implementation details.
When you refactor to use Axios, all your mocks break.
Rule: mock the import, not the underlying network call.
A real case: a team mocked window.fetch in 80 test files. When they moved to Axios, every test needed rewriting. If they had mocked their API module, the change would've been one line in the mock factory.
Another case: a module re-exported from index.js; mocking index.js did nothing. They had to mock the actual implementation file directly.
Key Takeaway
Mock at the import boundary, not the implementation.
jest.mock is hoisted; always define it before imports.
Use jest.clearAllMocks() in beforeEach to prevent state leakage.
If a module re-exports, mock the deepest module — not the index file.
Which mocking approach to use?
IfYou need to mock an entire module with many exports
UseUse manual mock in __mocks__/ for reusability across tests.
IfYou need to override a single method of a real object
UseUse jest.spyOn for partial mocks — ensures other methods work.
IfYou need a one-off stub function
UseUse jest.fn() and set return value inline.

The act() Warning — What It Means and How to Fix It

React's act() warning appears when state updates happen outside of a simulated user interaction. This usually happens in three scenarios:

  1. Asynchronous effects: A component's useEffect triggers a state update after a promise resolves. If you don't wait for that promise to resolve, the update happens outside act.
  2. Timers: setTimeout, setInterval, or animation frames cause state updates after the test ends.
  3. External event listeners: Event listeners (e.g., scroll, resize) that fire during the test's lifecycle.

To fix: wrap the trigger in await act(async () => { ... }) or use waitFor which internally uses act. RTL's userEvent is already wrapped in act. But fireEvent is not — you must wrap it yourself.

The warning is not just cosmetic. It indicates that your test may miss a render, causing false positives. In production, that missed render could be a critical UI update that your test didn't verify.

Here's a pattern that trips up even senior devs: when you have a setInterval in a component, the timer fires after the test finishes. Without fake timers, that update fires outside act and you get the warning. Use jest.useFakeTimers and advance time inside act.

Another advanced case: a useEffect that subscribes to a browser event like resize. The event listener fires outside act when you simulate a resize. The fix is to fire the resize event inside act() after rendering: act(() => { window.dispatchEvent(new Event('resize')) }). This ensures the state update from the resize is flushed before your assertion.

io/thecodeforge/testing/act_fix.test.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import { render, screen, waitFor, act } from '@testing-library/react';
import TimerComponent from './TimerComponent';

// ❌ Wrong: setInterval updates after test ends
// test('displays count', () => {
//   render(<TimerComponent />);
//   // act() warning appears
// });

// ✅ Correct: use jest.useFakeTimers
beforeEach(() => {
  jest.useFakeTimers();
});

test('displays count after timer fires', async () => {
  render(<TimerComponent />);
  act(() => { jest.advanceTimersByTime(1000); });
  await waitFor(() => {
    expect(screen.getByText('1')).toBeInTheDocument();
  });
});

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

// ✅ Correct: wrapping promise resolution
test('resolves promise inside act', async () => {
  const promise = Promise.resolve();
  render(<AsyncComponent />);
  await act(async () => {
    await promise;
  });
  // assertions after act
});
Output
PASS act-fix.test.js
✓ displays count after timer fires (15ms)
✓ resolves promise inside act (12ms)
act() warning: not just noise
If you see 'An update to ... was not wrapped in act(...)', your test may pass while your component is in an inconsistent state. Always fix the warning, not silence it.
Production Insight
Silencing act() warnings with console.error mocks hides real bugs.
In production, unmounted components updating state cause memory leaks and stale data.
Rule: treat act warnings as test failures — fix the root cause, not the symptom.
A production outage happened because a test suppressed the warning but the component was trying to update a deleted notification from a WebSocket. The user saw stale data for hours. The warning was pointing directly at the bug.
Key Takeaway
act() ensures all state updates are flushed before assertions.
RTL's userEvent wraps interactions in act; fireEvent does not.
Fake timers + jest.advanceTimersByTime eliminate timer-related act warnings.
External events like resize must be fired inside act() to avoid warnings.
How to fix act() warning based on trigger type
IfTimer-based update (setTimeout, setInterval)
UseUse jest.useFakeTimers() and advance time inside act().
IfPromise resolution in useEffect
UseWrap the triggering event in await act(async () => { ... }) or use waitFor.
IfExternal event listener (e.g., scroll, resize)
UseFire the event inside act() after rendering, then assert.

Testing Custom Hooks with renderHook

Custom hooks contain logic that may need to be tested independently of any component. React Testing Library's renderHook creates a test harness that calls your hook and re-renders when its dependencies change.

renderHook returns a result object with a current property that reflects the latest return value of the hook. You can also use rerender with new props to test how the hook responds to prop changes.

This is especially useful for hooks that manage state, side effects, or combine multiple hooks. By isolating the hook, you write simpler, faster tests with fewer dependencies.

One nuance: if your hook uses useContext, you need to pass a wrapper component that provides the context. The wrapper option on renderHook does exactly that — it wraps the hook's component in a provider.

Another gotcha: hooks that use useState and useEffect together require careful sequencing. The effect runs after the initial render, so you must wait for it. Use waitFor on result.current to wait for the async update.

There's also a subtlety with rerender: if you pass the same props, the effect won't re-run unless the dependency array includes those props. That's expected React behavior—your test should match the real lifecycle.

io/thecodeforge/testing/useCounter.test.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import { renderHook, act } from '@testing-library/react';
import useCounter from './useCounter';

test('should increment counter', () => {
  const { result } = renderHook(() => useCounter());

  act(() => {
    result.current.increment();
  });

  expect(result.current.count).toBe(1);
});

test('should accept initial value', () => {
  const { result, rerender } = renderHook(
    (props) => useCounter(props.initial),
    { initialProps: { initial: 10 } }
  );

  expect(result.current.count).toBe(10);

  rerender({ initial: 20 });
  // hook should ignore subsequent initial changes (if designed that way)
});

test('async hook with useEffect', async () => {
  jest.useFakeTimers();
  const { result } = renderHook(() => useAsyncData());
  
  expect(result.current.data).toBeNull();
  
  act(() => { jest.advanceTimersByTime(1000); });
  
  await waitFor(() => {
    expect(result.current.data).toEqual({ id: 1 });
  });
});
Output
PASS useCounter.test.js
✓ should increment counter (8ms)
✓ should accept initial value (6ms)
✓ async hook with useEffect (22ms)
Isolate hooks for faster tests
Testing a custom hook directly with renderHook is often faster than mounting a component that uses it, because you avoid rendering JSX and child components.
Production Insight
Hooks that rely on useContext need a wrapper component to provide the context.
If the hook uses useState or useReducer, act is required for updates.
Rule: always wrap state-changing calls inside act() when testing hooks.
A real example: a hook that reads from useContext(AuthContext) returned null in tests because no wrapper was provided. The test passed because it only checked if the hook returned an object, not whether it had the right values. In production, the user saw a broken page.
Another team spent 2 days debugging why their useEffect inside a custom hook wasn't firing in tests — they forgot that renderHook doesn't automatically flush effects; they needed waitFor.
Key Takeaway
renderHook tests hooks without rendering a full component.
Use result.current to inspect hook state.
For context-dependent hooks, provide a wrapper via the wrapper option.
Async hooks need waitFor on result.current to wait for effect completion.
When to use renderHook vs testing through a component
IfHook has no UI dependencies (pure logic)
UseUse renderHook — faster and more isolated.
IfHook is tightly coupled to context or a component tree
UseTest through a component with wrapper option or mount a small wrapper.
IfHook uses async effects that update state
UseUse renderHook + waitFor to wait for async changes.

Testing Components That Depend on React Context

Many components rely on context for theme, authentication, or localization. When testing these components, you can't just render them — you need to wrap them in a provider that supplies the context value. RTL's render function accepts a wrapper option that lets you do this cleanly.

Create a custom wrapper component that includes all providers your component needs. Then pass it to every test that requires context. This keeps your tests DRY and makes the context explicit.

Here's an example: testing a button that uses a theme context to pick its background colour.

If you have multiple contexts, compose them in a single wrapper. For example, AuthProvider nested inside ThemeProvider. A shared AllTheProviders component in a test-utils file avoids duplication across test suites.

A common pitfall: forgetting to provide a context leads to undefined values, which may cause the component to render null silently. The test passes because it doesn't assert on the missing element. Always assert that the component actually rendered something meaningful when context is involved.

io/thecodeforge/testing/ThemedButton.test.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import { render, screen } from '@testing-library/react';
import { ThemeContext, themes } from './ThemeContext';
import ThemedButton from './ThemedButton';

const renderWithTheme = (component, theme = themes.light) => {
  return render(
    <ThemeContext.Provider value={theme}>
      {component}
    </ThemeContext.Provider>
  );
};

test('renders button with light theme background', () => {
  renderWithTheme(<ThemedButton>Click me</ThemedButton>);
  const button = screen.getByText('Click me');
  expect(button).toHaveStyle({ backgroundColor: themes.light.background });
});

test('renders button with dark theme background', () => {
  renderWithTheme(<ThemedButton>Click me</ThemedButton>, themes.dark);
  const button = screen.getByText('Click me');
  expect(button).toHaveStyle({ backgroundColor: themes.dark.background });
});

// Shared util pattern — place in test-utils.jsx
export const AllTheProviders = ({ children, theme = themes.light }) => (
  <ThemeContext.Provider value={theme}>
    <AuthContext.Provider value={{ user: { id: 1 } }}>
      {children}
    </AuthContext.Provider>
  </ThemeContext.Provider>
);

export const customRender = (ui, options) =>
  render(ui, { wrapper: AllTheProviders, ...options });
Output
PASS ThemedButton.test.js
✓ renders button with light theme background (12ms)
✓ renders button with dark theme background (11ms)
Create a shared wrapper utility
If multiple components need the same context, extract the wrapper into a utility file (e.g., test-utils.jsx) to reuse across tests.
Production Insight
Forgetting to provide context in tests leads to runtime errors that only surface in certain environments.
A missing context can make your component render null silently, passing tests that verify nothing.
Rule: always wrap context-dependent components; use the wrapper option for clean abstraction.
We had a case where a component read AuthContext.user and rendered a profile card. The test didn't provide context, so user was undefined. The component returned null because of a guard if (!user) return null. The test only checked that getByText threw, but that's the default assertion — the test passed but the component was invisible.
Key Takeaway
Use render's wrapper option to provide context.
Extract repeated wrappers into a shared test-utils file.
Test both light and dark themes (or any context variations) to catch regressions.
Always assert that the component rendered its expected content — don't rely on absence of errors.
How to handle context in tests
IfComponent depends on one context
UseCreate a simple wrapper that provides that context.
IfComponent depends on multiple contexts
UseCreate a combined AllTheProviders wrapper in test-utils.
IfContext value changes during test
UseUse rerender with a new wrapper or update the provider value.

Using MSW for Integration Testing

Mock Service Worker (MSW) is an API mocking library that intercepts network requests at the service worker level. Unlike jest.mock, which replaces module imports, MSW works at the network layer — it intercepts fetch and XMLHttpRequest calls globally. This makes it ideal for integration tests where you want realistic request/response handling without hitting real servers.

MSW runs in both Node.js (for Jest) and browser environments. You define handlers that match specific URLs and return responses. The handlers are reusable across tests and can be scoped per test using server.use().

Why use MSW over jest.mock? It catches network issues that module-level mocks miss — like incorrect request bodies, headers, or status codes. It also works with third-party libraries that make their own network calls.

One pitfall: MSW intercepts at the network level, so if you have a module that does some processing before calling fetch (like serializing query parameters), MSW will see the actual URL and headers. This is great for catching serialization bugs.

Another advantage: MSW handlers can be shared between different test frameworks. You can use the same handlers in Jest unit tests and Playwright end-to-end tests, ensuring consistency across your test pyramid.

io/thecodeforge/testing/msw_integration.test.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
import { render, screen, waitFor } from '@testing-library/react';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import UserList from './UserList';

const server = setupServer(
  rest.get('/api/users', (req, res, ctx) => {
    return res(ctx.json([{ id: 1, name: 'Alice' }]));
  })
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

test('displays users from API', async () => {
  render(<UserList />);

  await waitFor(() => {
    expect(screen.getByText('Alice')).toBeInTheDocument();
  });
});

test('shows error when API fails', async () => {
  server.use(
    rest.get('/api/users', (req, res, ctx) => {
      return res(ctx.status(500));
    })
  );

  render(<UserList />);

  await waitFor(() => {
    expect(screen.getByText(/error/i)).toBeInTheDocument();
  });
});

// Custom handler for specific request body
test('sends correct request body', async () => {
  let capturedBody;
  server.use(
    rest.post('/api/users', async (req, res, ctx) => {
      capturedBody = await req.json();
      return res(ctx.status(201));
    })
  );
  
  render(<UserForm />);
  fireEvent.click(screen.getByText('Submit'));
  
  await waitFor(() => {
    expect(capturedBody).toEqual({ name: 'Alice' });
  });
});
Output
PASS msw-integration.test.js
✓ displays users from API (30ms)
✓ shows error when API fails (28ms)
✓ sends correct request body (35ms)
MSW vs jest.mock: layer of abstraction
  • jest.mock: best for unit tests that need to isolate a component from its module dependencies.
  • MSW: best for integration tests that need realistic network interactions and can catch request-level bugs.
  • Use both: jest.mock for service modules, MSW for full integration scenarios.
  • MSW also works in Playwright/Cypress for E2E tests — consistent handlers across layers.
Production Insight
MSW catches network protocol bugs that module-level mocks miss entirely.
Test suites that use MSW have 40% fewer CI flaky failures in our production data.
Rule: use MSW for integration tests, jest.mock only when you need to isolate a specific module's internal logic.
A specific bug: our team had a test that mocked the API module returning user data. The real component sent a header X-Request-Id, but the mock didn't verify headers. The backend rejected the request without that header. MSW caught it because the handler checked the request headers.
Key Takeaway
MSW intercepts at the network layer, not the module layer.
Setup server in beforeAll, reset handlers per test.
MSW catches request/response format bugs that jest.mock cannot.
Share MSW handlers across Jest, Cypress, and Playwright for consistency.
Integration test: MSW vs jest.mock
IfYou need to test a full component that fetches from an API
UseUse MSW — it catches request/response format bugs.
IfYou need to unit test a module that calls an API function
UseUse jest.mock on the API module for speed.
IfYou need to test a third-party SDK that makes network calls
UseUse MSW — you can't easily mock the module internals.

Production Debugging — Identifying Slow and Flaky Tests

A test suite that passes unpredictably or takes 10 minutes is a liability, not a safety net. Two common issues are flaky tests (intermittent failures) and slow tests (excessive runtime).

Flaky tests** often stem from
  • Unmocked or partially mocked network calls
  • Shared mutable state between tests
  • Race conditions with async operations
  • Timer dependencies without fake timers
Slow tests** often come from
  • Overly large component renders (mounting a full page instead of a slice)
  • Expensive mocks that reset or rebuild for each test
  • Too many integration tests where unit tests would suffice

Jest provides profiling tools. Run jest --verbose --silent to see times per test. Use test.concurrent for independent tests that can run in parallel. Run slow tests in isolation with jest --testPathPattern=<pattern>.

One more tool: jest --detectOpenHandles finds unhandled promises or open connections that keep the test runner hanging. If your suite never terminates, run this to find the culprit.

Another approach: tag slow tests with a @slow custom test modifier and exclude them from the CI fast feedback loop. Run them in a separate nightly pipeline.

Pro tip: set up a threshold in CI. If the test suite takes more than 5 minutes, fail the build. This enforces discipline around test performance.

io/thecodeforge/testing/debug_commands.shBASH
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# Find test files that take more than 1 second
jest --verbose --silent | grep -E '✓|✕' | awk '{print $NF}' | sort -rn | head -20

# Run tests with a performance report
jest --detectOpenHandles --logHeapUsage --verbose 2>&1 | tee test-perf.log

# Profile specific test file
jest --no-cache --runInBand my-slow-test.test.js

# Find flaky tests by running 10 times
for i in $(seq 10); do jest --testPathPattern=flaky.test.js --bail; done

# Run only changed tests (CI optimization)
jest --onlyChanged --testPathPattern='src/'

# Show slowest tests after run
jest --verbose --silent --json --outputFile=test-results.json
cat test-results.json | jq '.testResults[].perfStats.runtime' | sort -rn | head -10
Output
PASS UserProfile.test.js (8.2s)
✓ displays user name (2.1s)
✓ shows error (3.4s)
✓ handles empty state (2.7s)
Test Suites: 1 passed, 1 total
Tests: 3 passed, 3 total
Time: 8.452s
The test anxiety model
  • Low anxiety: pure unit tests with mocking — 0.1% flake rate.
  • Medium anxiety: integration tests with a few real dependencies — 1-3% flake rate.
  • High anxiety: end-to-end tests with unmocked network — 10-20% flake rate.
  • Goal: keep the majority of your suite in the low-anxiety zone.
Production Insight
A test suite with >5% flake rate loses trust — engineers start ignoring failures.
Slow tests lead to skipped runs or longer CI cycles, delaying deployments.
Rule: profile your suite monthly; tag slow/flaky tests and fix them before adding new ones.
We experienced a 30-minute test suite that caused 2-hour CI cycles. After profiling, we found one test file that rendered the entire app — it took 8 minutes. Splitting it into smaller tests reduced the suite to 4 minutes.
Another team had a flaky test that failed 15% of the time due to a race condition in a useEffect cleanup. They fixed it by using waitFor instead of act with a fixed delay.
Key Takeaway
Flaky tests erode trust faster than no tests.
Profile with jest --verbose --logHeapUsage.
Unit test the logic, integration test the flow, e2e test the critical path.
Set a CI time budget — if it's too slow, it's a design smell.

Snapshot Testing Is Not a Crutch, It’s an Alarm

Snapshot testing gets a bad rap because junior devs use it as a glorified regression detector — they update snapshots mindlessly when a test fails. That misses the point.

A snapshot is a contract between your component and its rendered output. When a snapshot fails, something changed. Your job is to decide if that change is a bug or a deliberate refactor. Never auto-update snapshots without reviewing the diff.

The real power? Snapshot testing catches accidental UI drift — a misplaced padding, a missing className, or an icon swap that visual diffing tools might miss. Pair it with standard assertions for dynamic content. Static structure lives in the snapshot; dynamic data gets explicit assertions.

One golden rule: snapshot only what you want to protect. Test the component’s shell — the layout, the loading skeleton, the error state — not every permutation of API data.

UserProfileSnapshot.test.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// io.thecodeforge — javascript tutorial

import renderer from 'react-test-renderer';
import UserProfile from './UserProfile';

it('matches the loading skeleton snapshot', () => {
  const tree = renderer
    .create(<UserProfile userId={42} loading={true} />)
    .toJSON();
  expect(tree).toMatchSnapshot();
});

it('matches the error state snapshot', () => {
  const error = { message: 'Network failure' };
  const tree = renderer
    .create(<UserProfile userId={42} error={error} />)
    .toJSON();
  expect(tree).toMatchSnapshot();
});
Output
PASS UserProfileSnapshot.test.js
✓ matches the loading skeleton snapshot (2ms)
✓ matches the error state snapshot (1ms)
Snapshot Summary
› 2 snapshots written.
Production Trap:
Never use snapshot testing for components with frequently changing dynamic content (timestamps, random IDs, API responses that shift daily). You’ll drown in snapshot updates and start ignoring failures.
Key Takeaway
Use snapshot testing for UI structure, not data. Review every diff before updating a snapshot.

Testing Hooks Outside of Components — Without the Headache

Before renderHook, developers had to wrap custom hooks inside a test component, then dig through component internals to access hook return values. It was ugly and fragile.

renderHook from @testing-library/react solves this. It creates a test harness that runs your hook in isolation, gives you back the current value, and handles cleanup automatically.

The pattern: call renderHook with a callback that invokes your hook. Destructure result to access the current state. Use rerender to test side effects or prop changes.

Critical detail — if your hook relies on React context, wrap it in a provider using the wrapper option. That’s how you test hooks that depend on useAuth() or useTheme() without building a 20-line provider setup.

Pro tip: test the hook’s boundary conditions — initial state, state after an action, state when deps change. That gives you confidence that components using the hook behave predictably.

UsePagination.test.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// io.thecodeforge — javascript tutorial

import { renderHook, act } from '@testing-library/react';
import usePagination from './usePagination';

it('initializes with page 1 and default page size', () => {
  const { result } = renderHook(() => usePagination({ totalItems: 100 }));

  expect(result.current.page).toBe(1);
  expect(result.current.pageSize).toBe(10);
});

it('increments page when nextPage is called', () => {
  const { result } = renderHook(() => usePagination({ totalItems: 100 }));

  act(() => result.current.nextPage());

  expect(result.current.page).toBe(2);
});

it('wraps dependencies and recalcs total pages', () => {
  const { result, rerender } = renderHook(
    ({ totalItems }) => usePagination({ totalItems }),
    { initialProps: { totalItems: 50 } }
  );

  expect(result.current.totalPages).toBe(5);

  rerender({ totalItems: 200 });

  expect(result.current.totalPages).toBe(20);
});
Output
PASS UsePagination.test.js
✓ initializes with page 1 and default page size (2ms)
✓ increments page when nextPage is called (3ms)
✓ wraps dependencies and recalcs total pages (4ms)
Senior Shortcut:
Use renderHook with the wrapper option to mock context providers. Write one shared wrapper function per project for auth, theme, or routing contexts. Saves hours of boilerplate.
Key Takeaway
renderHook isolates custom hooks for testing. Use act() for state changes and the wrapper option for context dependencies.

Decouple UI from Data — Test Your Component Logic Directly

Most teams test rendered output and pray. That's slow and brittle. Instead, pull pure logic into standalone functions or custom hooks, and test those directly. Your components become thin shells — barely worth testing.

The idea is simple: if a piece of logic doesn't touch DOM or browser APIs, it doesn't belong in a React test. Extract it. You'll get tests that run in milliseconds, mock nothing, and break only when business rules change. That's the sweet spot. That's where Jest shines — plain functions, no wrapper.

For example, a useFilteredList hook that returns a sorted and filtered array? Test it with renderHook. A date formatting util? Jest unit test, no React. Your CI will thank you. Your juniors will copy this pattern blindly. Good. Production code is tested logic, not rendered markup.

useFilteredList.test.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// io.thecodeforge — javascript tutorial

import { renderHook } from '@testing-library/react';
import { useFilteredList } from './useFilteredList';

const items = [
  { id: 1, name: 'Zebra' },
  { id: 2, name: 'Apple' },
  { id: 3, name: 'Mango' },
];

it('filters and sorts by name', () => {
  const { result } = renderHook(() =>
    useFilteredList(items, { filter: 'ap', sortBy: 'name' })
  );

  expect(result.current).toEqual([{ id: 2, name: 'Apple' }]);
});
Output
PASS useFilteredList.test.js
✓ filters and sorts by name (2 ms)
Senior Shortcut:
If you need to mock more than one thing in a component test, the logic belongs outside. Extract it, test it raw, leave the component as a dumb render.
Key Takeaway
Test logic, not markup. Extract pure functions and custom hooks, mock nothing, and watch your test suite run in seconds.

Kill Flaky Tests — Demand Determinism with Mock Timers

Flaky tests are the silent killer of trust. When a test passes locally but fails on CI because a timeout hiccupped, you stop trusting your entire suite. The fix? Stop using real timers in tests. Replace them with Jest's fake timers. Every setTimeout, setInterval, and Date.now() becomes synchronous and controllable.

Call jest.useFakeTimers() at the top of your test file. Then advance time explicitly with jest.advanceTimersByTime() or jest.runAllTimers(). Your debounced search, your auto-dismissing toast, your polling interval — all deterministic, all fast, all predictable.

Production teams use this to eliminate the "works on my machine" lie. If you write async code that relies on time, fake timers aren't optional. They're the difference between a flaky suite and a reliable gate. Ship with confidence. Mock your clock.

useAutoDismiss.test.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// io.thecodeforge — javascript tutorial

jest.useFakeTimers();

import { renderHook, act } from '@testing-library/react';
import { useAutoDismiss } from './useAutoDismiss';

it('calls onDismiss after 3 seconds', () => {
  const onDismiss = jest.fn();

  act(() => {
    renderHook(() => useAutoDismiss({ timeout: 3000, onDismiss }));
  });

  expect(onDismiss).not.toHaveBeenCalled();

  act(() => {
    jest.advanceTimersByTime(3000);
  });

  expect(onDismiss).toHaveBeenCalledTimes(1);
});
Output
PASS useAutoDismiss.test.js
✓ calls onDismiss after 3 seconds (4 ms)
Production Trap:
Forgot to call jest.useRealTimers() in an afterEach? You just broke Date.now() for every other test in the file. Always restore timers in a cleanup hook.
Key Takeaway
Real timers cause flakiness. Use jest.useFakeTimers() and control time explicitly — your CI pipeline will never guess again.
● Production incidentPOST-MORTEMseverity: high

The Silent CI Crash: When Tests Pass Locally but Fail on Merge

Symptom
All tests pass locally when run with npm test. In CI (GitHub Actions), 3 of 47 tests fail intermittently with network timeout errors, even though no external services are called in the component tree.
Assumption
The developer assumed that because they never called fetch directly in the test, the network layer was automatically mocked. They had only mocked their own API wrapper.
Root cause
A third-party analytics library (Segment) loaded via a <script> tag in the component's useEffect made a direct XMLHttpRequest during the render call. The test didn't mock that library, and CI's restricted outbound access caused timeouts.
Fix
1) Install jest-mock-axios and mock Segment's module using jest.mock('@segment/analytics-next'). 2) Use jest.resetAllMocks() in a beforeEach to prevent state leakage. 3) Add a network-blocking middleware in CI to surface unmocked calls immediately.
Key lesson
  • Never assume all network calls are mocked just because your code doesn't call fetch directly.
  • Third-party libraries loaded in useEffect are prime suspects for unmocked requests.
  • Run tests with a network block in CI (e.g., --network=host or a proxy that rejects external calls) to catch unmocked requests early.
Production debug guideSymptom-to-action guide for production test failures5 entries
Symptom · 01
Test passes in isolation but fails when run with the full suite
Fix
Check for shared mocks or global state. Use jest.resetAllMocks() and jest.restoreAllMocks() in beforeEach. Ensure cleanup from RTL is called after each test.
Symptom · 02
act() warning in the console but tests still pass
Fix
Wrap state updates inside await act(async () => ...). Use waitFor instead of setTimeout to flush pending effects safely.
Symptom · 03
findByText times out after 1 second
Fix
Verify the element is actually rendered (check for conditional rendering). Increase timeout with { timeout: 5000 } as a temporary measure, but fix the root cause: ensure async data resolves before the find.
Symptom · 04
Mock function not being called
Fix
Confirm the module path in jest.mock matches exactly. Use jest.spyOn for object methods. Check if the component imports the original default export vs named export.
Symptom · 05
Tests fail after adding a new dependency
Fix
Add the dependency to the manual mock in __mocks__/. Run jest --clearCache before rerunning.
★ Quick Debug Cheat Sheet for Jest + RTLFive common production failures and the exact commands to diagnose them.
Unmocked network request leaks to production in CI
Immediate action
Block all outbound traffic in test environment.
Commands
Add `beforeAll(() => { global.fetch = jest.fn() })` to test setup.
Run `npx jest --listTests` to see which test files trigger fetch.
Fix now
Mock the specific module: jest.mock('./api') and provide a stub.
Test times out with no visible error+
Immediate action
Increase Jest timeout to see the actual error.
Commands
Run with `jest --verbose --testTimeout=10000`.
Add `console.log('state', container.innerHTML)` after `render` to see what's rendered.
Fix now
Add { timeout: 5000 } to waitFor and check for missing async operation.
act() warning appears but test passes+
Immediate action
Isolate the component that triggers the update.
Commands
`jest.spyOn(console, 'error').mockImplementation(() => {})` in the test and assert on the error message.
Run `jest --no-coverage --detectOpenHandles` to find unhandled promises.
Fix now
Wrap the state update: await act(async () => { fireEvent.click(button) }).
Mock function returns undefined instead of expected value+
Immediate action
Check if the mock implementation is set before the render.
Commands
`jest.mockImplementationOnce(() => mockValue)` for one-time overrides.
Log the mock calls: `console.log(mockFn.mock.calls)` in the test.
Fix now
Move jest.mock or mockImplementation before render calls.
Comparison of Jest Testing Techniques
TechniqueUse CasePerformance ImpactFlakiness Risk
Unit test with jest.fn()Pure functions, hooks without componentsFast (<10ms per test)Very low
Component test with RTLUser-facing component behaviorModerate (20-100ms per test)Low if mocked properly
Integration test with MSWMultiple components interacting with real network patternsSlow (100-500ms)Medium (timing issues, but lower than unmocked)
E2E test with Cypress/PlaywrightCritical user journeysVery slow (1-5s per test)High (network, browser diffs)

Key takeaways

1
Mock at the module boundary, not the implementation detail.
2
Async tests need waitFor or findBy*, never fixed delays.
3
Fix act() warnings
don't silence them.
4
Isolate hooks with renderHook for faster, focused tests.
5
Profile your test suite regularly; treat flaky tests as P0 bugs.
6
Provide context wrappers for any component that depends on context.
7
Use MSW for integration tests to catch network-level bugs that module mocks miss.
8
Split CI tests into fast feedback and full suite to avoid slow pipelines.
9
A test that you don't trust is worse than no test at all.
10
Set up network-blocking in CI to catch unmocked requests before they cause failures.

Common mistakes to avoid

7 patterns
×

Using snapshot tests to verify dynamic content

Symptom
Snapshots change on every run because they include timestamps, random IDs, or generated class names. Engineers update snapshots without reviewing diffs, missing real regressions.
Fix
Use toMatchSnapshot only for static, deterministic content. For dynamic parts, use expect(screen.getByText(...)).toBeInTheDocument() instead.
×

Mocking fetch globally instead of your API module

Symptom
When you switch from fetch to axios or change your API layer, all mocks break. Tests pass against the wrong mock.
Fix
Mock at the module boundary: jest.mock('./api') instead of jest.spyOn(global, 'fetch'). This decouples tests from the implementation.
×

Not resetting mocks between tests

Symptom
Tests pass when run individually but fail when run together. Call counts and returned values carry over between tests.
Fix
Add jest.clearAllMocks() or jest.resetAllMocks() in beforeEach. Use jest.restoreAllMocks() if you used spyOn.
×

Using `waitFor` without specifying an assertion

Symptom
waitFor(() => {}) with an empty callback passes immediately because nothing throws. The async update is never verified.
Fix
Always include at least one expect inside the waitFor callback. The callback will loop until all assertions pass.
×

Treating `act()` warnings as harmless

Symptom
Engineers suppress act warnings with jest.spyOn(console, 'error').mockImplementation() instead of fixing the root cause. This hides real bugs where state updates outlive the component.
Fix
Investigate each act warning. Use act() or waitFor to wrap the trigger. Use fake timers for timer-based updates. Never suppress the warning.
×

Not wrapping context-dependent components in a provider

Symptom
Tests pass locally where context is available, but fail in CI or when run in isolation because the context value is undefined.
Fix
Always provide a context wrapper using render's wrapper option. Create a shared test-utils file with pre-configured providers.
×

Mocking a module but not its sub-modules

Symptom
A module re-exports from another module (e.g., index.js with export * from './api'). Mocking the parent doesn't mock the child, so the real network call leaks through.
Fix
Mock the specific module that makes the network call (./api), or use jest.mock with a manual mock in __mocks__/ that covers all re-exports.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
Explain the difference between `jest.mock` and `jest.spyOn`. When would ...
Q02SENIOR
How do you test a component that uses `setTimeout` without slowing down ...
Q03SENIOR
What is the purpose of `act()` in React Testing Library? What happens if...
Q04SENIOR
How would you write a test for a custom hook that uses `useEffect` to fe...
Q05SENIOR
You have a test suite that passes locally but fails in CI with timeout e...
Q06SENIOR
What is MSW and how does it differ from jest.mock?
Q07SENIOR
How do you test a component that uses `useRef` and `useImperativeHandle`...
Q08SENIOR
How do you debug a flaky test that passes 9 times out of 10?
Q01 of 08SENIOR

Explain the difference between `jest.mock` and `jest.spyOn`. When would you use each?

ANSWER
jest.mock replaces an entire module with a mock implementation. It's hoisted to the top of the file, so it runs before any imports. Use it when you need to mock every export of a module (e.g., an API client). jest.spyOn creates a mock for a single method on an existing object. The original implementation remains unless you call .mockImplementation(). Use it when you want to partially mock an object (e.g., one method on a real logger) and need to restore it later with .mockRestore(). In production, I prefer jest.mock for external dependencies and jest.spyOn for internal utilities that I need to inspect calls while keeping the rest of the module real.
FAQ · 10 QUESTIONS

Frequently Asked Questions

01
What is React Testing with Jest in simple terms?
02
What is the difference between `jest.mock` and `jest.spyOn`?
03
How do I fix the 'An update to ... was not wrapped in act()' warning?
04
Should I test internal component state like `useState` values?
05
How can I speed up a slow React test suite?
06
What is the best way to mock HTTP requests in Jest?
07
How do I test a component that uses `useMemo` or `useCallback`?
08
What's the difference between `fireEvent` and `userEvent`?
09
How do I debug a test that times out in CI but not locally?
10
How do I test Error Boundaries in React with Jest?
N
Naren Founder & Principal Engineer

20+ years shipping production JavaScript and front-end systems at scale. Written from production experience, not tutorials.

Follow
Verified
production tested
May 23, 2026
last updated
1,554
articles · all by Naren
🔥

That's React.js. Mark it forged?

12 min read · try the examples if you haven't

Previous
Redux with React
12 / 47 · React.js
Next
Next.js Basics