Testing React components ensures they work correctly and prevents regressions. This guide covers common testing patterns using React Testing Library and Jest.
Testing Philosophy
Test User Behavior Test components the way users interact with them, not implementation details.
Avoid Testing Internals Don’t test state variables or internal functions directly. Test the output.
Use Real DOM React Testing Library uses real DOM nodes for more realistic tests.
Accessibility First Query elements by accessible attributes like labels and roles.
Common Testing Patterns
Testing Asynchronous Updates
Many React components update asynchronously, especially when using libraries like React DnD or data fetching hooks. Here’s how to handle async updates:
If you see warnings about wrapping tests in act(...), it usually means:
The component updated after the test completed
There are pending async updates
You need to wait for updates to finish
The solution is to use waitFor() from React Testing Library.
Example: Testing a Drag and Drop Component
Here’s a real example of testing a component that updates asynchronously:
import React from 'react' ;
import { useDrag } from 'react-dnd' ;
const Card = ({
card: {
id ,
title
}
}) => {
const [ style , drag ] = useDrag ({
item: { id , type: 'card' },
collect : monitor => ({
opacity: monitor . isDragging () ? 0 : 1
})
});
return (
< li className = "card" id = { id } ref = { drag } style = { style } >
{ title }
</ li >
);
};
Problematic Test
Fixed Test
Setup Requirements
import React from 'react' ;
import { fireEvent } from '@testing-library/react' ;
import Card from './components/Card' ;
import renderDndConnected from './test_utils/renderDndConnected' ;
describe ( '<Card/>' , () => {
let card ;
beforeEach (() => {
const utils = renderDndConnected (
< Card card = { { id: '1' , title: 'Card' } } />
);
card = utils . container . querySelector ( '.card' );
});
it ( 'initial opacity is 1' , () => {
expect ( card . style . opacity ). toEqual ( '1' );
});
describe ( 'when drag starts' , () => {
beforeEach (() => {
fireEvent . dragStart ( card );
});
// This test fails with act(...) warning
it ( 'opacity is 0' , () => {
expect ( card . style . opacity ). toEqual ( '0' );
});
});
});
This test fails because the dragStart event doesn’t immediately update the component’s style. The collect function takes time to run. import React from 'react' ;
import { fireEvent , waitFor } from '@testing-library/react' ;
import renderDndConnected from './test_utils/renderDndConnected' ;
import Card from './components/Card' ;
describe ( '<Card/>' , () => {
let card ;
beforeEach (() => {
const utils = renderDndConnected (
< Card card = { { id: '1' , title: 'Card' } } />
);
card = utils . container . querySelector ( '.card' );
});
it ( 'initial opacity is 1' , () => {
expect ( card . style . opacity ). toEqual ( '1' );
});
describe ( 'when drag starts' , () => {
beforeEach (() => {
fireEvent . dragStart ( card );
});
// Fixed: Now waits for async update
it ( 'opacity is 0' , async () => {
await waitFor (() => expect ( card . style . opacity ). toEqual ( '0' ));
});
});
});
The fix uses waitFor() to wait for the asynchronous update to complete before asserting. If you’re using older versions of react-scripts, you might need to:
Update react-testing-library :
npm install --save-dev @testing-library/react@latest
Fix MutationObserver errors :
Update your test script in package.json:
{
"scripts" : {
"test" : "react-scripts test --env=jsdom-fourteen"
}
}
Key Testing Concepts
Using waitFor()
waitFor() is essential for testing components that update asynchronously:
import { waitFor } from '@testing-library/react' ;
// Wait for an element to appear
await waitFor (() => {
expect ( screen . getByText ( 'Loaded' )). toBeInTheDocument ();
});
// Wait for a condition
await waitFor (() => {
expect ( mockCallback ). toHaveBeenCalled ();
});
// Wait for style changes
await waitFor (() => {
expect ( element . style . opacity ). toEqual ( '0' );
});
waitFor() repeatedly checks the assertion until it passes or times out. This is perfect for async updates.
Testing User Interactions
Test components by simulating user actions:
import { render , fireEvent , screen } from '@testing-library/react' ;
import userEvent from '@testing-library/user-event' ;
// Using fireEvent (synchronous)
fireEvent . click ( button );
fireEvent . change ( input , { target: { value: 'new value' } });
fireEvent . dragStart ( element );
// Using userEvent (more realistic, async)
await userEvent . click ( button );
await userEvent . type ( input , 'new value' );
Testing Redux Connected Components
When testing components connected to Redux:
import { render } from '@testing-library/react' ;
import { Provider } from 'react-redux' ;
import { createStore } from 'redux' ;
import rootReducer from './reducers' ;
const renderWithRedux = (
component ,
{ initialState , store = createStore ( rootReducer , initialState ) } = {}
) => {
return {
... render ( < Provider store = { store } > { component } </ Provider > ),
store
};
};
// Usage
const { getByText , store } = renderWithRedux ( < MyComponent /> , {
initialState: { user: { name: 'John' } }
});
Testing Portals
Components that render to portals (like modals) need special handling:
import { render } from '@testing-library/react' ;
// Ensure the portal container exists
beforeEach (() => {
const portalRoot = document . createElement ( 'div' );
portalRoot . setAttribute ( 'id' , 'portal-root' );
document . body . appendChild ( portalRoot );
});
afterEach (() => {
document . body . removeChild ( document . getElementById ( 'portal-root' ));
});
it ( 'renders modal in portal' , () => {
const { getByText } = render ( < Modal isOpen = { true } /> );
expect ( getByText ( 'Modal content' )). toBeInTheDocument ();
});
Common Test Patterns
Testing Hooks
Test custom hooks using the renderHook utility:
import { renderHook , act } from '@testing-library/react' ;
import { useCounter } from './useCounter' ;
it ( 'increments counter' , () => {
const { result } = renderHook (() => useCounter ());
act (() => {
result . current . increment ();
});
expect ( result . current . count ). toBe ( 1 );
});
Test form submissions and validation:
import { render , fireEvent , screen } from '@testing-library/react' ;
it ( 'submits form with valid data' , async () => {
const handleSubmit = jest . fn ();
render ( < MyForm onSubmit = { handleSubmit } /> );
await userEvent . type ( screen . getByLabelText ( 'Email' ), '[email protected] ' );
await userEvent . type ( screen . getByLabelText ( 'Password' ), 'password123' );
fireEvent . submit ( screen . getByRole ( 'form' ));
await waitFor (() => {
expect ( handleSubmit ). toHaveBeenCalledWith ({
email: '[email protected] ' ,
password: 'password123'
});
});
});
Testing Error States
Test error handling and error boundaries:
import { render , screen } from '@testing-library/react' ;
it ( 'displays error message on fetch failure' , async () => {
global . fetch = jest . fn (() => Promise . reject ( 'API error' ));
render ( < DataFetchingComponent /> );
await waitFor (() => {
expect ( screen . getByText ( 'Error loading data' )). toBeInTheDocument ();
});
});
Best Practices
Query Priority Prefer queries in this order:
getByRole
getByLabelText
getByPlaceholderText
getByText
getByTestId (last resort)
Async Utilities Use waitFor, findBy*, and waitForElementToBeRemoved for async operations.
User Events Prefer @testing-library/user-event over fireEvent for more realistic interactions.
Cleanup React Testing Library automatically cleans up, but clean up mocks and timers manually.
Summary: Key Takeaways
Handle Async Updates
Use waitFor() when testing components that update asynchronously. This solves most act(...) warnings.
Test User Behavior
Focus on testing what users see and do, not implementation details.
Setup Test Environment
Ensure you have the latest testing library versions and proper test environment configuration.
Mock External Dependencies
Mock API calls, timers, and external libraries for reliable tests.
Common issues:
act(...) warnings usually mean async updates weren’t awaited
MutationObserver is not a constructor requires --env=jsdom-fourteen
Missing cleanup can cause test interference