React Testing with Jest — Unmocked Analytics Breaks CI
- Mock at the module boundary, not the implementation detail.
- Async tests need
waitFororfindBy*, never fixed delays. - Fix
warnings — don't silence them.act()
- Core concept: Jest + React Testing Library (RTL) test components from a user perspective, not implementation.
- Key components:
render,screen.getByText,fireEvent,waitFor,jest.mock, andact(). - Async testing:
waitForandfindBy*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.
Quick Debug Cheat Sheet for Jest + RTL
Unmocked network request leaks to production in CI
Add `beforeAll(() => { global.fetch = jest.fn() })` to test setup.Run `npx jest --listTests` to see which test files trigger fetch.Test times out with no visible error
Run with `jest --verbose --testTimeout=10000`.Add `console.log('state', container.innerHTML)` after `render` to see what's rendered.act() warning appears but test passes
`jest.spyOn(console, 'error').mockImplementation(() => {})` in the test and assert on the error message.Run `jest --no-coverage --detectOpenHandles` to find unhandled promises.Mock function returns undefined instead of expected value
`jest.mockImplementationOnce(() => mockValue)` for one-time overrides.Log the mock calls: `console.log(mockFn.mock.calls)` in the test.Production Incident
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.fetch directly in the test, the network layer was automatically mocked. They had only mocked their own API wrapper.<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.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.--network=host or a proxy that rejects external calls) to catch unmocked requests early.Production Debug GuideSymptom-to-action guide for production test failures
jest.resetAllMocks() and jest.restoreAllMocks() in beforeEach. Ensure cleanup from RTL is called after each test.act() warning in the console but tests still pass→Wrap state updates inside await act(async () => ...). Use waitFor instead of setTimeout to flush pending effects safely.findByText times out after 1 second→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.jest.mock matches exactly. Use jest.spyOn for object methods. Check if the component imports the original default export vs named export.__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 — 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(); });
✓ button calls onClick when clicked (12ms)
✓ button is disabled when disabled prop is true (8ms)
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 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., act()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.
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(); });
✓ displays user name after fetch (45ms)
✓ shows error state when API fails (32ms)
✓ finding element with findBy (38ms)
setTimeout to wait for async updates is a race condition waiting to happen. Always use waitFor or findBy* instead.setTimeout with fixed delays hide latency issues.waitFor, findBy) — they adapt to timing.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.findBy* is syntactic sugar for waitFor + query.act() warnings leads to flaky tests that pass locally and fail in CI.waitFor — an empty callback passes instantly.findByText — it polls until element exists or timeout.waitFor with a callback that contains multiple expect calls.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 whenjest.mockis called. jest.spyOn(object, methodName): Creates a mock for a method on an existing object. It preserves the original implementation unless you callmockImplementationormockReturnValue. 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.
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)); });
✓ spyOn preserves original behavior (5ms)
✓ analytics track is called on login (28ms)
- 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).
fetch globally instead of your API module couples tests to implementation details.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.index.js; mocking index.js did nothing. They had to mock the actual implementation file directly.jest.mock is hoisted; always define it before imports.jest.clearAllMocks() in beforeEach to prevent state leakage.__mocks__/ for reusability across tests.jest.spyOn for partial mocks — ensures other methods work.jest.fn() and set return value inline.The act() Warning — What It Means and How to Fix It
React's warning appears when state updates happen outside of a simulated user interaction. This usually happens in three scenarios:act()
- Asynchronous effects: A component's
useEffecttriggers a state update after a promise resolves. If you don't wait for that promise to resolve, the update happens outsideact. - Timers:
setTimeout,setInterval, or animation frames cause state updates after the test ends. - 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 after rendering: act()act(() => { window.dispatchEvent(new Event('resize')) }). This ensures the state update from the resize is flushed before your assertion.
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 });
✓ displays count after timer fires (15ms)
✓ resolves promise inside act (12ms)
act() warnings with console.error mocks hides real bugs.act warnings as test failures — fix the root cause, not the symptom.act() ensures all state updates are flushed before assertions.userEvent wraps interactions in act; fireEvent does not.jest.advanceTimersByTime eliminate timer-related act warnings.act() to avoid warnings.jest.useFakeTimers() and advance time inside act().await act(async () => { ... }) or use waitFor.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.
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 }); }); });
✓ should increment counter (8ms)
✓ should accept initial value (6ms)
✓ async hook with useEffect (22ms)
renderHook is often faster than mounting a component that uses it, because you avoid rendering JSX and child components.useContext need a wrapper component to provide the context.useState or useReducer, act is required for updates.act() when testing hooks.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.useEffect inside a custom hook wasn't firing in tests — they forgot that renderHook doesn't automatically flush effects; they needed waitFor.renderHook tests hooks without rendering a full component.result.current to inspect hook state.wrapper option.waitFor on result.current to wait for effect completion.renderHook — faster and more isolated.wrapper option or mount a small wrapper.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.
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 });
✓ renders button with light theme background (12ms)
✓ renders button with dark theme background (11ms)
test-utils.jsx) to reuse across tests.wrapper option for clean abstraction.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.render's wrapper option to provide context.AllTheProviders wrapper in test-utils.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.
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' }); }); });
✓ displays users from API (30ms)
✓ shows error when API fails (28ms)
✓ sends correct request body (35ms)
- 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.
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.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).
- Unmocked or partially mocked network calls
- Shared mutable state between tests
- Race conditions with async operations
- Timer dependencies without fake timers
- 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.
# 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
✓ 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
- 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.
useEffect cleanup. They fixed it by using waitFor instead of act with a fixed delay.jest --verbose --logHeapUsage.| Technique | Use Case | Performance Impact | Flakiness Risk |
|---|---|---|---|
Unit test with jest.fn() | Pure functions, hooks without components | Fast (<10ms per test) | Very low |
| Component test with RTL | User-facing component behavior | Moderate (20-100ms per test) | Low if mocked properly |
| Integration test with MSW | Multiple components interacting with real network patterns | Slow (100-500ms) | Medium (timing issues, but lower than unmocked) |
| E2E test with Cypress/Playwright | Critical user journeys | Very slow (1-5s per test) | High (network, browser diffs) |
🎯 Key Takeaways
- Mock at the module boundary, not the implementation detail.
- Async tests need
waitFororfindBy*, never fixed delays. - Fix
warnings — don't silence them.act() - Isolate hooks with
renderHookfor 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
Interview Questions on This Topic
- QExplain the difference between
jest.mockandjest.spyOn. When would you use each?Mid-levelReveal - QHow do you test a component that uses
setTimeoutwithout slowing down your test suite?Mid-levelReveal - QWhat is the purpose of
in React Testing Library? What happens if you ignore anact()warning?SeniorRevealact() - QHow would you write a test for a custom hook that uses
useEffectto fetch data?Mid-levelReveal - 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
- QWhat is MSW and how does it differ from jest.mock?SeniorReveal
- QHow do you test a component that uses
useRefanduseImperativeHandle?SeniorReveal - QHow do you debug a flaky test that passes 9 times out of 10?SeniorReveal
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 . Prefer act()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: after each interaction. Check if the CI runner has restricted outbound network — add screen.debug()global.fetch = to see if unmocked requests are the cause. Also compare Node.js versions between local and CI. Finally, run the test with jest.fn()--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.
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.