Testing React components can be tricky, especially when dealing with complex scenarios involving state management, context providers, or controlled components. While React Testing Library is an excellent tool for testing components in a way that mirrors how users interact with them, some situations require additional set up or specific patterns to test effectively.
In this post, I'll walk through several challenging testing scenarios I've encountered and share the strategies I use to handle them. We'll look at testing controlled components with state management, working with React Context and reducers, and share some helpful utilities for dealing with common testing roadblocks.
The examples here assume familiarity with React Testing Library and a test framework like Jest or Vitest. If you're new to writing tests for React components, I'd recommend starting with the React Testing Library documentation before diving into these more advanced patterns.
Test Harness
As your React application grows, you may split up larger components by extracting out smaller components to encapsulate specific logic, or to reuse common components in multiple places. The example I have here is an EmailField component - a text field that shows an error message if the value is invalid.
// EmailField.tsx
interface Props {
value: string;
onChange: (value: string) => void;
}
export default function EmailField({ value, onChange }: Props) {
const [errorMessage, setErrorMessage] = useState("");
const onChanged = (event) => {
const nextValue = initValue(evt.target.value);
onChange(nextValue);
};
const onBlurred = () => {
if (!isValidEmail(value)) {
setErrorMessage("Please enter a valid email.");
} else {
setErrorMessage("");
}
};
return (
{errorMessage}
);
}
function isValidEmail(email: string): boolean {
return email.includes("@");
}
This EmailField component has specific validation rules and expected behaviors - let's try to write a unit test.
// EmailField.test.tsx
test("shows an error message after the user leaves the field with an invalid email", () => {
render( );
const emailField = screen.getByLabelText("Email");
fireEvent.change(emailField, { target: { value: "[email protected]" } }); // this won't change the controlled input value!
fireEvent.blur(emailField);
expect(screen.queryByText("Please enter a valid email.")).not.toBeInTheDocument();
});
const Wrapper = () => {
const [email, setEmail] = useState("");
return (
{
setEmail(value);
}}
/>
);
};
test("shows an error message after the user leaves the field with an invalid email", () => {
render( );
const emailField = screen.getByLabelText("Email");
fireEvent.change(emailField, { target: { value: "[email protected]" } });
fireEvent.blur(emailField);
expect(screen.queryByText("Please enter a valid email.")).not.toBeInTheDocument();
fireEvent.change(emailField, { target: { value: "test" } });
fireEvent.blur(emailField);
expect(screen.getByText("Please enter a valid email.")).toBeInTheDocument();
});
Typically, this EmailField component would be a child of a larger component, such as a sign up form, or login page. In that case, you don’t necessarily need to use this “Test Harness” approach. Instead, you can verify the EmailField behavior by writing larger “integration-style” tests of the larger component that more closely resembles how a “real user” will interact with your application.
If testing indirectly through the larger component isn’t an option - maybe the EmailField is part of your component library, or you don’t want to deal with any additional behavior or side effects happening in the larger component - this approach can help you test the EmailField in isolation.
React Context + Reducer
When a component uses data from a context provider, it will need some additional setup when testing that component in isolation. I’ll be borrowing the To-Do app example from the React documentation (https://react.dev/learn/scaling-up-with-reducer-and-context), and walking through a test writing example.
Here's an implementation outline for the context provider and app components.
// TasksContext.ts
export const TasksContext = createContext(null);
export const TasksDispatchContext = createContext(null);
export function TasksProvider({ children }) {
const [tasks, dispatch] = useReducer(
tasksReducer,
initialTasks
);
return (
{children}
);
}
// TaskApp.ts
export default function TaskApp() {
return (
Day off in Kyoto
);
}
// AddTask.ts
export default function AddTask() {
const [text, setText] = useState('');
const dispatch = useContext(TasksDispatchContext);
return (
<>
setText(e.target.value)}
/>
setText('');
dispatch({
type: 'added',
id: nextId++,
text: text,
});
}}>Add
>
);
}
// TaskList.ts
export default function TaskList() {
const tasks = useContext(TasksContext);
return (
{tasks.map(task => (
-
))}
);
}
Let's try to write some tests:
First, let's test that we see all the tasks.
// TaskList.test.ts
// this is straightforward
it("should display all the tasks", () => {
render(
);
expect(screen.getByText("Task 1")).toBeInTheDocument();
});
Now let's write a test for adding a new task:
// AddTask.test.ts
// one option: replace dispatch with a mock function to verify calls
it("should add a task after clicking the 'Add' button", async () => {
const mockDispatch = vi.fn();
render(
);
await userEvent.type(screen.getByLabelText("New Task Name", "My New Task");
await userEvent.click(screeb.getByRole("button", { name: "Add" });
expect(mockDispatch).toBeCalledTimes(1);
expect(mockDispatch).toBeCalledWith({ type: "added", id: 0, text: "My New Task"});
});
// another option: write an integration test of the whole TaskApp
// TaskApp.test.ts
it("should add a task after clicking the 'Add' button", async () => {
render(
);
await userEvent.type(screen.getByLabelText("New Task Name", "My New Task");
await userEvent.click(screeb.getByRole("button", { name: "Add" });
expect(screen.getByText("My New Task")).toBeInTheDocument();
});
If you want more control over the initial state of the test, you can create a Wrapper component to manage the context provider setup. For example, you might want to customize the initial task list for each unit test.
it("should add a task after clicking the 'Add' button", async () => {
const Wrapper = ({ children, initialTasks }) => {
const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);
return (
{children}
);
}
render(
);
await userEvent.type(screen.getByLabelText("New Task Name", "My New Task");
await userEvent.click(screeb.getByRole("button", { name: "Add" });
expect(screen.getByText("My New Task")).toBeInTheDocument();
});
Assorted Helpers
Here are a couple code snippets that I've brought from project to project to streamline React testing.
Run Pending Promises
This is helpful to “tick forward” in the test to process queued Promises and start the next React render cycle. For example, if your component fires an asynchronous API call on initial load, you can render then run pending promises to resolve the API call promise.
export const runPendingPromises = async () => act(async () => {});
React Native AnimatedView Mock
My team encountered this issue when testing button presses in React Native code. Our application created custom buttons using the TouchableOpacity component, which had a built-in animation when the component was pressed. This caused a "Warning: An update to ForwardRef inside a test was not wrapped in act(...)" console error to appear in tests where the button was pressed.
To address the warning, we mocked the <Animated.View> component to return a normal view, but made sure to keep the same props from the original component. This was inspired by the discussion from this Github issue on react-native-testing-library - some previous answers suggested a similar mock that removed the animated "wrapper" entirely. However, we found that this caused tests to fail unexpectedly when verifying specific properties of the "wrapper", such as disabled state or accessibility labels.
import React from "react";
import { View } from "react-native";
const MockAnimatedViewComponent = (props: any) => {
return React.createElement(View, props);
};
export const mockAnimatedView = () => {
jest.mock("react-native", () => {
const rn = jest.requireActual("react-native");
const spy = jest.spyOn(rn.Animated, "View", "get");
spy.mockImplementation(() => jest.fn(MockAnimatedViewComponent));
return rn;
});
jest.useFakeTimers();
};
As your React application grows and becomes more complex, it can be tricky to find the right level of isolation and mocking when writing component tests - I'd need another blog post or two for that discussion! But the most important thing is to write tests that are useful: they should verify behaviors you care about, fail for the right reasons, and be easy to adapt to meet future needs. I hope these React testing strategies will help you accomplish that in your own projects.