Testing reducers in React without rendering components works because a reducer is a pure function. Call it with a starting state and an action, then assert on the returned state. No DOM, no React renderer, and no test environment beyond a JavaScript runtime are required.
Because the reducer imports nothing from React, the test file runs in milliseconds. A few dozen cases still finish in well under a second.
Why reducers test well
A reducer takes state and an action and returns the next state. Because it has no side effects and no dependencies, you can exercise every branch with two arguments and one assertion per case. Each switch case becomes one test, and a failing test names the exact action that broke.
export function tasksReducer(tasks, action) {
switch (action.type) {
case "added": return [...tasks, { id: action.id, text: action.text }];
case "removed": return tasks.filter((task) => task.id !== action.id);
default: return tasks;
}
}This reducer lives in tasksReducer.js and is exported so the test file can import it. The tests will live in tasksReducer.test.js next to it.
Set up Vitest
Install Vitest as a dev dependency and import its test helpers. Vitest is the common runner in the React ecosystem, and these imports are all you need for plain function tests.
Install it with npm install -D vitest and add a test script that runs vitest run. The same pattern works with any runner because the reducer has no React imports.
import { describe, it, expect } from "vitest";
import { tasksReducer } from "./tasksReducer";
describe("tasksReducer", () => {
it("adds a task", () => {
const next = tasksReducer([], { type: "added", id: 1, text: "Pack" });
expect(next).toEqual([{ id: 1, text: "Pack" }]);
});
});The first test calls the reducer with an empty list and an added action, then asserts the returned array contains one task. Run it with npx vitest run from the project root.
Check mutation and identity
A reducer must return new objects and arrays, not change the ones it received. Assert both that the original input is unchanged and that the returned value is a different reference.
it("does not mutate the previous state", () => {
const before = [];
const next = tasksReducer(before, { type: "added", id: 1, text: "Pack" });
expect(before).toEqual([]);
expect(next).not.toBe(before);
});The before array is still empty after the call, and next is a new array. This test fails if someone adds push to the reducer later. toEqual compares values, while toBe and not.toBe compare references, so one proves correctness and the other proves immutability.
Cover the unknown action
The default branch should return the same state so unknown actions do not crash or wipe data. Identity matters here, not just deep equality.
it("returns the same state for an unknown action", () => {
const tasks = [{ id: 1, text: "Pack" }];
expect(tasksReducer(tasks, { type: "unknown" })).toBe(tasks);
});Using toBe checks that the exact same reference comes back, which means no copy was made. You can also dispatch several actions in a row and assert the final state, which mirrors a real session. Read why reducers must be pure for the reasoning behind these checks, or how to write actions and reducers to design the cases you test.
Rune AI
Key Insights
- Reducers are pure functions, so they test fast.
- Call the reducer with state and an action, then assert.
- Use toEqual for new objects and toBe for identity.
- Assert the previous state was not mutated.
- Add a case for unknown actions.
Frequently Asked Questions
Do I need React or jsdom to test a reducer?
Which test runner should I use?
Should I test reducers instead of components?
Conclusion
Testing a reducer without rendering components is just testing a pure function. Call it with a starting state and an action, assert the returned state, and add checks for mutation and unknown actions.
More in this topic
How to Build a Dropdown Menu in React
Build a React dropdown menu with the ARIA menu button pattern. Handle open and close, keyboard arrows, and clicks outside the menu.
How to Animate React Components with Motion
Animate React components with the Motion library. Set up motion, add enter, hover, and exit animations, and respect reduced motion.
Headless UI Components Explained: Logic Without Locked Styling
Understand headless UI components and how libraries like Radix give you unstyled, accessible behavior that you style yourself.