Testing React applications with Vitest and React Testing Library
DevOps Engineer at Andela
Note: This tutorial was originally written using Jest and Enzyme. It has been fully updated to use Vitest and React Testing Library, which are the current recommended tools for testing React applications.
React continues to be the web framework of choice for many UI developers, according to the Stack Overflow Developer Survey. React provides an intuitive model for building data-driven user interfaces, and efficiently updates the DOM when this data changes. React pairs nicely with Redux for managing the data that React needs to render interfaces. Redux offers a predictable way to structure and update the data in those frontend applications.
In this tutorial, we’ll build a small React and Redux application and write tests for it using Vitest and React Testing Library. You’ll then configure continuous integration with CircleCI to automate the testing and ensure that any new code doesn’t break the existing functionality.
Prerequisites
To follow along with the tutorial, a few things are required:
- Node.js 20 LTS or later (Node 22 LTS recommended) installed on your machine
- A basic familiarity with React and Redux
- A CircleCI account
- A GitHub account
Getting started
This project builds on the Redux Async Example app. Working with a real app shows how to add tests as you would on your own project.
Before adding the tests, get familiar with what the app does. Clone the starter and run it locally:
git clone --branch template-v2-2026 https://github.com/CIRCLECI-GWP/ci-react-jest-enzyme.git
cd ci-react-jest-enzyme
npm install
npm run dev
Vite serves the app at http://localhost:5173.
Keeping track of progress with Git and GitHub
To track changes on a fresh repository, delete the cloned .git directory and initialize a new one:
rm -rf .git
git init
Refer to creating a repo to learn how to push and track your changes on GitHub.
Overview of the sample application
The sample app displays current headlines from a selected subreddit, by fetching the data from the Reddit API. Users can select the subreddit they want to see headlines for. The headlines load and display on screen. A Refresh button updates the data displayed for the currently selected subreddit.
This simple app is a good example because it has all the functionality needed for most real-world apps, including:
- Fetching data from an API.
- User interactions.
- Synchronous and asynchronous actions.
- Presentational and container components.
With the app running locally and a copy of the code on GitHub, the next step is adding the tests.
Testing React components
You’ll use Vitest as the test runner and React Testing Library (RTL) for rendering and querying components. RTL replaces Enzyme, which has been unmaintained since React 17 and has no adapter for React 18 or 19.
Install the test dependencies:
npm install --save-dev vitest jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event
Add a test script in package.json:
"scripts": {
"test": "vitest run"
}
Configure Vitest by creating vitest.config.js at the project root:
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
globals: true,
setupFiles: ["./src/setupTests.js"],
},
});
The setupFiles entry points at a file that runs before every test. Create src/setupTests.js:
import "@testing-library/jest-dom/vitest";
This adds DOM matchers like toBeInTheDocument to every test.
You’ll use snapshot testing for one component to track changes to its rendered output, and behavioral assertions for the rest. Vitest ships its own snapshot serializer, so no extra package is needed.
You’re now ready to begin testing.
Component tests
Begin by writing tests for the App component. A good starting point is adding a snapshot test to ensure that the component renders the expected output, given a known store state.
For reference, here’s what src/containers/App.jsx contains in the starter:
import { useEffect } from "react";
import { useSelector, useDispatch } from "react-redux";
import { selectSubreddit, fetchPostsIfNeeded, invalidateSubreddit } from "../actions";
import Picker from "../components/Picker";
import Posts from "../components/Posts";
const EMPTY_POSTS = { isFetching: true, items: [] };
export default function App() {
const dispatch = useDispatch();
const selectedSubreddit = useSelector((s) => s.selectedSubreddit);
const postsState = useSelector(
(s) => s.postsBySubreddit[s.selectedSubreddit] ?? EMPTY_POSTS
);
const { isFetching, lastUpdated, items: posts = [] } = postsState;
useEffect(() => {
dispatch(fetchPostsIfNeeded(selectedSubreddit));
}, [dispatch, selectedSubreddit]);
const handleChange = (next) => dispatch(selectSubreddit(next));
const handleRefreshClick = (e) => {
e.preventDefault();
dispatch(invalidateSubreddit(selectedSubreddit));
dispatch(fetchPostsIfNeeded(selectedSubreddit));
};
const isEmpty = posts.length === 0;
return (
<div>
<Picker
value={selectedSubreddit}
onChange={handleChange}
options={["reactjs", "frontend"]}
/>
<p>
{lastUpdated && (
<span>Last updated at {new Date(lastUpdated).toLocaleTimeString()}. </span>
)}
{!isFetching && <button onClick={handleRefreshClick}>Refresh</button>}
</p>
{isEmpty ? (
isFetching ? (
<h2>Loading...</h2>
) : (
<h2>Empty.</h2>
)
) : (
<div style={{ opacity: isFetching ? 0.5 : 1 }}>
<Posts posts={posts} />
</div>
)}
</div>
);
}
What App does:
- Reads
selectedSubredditand the matching post slice from the store. - Dispatches a fetch on mount and whenever the subreddit changes.
- Renders a picker plus a refresh button.
By convention, Vitest finds test files with names ending in .test.js or .test.jsx anywhere under src/. Create the directory src/containers/__tests__/ and add App.test.jsx:
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Provider } from "react-redux";
import { configureStore } from "@reduxjs/toolkit";
import rootReducer from "../../reducers";
import App from "../App";
const renderWithStore = (preloadedState) => {
const store = configureStore({ reducer: rootReducer, preloadedState });
return {
store,
...render(
<Provider store={store}>
<App />
</Provider>
),
};
};
describe("App", () => {
beforeEach(() => {
global.fetch = vi.fn(() =>
Promise.resolve({
json: () => Promise.resolve({ data: { children: [] } }),
})
);
});
it("renders without crashing given the default store state", async () => {
const { asFragment } = renderWithStore();
expect(await screen.findByRole("heading", { name: "reactjs" })).toBeInTheDocument();
expect(asFragment()).toMatchSnapshot();
});
});
In this test, you are rendering App inside a real Redux store created with configureStore. The store provides the data App needs through useSelector and useDispatch, exactly as it would in production. You mock global.fetch so the network call inside fetchPostsIfNeeded resolves with an empty post list.
render mounts the component into a JSDOM document. screen.findByRole waits for the asynchronous render to settle. asFragment().toMatchSnapshot() captures the rendered DOM and compares it against a stored snapshot on subsequent runs.
Run the tests with npm test. Vitest runs the suite and prints a summary.
The first run also creates src/containers/__tests__/__snapshots__/App.test.jsx.snap containing the captured DOM.
Next, add a test that asserts the Refresh button renders when isFetching is false. Add this test below the existing one, inside the same describe block:
it("renders the Refresh button when isFetching is false", async () => {
renderWithStore({
selectedSubreddit: "reactjs",
postsBySubreddit: {
reactjs: { isFetching: false, didInvalidate: false, items: [] },
},
});
expect(await screen.findByRole("button", { name: /refresh/i })).toBeInTheDocument();
});
This test seeds the store with a state where isFetching is false, then asserts the Refresh button appears. RTL queries the rendered DOM by role and accessible name, which is closer to how a user finds elements than querying by component identity.
Finally, add a test that exercises the click handler. Clicking Refresh should call fetchPostsIfNeeded, which calls fetch with the subreddit URL:
it("dispatches refresh actions on Refresh click", async () => {
const user = userEvent.setup();
renderWithStore({
selectedSubreddit: "reactjs",
postsBySubreddit: {
reactjs: { isFetching: false, didInvalidate: false, items: [] },
},
});
await user.click(await screen.findByRole("button", { name: /refresh/i }));
await waitFor(() =>
expect(global.fetch).toHaveBeenCalledWith("https://www.reddit.com/r/reactjs.json")
);
});
userEvent.setup() returns a user object whose methods simulate real browser events (focus, keyboard, click) more faithfully than firing synthetic events directly. The test clicks the button and asserts that fetch was called with the right URL — proving the click flowed through handleRefreshClick, invalidateSubreddit, and fetchPostsIfNeeded end-to-end.
This pattern — render with a real store, mock the network boundary, drive the UI as a user would — covers the same code paths the original class-component tests covered, with no need to reach into component internals.
Testing Redux functionality
In this section, we’ll add tests for the Redux pieces of the application: the actions and the reducers.
Testing action creators
The app has both synchronous and asynchronous action creators. Asynchronous ones are used with redux-thunk to enable async operations like fetching data. Synchronous ones return plain objects. You’ll test both.
For reference, here’s what src/actions/index.js contains:
export const REQUEST_POSTS = "REQUEST_POSTS";
export const RECEIVE_POSTS = "RECEIVE_POSTS";
export const SELECT_SUBREDDIT = "SELECT_SUBREDDIT";
export const INVALIDATE_SUBREDDIT = "INVALIDATE_SUBREDDIT";
export const selectSubreddit = (subreddit) => ({
type: SELECT_SUBREDDIT,
subreddit,
});
export const invalidateSubreddit = (subreddit) => ({
type: INVALIDATE_SUBREDDIT,
subreddit,
});
export const requestPosts = (subreddit) => ({
type: REQUEST_POSTS,
subreddit,
});
export const transformResponseBody = (json) => {
return json.data.children.map((child) => child.data);
};
export const receivePosts = (subreddit, json) => ({
type: RECEIVE_POSTS,
subreddit,
posts: transformResponseBody(json),
receivedAt: Date.now(),
});
export const fetchPosts = (subreddit) => (dispatch) => {
dispatch(requestPosts(subreddit));
return fetch(`https://www.reddit.com/r/${subreddit}.json`)
.then((response) => response.json())
.then((json) => dispatch(receivePosts(subreddit, json)));
};
const shouldFetchPosts = (state, subreddit) => {
const posts = state.postsBySubreddit[subreddit];
if (!posts) {
return true;
}
if (posts.isFetching) {
return false;
}
return posts.didInvalidate;
};
export const fetchPostsIfNeeded = (subreddit) => (dispatch, getState) => {
if (shouldFetchPosts(getState(), subreddit)) {
return dispatch(fetchPosts(subreddit));
}
};
Inside src/actions/, create a folder named __tests__. Inside that, create actions.test.js.
Synchronous action creators are pure functions that take some data and return an action object. The test checks that, given the necessary arguments, the action creator returns the correct action. Start with selectSubreddit:
import * as actions from "../index";
describe("actions", () => {
const subreddit = "reactjs";
describe("selectSubreddit", () => {
it("creates an action with the given subreddit", () => {
const expectedAction = {
type: actions.SELECT_SUBREDDIT,
subreddit,
};
expect(actions.selectSubreddit(subreddit)).toEqual(expectedAction);
});
});
});
For most synchronous action creators, that’s all you need.
receivePosts includes a transformation on the response body. Extract that into a helper so the test can check it directly. The actions file already exports transformResponseBody:
export const transformResponseBody = (json) => {
return json.data.children.map((child) => child.data);
};
export const receivePosts = (subreddit, json) => ({
type: RECEIVE_POSTS,
subreddit,
posts: transformResponseBody(json),
receivedAt: Date.now(),
});
The action also has a receivedAt property that returns Date.now(). You can skip asserting on it because it changes each call. You could mock Date.now if you wanted to verify it, but toMatchObject already lets you assert on a subset of the action.
Add the test for receivePosts:
describe("actions", () => {
const subreddit = "reactjs";
const mockJSON = {
data: {
children: [{ data: { title: "Post 1" } }, { data: { title: "Post 2" } }],
},
};
// ... other tests...
describe("receivePosts", () => {
it("creates the expected action", () => {
const expectedAction = {
type: actions.RECEIVE_POSTS,
subreddit,
posts: actions.transformResponseBody(mockJSON),
};
expect(actions.receivePosts(subreddit, mockJSON)).toMatchObject(expectedAction);
});
});
});
toMatchObject matches a subset of the returned action, which excludes receivedAt.
Time to test async action creators, specifically fetchPosts. You’ll dispatch the thunk against a real Redux store, mock global.fetch so the network call resolves with predictable data, then assert on the resulting state and on what fetch was called with.
Add the imports at the top of the test file and a new describe block:
import { describe, it, expect, vi, afterEach } from "vitest";
import { configureStore } from "@reduxjs/toolkit";
import * as actions from "../index";
import rootReducer from "../../reducers";
describe("actions", () => {
const subreddit = "reactjs";
const mockJSON = {
data: {
children: [{ data: { title: "Post 1" } }, { data: { title: "Post 2" } }],
},
};
// ... synchronous tests ...
describe("fetchPosts", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("fetches posts and stores them in state", async () => {
global.fetch = vi.fn(() =>
Promise.resolve({ json: () => Promise.resolve(mockJSON) })
);
const store = configureStore({ reducer: rootReducer });
await store.dispatch(actions.fetchPostsIfNeeded(subreddit));
expect(global.fetch).toHaveBeenCalledWith(
`https://www.reddit.com/r/${subreddit}.json`
);
const slice = store.getState().postsBySubreddit[subreddit];
expect(slice.isFetching).toBe(false);
expect(slice.items).toEqual(actions.transformResponseBody(mockJSON));
});
});
});
configureStore from Redux Toolkit wires up redux-thunk automatically, so dispatching a thunk function works out of the box. The afterEach restores the original fetch so other tests aren’t affected.
The assertions check both ends of the flow: fetch was called with the right URL, and the store ended up in the expected state. This is the integration-style approach the Redux team recommends for testing — exercising real reducers and middleware rather than mocking them.
Re-running the tests at this point should confirm everything passes.
That concludes the action creators tests. Next: how to test reducers.
Testing reducers
Reducers are at the heart of Redux because they’re how the application state updates. The reducer tests verify that each dispatched action updates the state as expected.
For reference, here’s src/reducers/index.js:
import { combineReducers } from "redux";
import { SELECT_SUBREDDIT, INVALIDATE_SUBREDDIT, REQUEST_POSTS, RECEIVE_POSTS } from "../actions";
const selectedSubreddit = (state = "reactjs", action) => {
switch (action.type) {
case SELECT_SUBREDDIT:
return action.subreddit;
default:
return state;
}
};
const posts = (
state = {
isFetching: false,
didInvalidate: false,
items: [],
},
action
) => {
switch (action.type) {
case INVALIDATE_SUBREDDIT:
return {
...state,
didInvalidate: true,
};
case REQUEST_POSTS:
return {
...state,
isFetching: true,
didInvalidate: false,
};
case RECEIVE_POSTS:
return {
...state,
isFetching: false,
didInvalidate: false,
items: action.posts,
lastUpdated: action.receivedAt,
};
default:
return state;
}
};
const postsBySubreddit = (state = {}, action) => {
switch (action.type) {
case INVALIDATE_SUBREDDIT:
case RECEIVE_POSTS:
case REQUEST_POSTS:
return {
...state,
[action.subreddit]: posts(state[action.subreddit], action),
};
default:
return state;
}
};
const rootReducer = combineReducers({
postsBySubreddit,
selectedSubreddit,
});
export default rootReducer;
export { postsBySubreddit, selectedSubreddit };
The file has two reducers, each managing its own slice of the state. They’re combined into a root reducer using combineReducers. The named exports make it easy to test the reducers individually.
Reducer tests don’t touch React or the network — they’re plain function tests. Create __tests__ under reducers, then add reducers.test.js:
import {
SELECT_SUBREDDIT,
INVALIDATE_SUBREDDIT,
REQUEST_POSTS,
RECEIVE_POSTS,
} from "../../actions";
import { postsBySubreddit, selectedSubreddit } from "../index";
describe("app reducer", () => {
describe("selectedSubreddit", () => {
it("returns the default state", () => {
expect(selectedSubreddit(undefined, {})).toBe("reactjs");
});
it("updates the selectedSubreddit", () => {
const subreddit = "frontend";
const action = {
type: SELECT_SUBREDDIT,
subreddit,
};
expect(selectedSubreddit(undefined, action)).toBe(subreddit);
});
});
});
The first test checks that selectedSubreddit initializes correctly: given undefined state and an empty action, it returns the default value reactjs. The second verifies that a valid action updates the state.
Now postsBySubreddit:
describe("postsBySubreddit", () => {
const subreddit = "frontend";
it("returns the default state", () => {
expect(postsBySubreddit(undefined, {})).toEqual({});
});
it("handles INVALIDATE_SUBREDDIT", () => {
const action = {
type: INVALIDATE_SUBREDDIT,
subreddit,
};
expect(postsBySubreddit({}, action)).toEqual({
[subreddit]: {
isFetching: false,
didInvalidate: true,
items: [],
},
});
});
it("handles REQUEST_POSTS", () => {
const action = {
type: REQUEST_POSTS,
subreddit,
};
expect(postsBySubreddit({}, action)).toEqual({
[subreddit]: {
isFetching: true,
didInvalidate: false,
items: [],
},
});
});
it("handles RECEIVE_POSTS", () => {
const posts = ["post 1", "post 2"];
const receivedAt = Date.now();
const action = {
type: RECEIVE_POSTS,
subreddit,
posts,
receivedAt,
};
expect(postsBySubreddit({}, action)).toEqual({
[subreddit]: {
isFetching: false,
didInvalidate: false,
items: posts,
lastUpdated: receivedAt,
},
});
});
});
Start by testing that the reducer initializes correctly. The default state is an empty object.
The remaining tests follow the same pattern: given an action, the reducer returns the expected state update. The subreddit ends up as the key of the returned object, and the nested object updates per the rules in the reducer.
The common theme with reducers is that given a particular set of inputs (initial state and an action), a new state is returned. All assertions go on the returned state.
That covers the same components of a React + Redux application that a typical app needs to test.
Continuous integration with GitHub and CircleCI
Now we’ll add continuous integration with CircleCI. Continuous integration helps ensure that any changes to the code don’t break existing functionality. Tests run any time new code is pushed, whether by adding new commits to an existing branch or by opening a pull request to merge a new branch into the main branch. This catches bugs early in the development process.
CircleCI configuration
The starter ships with a build-only .circleci/config.yml. Extend it to run the test suite alongside the build:
version: 2.1
orbs:
node: circleci/node@7.2.1
jobs:
build-and-test:
docker:
- image: cimg/node:22.14
steps:
- checkout
- node/install-packages
- run:
name: Test
command: npm test
- run:
name: Build
command: npm run build
workflows:
build-and-test:
jobs:
- build-and-test
The cimg/node:22.14 image ships with Node 22 LTS, npm, and the build dependencies CircleCI needs. The node/install-packages step uses the orb to install npm dependencies with caching. The npm test step runs vitest run (a single non-watch run, suitable for CI), and npm run build produces the production bundle.
Integrating CircleCI and GitHub
Make sure all changes are pushed to the GitHub repository created earlier. Now set up CircleCI to test the code on every change.
To add the project to CircleCI:
From the CircleCI Projects view, click Set Up Project.
On the next screen, select the branch that contains the config file.
Check all steps in the build-and-test workflow.
Conclusion
Ready to automate your own React tests? Sign up for a free CircleCI account and connect your GitHub repository in minutes.
Congratulations! Your CI process is set up, and any new commit pushed to the repository triggers a test run to make sure none of your changes break the build. When changes cause the tests to fail, you’ll be notified, and you can track exactly which commit caused the failure.
If you found this tutorial useful, share what you learned with your team. Thank you for your time!