Skip to content
Home JavaScript React Testing with Jest — Unmocked Analytics Breaks CI

React Testing with Jest — Unmocked Analytics Breaks CI

Where developers are forged. · Structured learning · Free forever.
📍 Part of: React.js → Topic 12 of 47
3 of 47 tests fail in CI with timeout errors after a Segment analytics script makes unmocked XMLHttpRequest.
🔥 Advanced — solid JavaScript foundation required
In this tutorial, you'll learn
3 of 47 tests fail in CI with timeout errors after a Segment analytics script makes unmocked XMLHttpRequest.
  • Mock at the module boundary, not the implementation detail.
  • Async tests need waitFor or findBy*, never fixed delays.
  • Fix act() warnings — don't silence them.
✦ Plain-English analogy ✦ Real code with output ✦ Interview questions
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.
🚨 START HERE

Quick Debug Cheat Sheet for Jest + RTL

Five common production failures and the exact commands to diagnose them.
🟡

Unmocked network request leaks to production in CI

Immediate ActionBlock 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 NowMock the specific module: `jest.mock('./api')` and provide a stub.
🟡

Test times out with no visible error

Immediate ActionIncrease 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 NowAdd `{ timeout: 5000 }` to `waitFor` and check for missing async operation.
🟡

act() warning appears but test passes

Immediate ActionIsolate 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 NowWrap the state update: `await act(async () => { fireEvent.click(button) })`.
🟡

Mock function returns undefined instead of expected value

Immediate ActionCheck 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 NowMove `jest.mock` or `mockImplementation` before `render` calls.
Production Incident

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

A senior developer's test suite passed 100% locally but failed consistently in CI. The culprit? An unmocked API call that only works in the dev environment.
SymptomAll 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.
AssumptionThe 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 causeA 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.
Fix1) 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 Guide

Symptom-to-action guide for production test failures

Test passes in isolation but fails when run with the full suiteCheck for shared mocks or global state. Use jest.resetAllMocks() and jest.restoreAllMocks() in beforeEach. Ensure cleanup from RTL is called after each test.
act() warning in the console but tests still passWrap state updates inside await act(async () => ...). Use waitFor instead of setTimeout to flush pending effects safely.
findByText times out after 1 secondVerify 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.
Mock function not being calledConfirm 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.
Tests fail after adding a new dependencyAdd the dependency to the manual mock in __mocks__/. Run jest --clearCache before rerunning.

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.

What is React Testing with Jest?

React Testing with Jest is a core concept in JavaScript. Rather than starting with a dry definition, let's see it in action and understand why it exists.

Here's the thing: you don't test React components to hit a coverage number. You test them to know that when someone refactors the auth logic, the login button still works. That's the real win — a safety net that catches regressions before they reach production.

Think about the last time you pushed a change that broke something unrelated. It happens because JavaScript is dynamic — a change in one module can silently affect another. Tests catch this. But only if you're testing the right thing: user behavior, not implementation details.

A common question: should I test every component? No. Focus on critical user flows, edge cases, and any component that handles async logic, user input, or external dependencies. The rest? Smoke test them and move on.

A quick story: I once worked on a team that relied heavily on snapshot tests. Every time a designer tweaked a margin, the snapshot failed. Engineers would update the snapshot without reviewing the diff because it was always just the same margin change. One day, a developer accidentally removed the submit button from a form. The snapshot still passed because the margin changed too — they updated it all in one go. The button was gone from production for 4 hours. That's the cost of testing implementation instead of user-visible behavior.

io/thecodeforge/testing/jest_intro.test.js · JAVASCRIPT
1234567891011121314151617181920212223242526272829
/**
 * io.thecodeforge.testing — React Testing with Jest intro
 * Always use meaningful names, not x or n
 */

// Component to test
const LoginButton = ({ onClick, disabled = false, children }) => (
  <button onClick={onClick} disabled={disabled}>
    {children}
  </button>
);

// Test that verifies user behavior, not implementation
import { render, screen, fireEvent } from '@testing-library/react';

test('button calls onClick when clicked', () => {
  const handleClick = jest.fn();
  render(<LoginButton onClick={handleClick}>Log in</LoginButton>);
  
  fireEvent.click(screen.getByText('Log in'));
  
  expect(handleClick).toHaveBeenCalledTimes(1);
});

test('button is disabled when disabled prop is true', () => {
  render(<LoginButton onClick={() => {}} disabled>Log in</LoginButton>);
  
  expect(screen.getByText('Log in')).toBeDisabled();
});
▶ Output
PASS src/__tests__/LoginButton.test.js
✓ button calls onClick when clicked (12ms)
✓ button is disabled when disabled prop is true (8ms)
🔥Forge Tip:
Type this code yourself rather than copy-pasting. The muscle memory of writing it will help it stick.
📊 Production Insight
Many teams start testing with Jest but skip RTL entirely, leading to fragile tests tied to implementation.
The biggest production win is catching regressions before merge, not test coverage percentages.
Rule: write tests that simulate user actions, not internal state checks.
Snapshot tests are the most common source of false confidence — they break on every CSS change but miss actual logic bugs.
A real example: a team had 90% coverage but missed a critical bug where a modal's close button didn't work because they tested internal state, not the button click.
🎯 Key Takeaway
Jest + RTL tests what the user sees and does.
Avoid testing internal state or implementation details.
User-visible behavior is the only test that matters in production.
A test that breaks on a CSS change but ignores a missing button is worse than no test at all.

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.js · JAVASCRIPT
123456789101112131415161718192021222324252627282930313233343536
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.js · JAVASCRIPT
123456789101112131415161718192021222324252627282930
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)
Mental Model
Mocking mental model: the onion
Think of your component's dependencies as layers of an onion. You want to peel away everything except the layer you're testing.
  • 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.js · JAVASCRIPT
1234567891011121314151617181920212223242526272829303132333435
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.js · JAVASCRIPT
12345678910111213141516171819202122232425262728293031323334353637
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.js · JAVASCRIPT
1234567891011121314151617181920212223242526272829303132333435
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.js · JAVASCRIPT
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354
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)
Mental Model
MSW vs jest.mock: layer of abstraction
jest.mock works at the JavaScript module level. MSW works at the network level. They aren't competing — they're complementary.
  • 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.sh · BASH
123456789101112131415161718
# 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
Mental Model
The test anxiety model
Every test has an 'anxiety score' — the probability it fails for reasons unrelated to your code.
  • 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.
🗂 Comparison of Jest Testing Techniques
When to use each approach for React component testing
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

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

⚠ Common Mistakes to Avoid

    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 Questions on This Topic

  • QExplain the difference between jest.mock and jest.spyOn. When would you use each?Mid-levelReveal
    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.
  • QHow do you test a component that uses setTimeout without slowing down your test suite?Mid-levelReveal
    Use Jest's fake timers. Call jest.useFakeTimers() in a beforeEach block to replace all timers with mocks. Then, after rendering the component, use act(() => jest.advanceTimersByTime(ms)) to fast-forward time. You can also use jest.runAllTimers() to run all pending timers immediately. Make sure to restore real timers in afterEach with jest.useRealTimers(). This avoids leaking fake timers into other test files. Example: ``javascript beforeEach(() => jest.useFakeTimers()); test('callback fires after 1 second', () => { render(<DelayedComponent />); act(() => jest.advanceTimersByTime(1000)); expect(screen.getByText('Loaded')).toBeInTheDocument(); }); afterEach(() => jest.useRealTimers()); ``
  • QWhat is the purpose of act() in React Testing Library? What happens if you ignore an act() warning?SeniorReveal
    act() ensures that all state updates and effects related to a user interaction are processed before assertions run. React requires that any code that triggers a re-render (like fireEvent, dispatch, or resolving a promise) happens inside act() to guarantee a consistent state. If you ignore the warning, your test may pass but the component might be in an intermediate or inconsistent state. In production, the user would see a different UI than what your test verified. The warning is React's safety net — don't silence it. For example, a test that doesn't wrap a promise resolution in act might assert on the component before the promise resolves, causing a false positive. The warning points directly to this race condition.
  • QHow would you write a test for a custom hook that uses useEffect to fetch data?Mid-levelReveal
    Use renderHook from React Testing Library. Mock the API module that the hook calls (e.g., jest.mock('./api')). Then use waitFor to wait for the effect to complete. For example: ``javascript import { renderHook, waitFor } from '@testing-library/react'; import { fetchUser } from './api'; import useUser from './useUser'; jest.mock('./api'); test('returns user data after fetch', async () => { fetchUser.mockResolvedValue({ id: 1, name: 'Alice' }); const { result } = renderHook(() => useUser(1)); await waitFor(() => expect(result.current.user).toEqual({ id: 1, name: 'Alice' })); }); ` If the hook also handles error states, add a separate test where the mock rejects. For hooks that use timers, use jest.useFakeTimers() and advance time inside act()`.
  • QYou have a test suite that passes locally but fails in CI with timeout errors. What could be the cause and how do you debug it?SeniorReveal
    Common causes: 1. Unmocked network calls — CI blocks outbound traffic or has slower network. 2. Different Node.js version leading to different timing behavior. 3. Race conditions that only manifest under slower CPU in CI. 4. Missing jest.useFakeTimers() that slows down real-time waits. Debug steps: - Run with jest --verbose --testTimeout=10000 to see which test hangs. - Add --detectOpenHandles to find unclosed connections. - Log the component's inner HTML after render to see if async data resolved. - Add a beforeAll that blocks fetch: global.fetch = jest.fn() and see if the failing test then passes — this confirms an unmocked request. - Check CI environment variables — sometimes they override jest config.
  • QWhat is MSW and how does it differ from jest.mock?SeniorReveal
    MSW (Mock Service Worker) intercepts network requests at the service worker level, not at the module level. It works with both fetch and XMLHttpRequest. Unlike jest.mock, which replaces JavaScript module imports, MSW intercepts actual network calls. This means it catches request/response format bugs that module-level mocks miss. Use MSW for integration tests where you want realistic HTTP interactions, and jest.mock for unit tests where you need to isolate a component from its module dependencies. MSW also works across different test runners (Jest, Vitest) and E2E frameworks (Playwright, Cypress), allowing you to share mock handlers across your entire test pyramid.
  • QHow do you test a component that uses useRef and useImperativeHandle?SeniorReveal
    Use render with ref prop to get a reference, then call methods exposed via useImperativeHandle. For example: ``javascript const ref = React.createRef(); render(<MyComponent ref={ref} />); act(() => ref.current.someMethod()); expect(screen.getByText('result')).toBeInTheDocument(); ` You can also use forwardRef in the component to expose the ref. Test the ref methods directly inside act()` to ensure state updates are flushed. For components that expose multiple imperative methods, test each method separately. Also test that calling methods on an unmounted component doesn't throw (though this should be prevented by ref cleanup).
  • QHow do you debug a flaky test that passes 9 times out of 10?SeniorReveal
    First, isolate the test: run it 100 times in a loop for i in $(seq 100); do jest flaky.test.js --bail; done. This quantifies the flake rate. Then add logging inside the test to capture state at each step. Use console.log or pass a custom logger that writes to a file per run. Common causes in React tests: - Unmocked timers: use jest.useFakeTimers() and verify timer IDs - Race condition between waitFor and act: always use await waitFor on promises - Shared mutable state: ensure jest.resetAllMocks() in beforeEach - React concurrent mode features: certain state updates may be batched differently Once identified, add a reliable check (e.g., specific attribute instead of text content) and re-run to confirm the fix.

Frequently Asked Questions

What is React Testing with Jest in simple terms?

React Testing with Jest is a fundamental concept in JavaScript. Think of it as a tool — once you understand its purpose, you'll reach for it constantly. It allows you to simulate user interactions and verify the rendered output without needing a real browser, catching regressions before they hit production.

What is the difference between `jest.mock` and `jest.spyOn`?

jest.mock replaces the entire module. jest.spyOn wraps a single method on an existing object and lets you inspect calls while preserving the original behavior until you override it. Use jest.mock for external modules, jest.spyOn for partial mocking of internal objects.

How do I fix the 'An update to ... was not wrapped in act()' warning?

Identify what triggers the state update (timer, promise, event listener) and wrap that trigger in await act(async () => { ... }). For timers, use jest.useFakeTimers() and then act(() => jest.advanceTimersByTime(...)). Never suppress the warning — it indicates your test might be verifying an incomplete component state.

Should I test internal component state like `useState` values?

No. Test what the user sees (rendered output) and does (interactions). Internal state is an implementation detail. If you refactor from useState to useReducer, the test should still pass because the UI behavior didn't change.

How can I speed up a slow React test suite?

Identify slow tests with jest --verbose. Replace integration tests that render large component trees with unit tests for pure functions or hooks. Use jest.useFakeTimers to skip real waits. Mock expensive dependencies like animation libraries. Consider breaking the suite into smaller groups using jest --testPathPattern. Also use test.concurrent for independent tests — they run in parallel, reducing total time significantly.

What is the best way to mock HTTP requests in Jest?

For unit tests, use jest.mock on your API module to replace the fetch call. For integration tests, use MSW (Mock Service Worker) which intercepts network requests at the service worker level — it's more realistic and catches request/response format bugs. Avoid mocking global.fetch directly as it couples tests to the underlying HTTP library.

How do I test a component that uses `useMemo` or `useCallback`?

You don't need to test these hooks directly — they are implementation details. Instead, test the behavior they enable. For example, if useCallback prevents an unnecessary re-render of a child, test that the child renders correctly when its props change, not that the callback reference stayed the same. RTL's rerender method can help test memoization effects.

What's the difference between `fireEvent` and `userEvent`?

fireEvent dispatches a DOM event synchronously. userEvent from @testing-library/user-event simulates actual browser interactions like typing, clicking, and focusing — it's more realistic and automatically wraps actions in act(). Prefer userEvent for most tests; use fireEvent only when you need to trigger an event that userEvent doesn't support, like low-level clipboard events.

How do I debug a test that times out in CI but not locally?

CI environments often have slower CPUs and network. First, add jest.setTimeout(30000) to increase timeout temporarily. Log the component's state at each step: screen.debug() after each interaction. Check if the CI runner has restricted outbound network — add global.fetch = jest.fn() to see if unmocked requests are the cause. Also compare Node.js versions between local and CI. Finally, run the test with --runInBand to ensure serial execution (sometimes parallel execution in CI causes race conditions).

How do I test Error Boundaries in React with Jest?

Error boundaries require you to throw an error inside a child component and verify the boundary renders the fallback. Example: ```javascript const ThrowError = () => { throw new Error('test'); };

test('error boundary catches error', () => { jest.spyOn(console, 'error').mockImplementation(() => {}); render( <ErrorBoundary fallback={<div>Error occurred</div>}> <ThrowError /> </ErrorBoundary> ); expect(screen.getByText('Error occurred')).toBeInTheDocument(); console.error.mockRestore(); }); ``` Note: you must suppress console.error output because React logs caught errors there.

🔥
Naren Founder & Author

Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.

← PreviousRedux with ReactNext →Next.js Basics
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged