Testing Context & State Management
Test components that depend on React Context, Zustand stores, or other global state providers.
Recipe
Quick-reference recipe card -- copy-paste ready.
import { render, screen } from "@testing-library/react";
// Custom render with providers
function renderWithProviders(
ui: React.ReactElement,
{ theme = "light" }: { theme?: string } = {}
) {
return render(ui, {
wrapper: ({ children }) => (
<ThemeProvider initialTheme={theme}>
<AuthProvider>{children}</AuthProvider>
</ThemeProvider>
),
});
}
// Use in tests
renderWithProviders(<Dashboard />, { theme: "dark" });
// Reset Zustand store between tests
import { useCartStore } from "@/stores/cart";
beforeEach(() => {
useCartStore.setState({ items: [], total: 0 });
});When to reach for this: When components read from React Context or a Zustand store and you need to control the provided values in tests.
Working Example
// src/context/theme-context.tsx
"use client";
import { createContext, useContext, useState, useCallback } from "react";
type Theme = "light" | "dark";
interface ThemeContextValue {
theme: Theme;
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextValue | null>(null);
export function ThemeProvider({
children,
initialTheme = "light",
}: {
children: React.ReactNode;
initialTheme?: Theme;
}) {
const [theme, setTheme] = useState<Theme>(initialTheme);
const toggleTheme = useCallback(
() => setTheme((t) => (t === "light" ? "dark" : "light")),
[]
);
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error("useTheme must be used within ThemeProvider");
return ctx;
}// src/components/theme-toggle.tsx
"use client";
import { useTheme } from "@/context/theme-context";
export function ThemeToggle() {
const { theme, toggleTheme } = useTheme();
return (
<button onClick={toggleTheme} aria-label="Toggle theme">
{theme === "light" ? "Switch to dark" : "Switch to light"}
</button>
);
}// src/components/theme-toggle.test.tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect } from "vitest";
import { ThemeProvider } from "@/context/theme-context";
import { ThemeToggle } from "./theme-toggle";
function renderWithTheme(ui: React.ReactElement, initialTheme: "light" | "dark" = "light") {
return render(ui, {
wrapper: ({ children }) => (
<ThemeProvider initialTheme={initialTheme}>{children}</ThemeProvider>
),
});
}
describe("ThemeToggle", () => {
const user = userEvent.setup();
it("shows current theme", () => {
renderWithTheme(<ThemeToggle />);
expect(
screen.getByRole("button", { name: /toggle theme/i })
).toHaveTextContent("Switch to dark");
});
it("starts in dark mode when configured", () => {
renderWithTheme(<ThemeToggle />, "dark");
expect(screen.getByRole("button")).toHaveTextContent("Switch to light");
});
it("toggles theme on click", async () => {
renderWithTheme(<ThemeToggle />);
const button = screen.getByRole("button", { name: /toggle theme/i });
expect(button).toHaveTextContent("Switch to dark");
await user.click(button);
expect(button).toHaveTextContent("Switch to light");
await user.click(button);
expect(button).toHaveTextContent("Switch to dark");
});
it("throws when used outside provider", () => {
// Suppress console.error for expected error
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
expect(() => render(<ThemeToggle />)).toThrow(
"useTheme must be used within ThemeProvider"
);
spy.mockRestore();
});
});// src/stores/cart.ts
import { create } from "zustand";
interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
}
interface CartStore {
items: CartItem[];
addItem: (item: Omit<CartItem, "quantity">) => void;
removeItem: (id: string) => void;
updateQuantity: (id: string, quantity: number) => void;
total: () => number;
clearCart: () => void;
}
export const useCartStore = create<CartStore>((set, get) => ({
items: [],
addItem: (item) =>
set((state) => {
const existing = state.items.find((i) => i.id === item.id);
if (existing) {
return {
items: state.items.map((i) =>
i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i
),
};
}
return { items: [...state.items, { ...item, quantity: 1 }] };
}),
removeItem: (id) =>
set((state) => ({
items: state.items.filter((i) => i.id !== id),
})),
updateQuantity: (id, quantity) =>
set((state) => ({
items: state.items.map((i) =>
i.id === id ? { ...i, quantity: Math.max(0, quantity) } : i
),
})),
total: () =>
get().items.reduce((sum, item) => sum + item.price * item.quantity, 0),
clearCart: () => set({ items: [] }),
}));// src/stores/cart.test.ts
import { describe, it, expect, beforeEach } from "vitest";
import { useCartStore } from "./cart";
describe("CartStore", () => {
beforeEach(() => {
// Reset store to initial state between tests
useCartStore.setState({ items: [] });
});
it("adds an item", () => {
useCartStore.getState().addItem({ id: "1", name: "Widget", price: 10 });
const { items } = useCartStore.getState();
expect(items).toHaveLength(1);
expect(items[0]).toEqual({
id: "1",
name: "Widget",
price: 10,
quantity: 1,
});
});
it("increments quantity for duplicate items", () => {
const { addItem } = useCartStore.getState();
addItem({ id: "1", name: "Widget", price: 10 });
addItem({ id: "1", name: "Widget", price: 10 });
const { items } = useCartStore.getState();
expect(items).toHaveLength(1);
expect(items[0].quantity).toBe(2);
});
it("removes an item", () => {
useCartStore.setState({
items: [{ id: "1", name: "Widget", price: 10, quantity: 1 }],
});
useCartStore.getState().removeItem("1");
expect(useCartStore.getState().items).toHaveLength(0);
});
it("updates quantity", () => {
useCartStore.setState({
items: [{ id: "1", name: "Widget", price: 10, quantity: 1 }],
});
useCartStore.getState().updateQuantity("1", 5);
expect(useCartStore.getState().items[0].quantity).toBe(5);
});
it("calculates total", () => {
useCartStore.setState({
items: [
{ id: "1", name: "Widget", price: 10, quantity: 2 },
{ id: "2", name: "Gadget", price: 25, quantity: 1 },
],
});
expect(useCartStore.getState().total()).toBe(45);
});
it("clears the cart", () => {
useCartStore.setState({
items: [{ id: "1", name: "Widget", price: 10, quantity: 1 }],
});
useCartStore.getState().clearCart();
expect(useCartStore.getState().items).toHaveLength(0);
});
});// src/components/cart-summary.tsx
"use client";
import { useCartStore } from "@/stores/cart";
export function CartSummary() {
const items = useCartStore((s) => s.items);
const total = useCartStore((s) => s.total);
const removeItem = useCartStore((s) => s.removeItem);
if (items.length === 0) {
return <p>Your cart is empty.</p>;
}
return (
<div>
<h2>Cart ({items.length} items)</h2>
<ul>
{items.map((item) => (
<li key={item.id}>
{item.name} x{item.quantity} - ${(item.price * item.quantity).toFixed(2)}
<button
onClick={() => removeItem(item.id)}
aria-label={`Remove ${item.name}`}
>
Remove
</button>
</li>
))}
</ul>
<p>Total: ${total().toFixed(2)}</p>
</div>
);
}// src/components/cart-summary.test.tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, beforeEach } from "vitest";
import { useCartStore } from "@/stores/cart";
import { CartSummary } from "./cart-summary";
describe("CartSummary", () => {
const user = userEvent.setup();
beforeEach(() => {
useCartStore.setState({ items: [] });
});
it("shows empty message when cart is empty", () => {
render(<CartSummary />);
expect(screen.getByText(/your cart is empty/i)).toBeInTheDocument();
});
it("renders cart items", () => {
useCartStore.setState({
items: [
{ id: "1", name: "Widget", price: 10, quantity: 2 },
{ id: "2", name: "Gadget", price: 25, quantity: 1 },
],
});
render(<CartSummary />);
expect(screen.getByText(/widget x2/i)).toBeInTheDocument();
expect(screen.getByText(/\$20\.00/)).toBeInTheDocument();
expect(screen.getByText(/gadget x1/i)).toBeInTheDocument();
expect(screen.getByText(/total: \$45\.00/i)).toBeInTheDocument();
});
it("removes item on button click", async () => {
useCartStore.setState({
items: [{ id: "1", name: "Widget", price: 10, quantity: 1 }],
});
render(<CartSummary />);
await user.click(screen.getByRole("button", { name: /remove widget/i }));
expect(screen.getByText(/your cart is empty/i)).toBeInTheDocument();
});
});What this demonstrates:
- Custom render wrapper for context-dependent components
- Testing context providers and consumers together
- Zustand store unit tests with
getState()andsetState() - Resetting Zustand state between tests
- Testing components that read from Zustand
Deep Dive
How It Works
- The
wrapperoption inrender()wraps the test component in providers without needing to do it in every test - Context providers create a new scope -- tests see the values from the test's provider, not the app's
- Zustand stores are singletons, so state persists between tests in the same file unless explicitly reset
useCartStore.setState()directly sets store state without triggering actions, useful for test setupuseCartStore.getState()reads the current state synchronously, useful for store-level unit tests
Variations
Reusable test utilities file:
// src/test/utils.tsx
import { render, RenderOptions } from "@testing-library/react";
import { ThemeProvider } from "@/context/theme-context";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
interface CustomRenderOptions extends Omit<RenderOptions, "wrapper"> {
theme?: "light" | "dark";
}
export function renderWithProviders(
ui: React.ReactElement,
{ theme = "light", ...options }: CustomRenderOptions = {}
) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(ui, {
wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}>
<ThemeProvider initialTheme={theme}>{children}</ThemeProvider>
</QueryClientProvider>
),
...options,
});
}
// Re-export everything from Testing Library
export * from "@testing-library/react";
export { default as userEvent } from "@testing-library/user-event";// In test files, import from your utils instead
import { renderWithProviders, screen, userEvent } from "@/test/utils";Testing Zustand with middleware (persist, devtools):
// Stores with persist middleware need localStorage mocking
beforeEach(() => {
localStorage.clear();
useCartStore.setState({ items: [] });
});TypeScript Notes
// Typing custom render options
interface CustomRenderOptions extends Omit<RenderOptions, "wrapper"> {
theme?: "light" | "dark";
initialCartItems?: CartItem[];
}
// Typing wrapper components
function Wrapper({ children }: { children: React.ReactNode }) {
return <ThemeProvider>{children}</ThemeProvider>;
}Gotchas
-
Zustand state leaking between tests -- Zustand stores are module-level singletons. State set in one test persists to the next. Fix: Call
useCartStore.setState({ items: [] })inbeforeEach. -
Missing provider wrapper -- Components using
useContextthrow if no provider is found. Fix: Always use a custom render that wraps with required providers. -
Testing context in isolation vs integration -- Testing the provider and consumer separately can miss integration bugs. Fix: Test them together with the real provider whenever possible.
-
QueryClient leaking between tests -- React Query's cache persists if you reuse the same QueryClient. Fix: Create a new
QueryClientin the wrapper for each test, withretry: falseto avoid test timeouts. -
Zustand subscriptions not updating in tests -- If you modify the store outside React (e.g.,
getState().addItem()), components may not re-render. Fix: Useact()around store modifications that should trigger re-renders, or set state before rendering.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| Mocking the context hook | You want to test a component in isolation without the real provider | You want to test the provider's logic |
Zustand getState() unit tests | You want to test store logic without rendering components | You need to verify UI reactions to state changes |
| Integration tests | You want to test multiple components sharing state together | Fast isolated unit tests are sufficient |
| Dependency injection via props | The component can accept values as props instead of reading context | The component is deeply nested and prop drilling is impractical |
FAQs
How do I create a custom render function that wraps components in providers?
function renderWithProviders(ui: React.ReactElement) {
return render(ui, {
wrapper: ({ children }) => (
<ThemeProvider><AuthProvider>{children}</AuthProvider></ThemeProvider>
),
});
}Why does Zustand state leak between tests?
Zustand stores are module-level singletons. State set in one test persists to the next in the same file. Always reset state in beforeEach:
beforeEach(() => {
useCartStore.setState({ items: [] });
});How do I test a Zustand store without rendering a component?
Use getState() and setState() directly:
useCartStore.getState().addItem({ id: "1", name: "W", price: 10 });
expect(useCartStore.getState().items).toHaveLength(1);Gotcha: What happens if I render a component that uses useContext without a provider?
It throws an error. Always wrap context-dependent components in their required provider, either directly or via a custom render wrapper.
How do I test that a context provider and consumer work together?
Render the consumer inside the real provider and interact with it:
renderWithTheme(<ThemeToggle />);
await user.click(screen.getByRole("button"));
expect(screen.getByRole("button")).toHaveTextContent("Switch to light");How do I prevent React Query cache from leaking between tests?
Create a new QueryClient in the wrapper for each test with retry: false:
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});How do I test a component that reads from a Zustand store?
Set the store state before rendering:
useCartStore.setState({
items: [{ id: "1", name: "Widget", price: 10, quantity: 2 }],
});
render(<CartSummary />);
expect(screen.getByText(/widget x2/i)).toBeInTheDocument();Gotcha: Why doesn't my component re-render when I modify Zustand state outside React?
Modifying the store via getState().addItem() outside a React render may not trigger re-renders. Use act() around store modifications, or set state before rendering.
How do I type the custom render options in TypeScript?
interface CustomRenderOptions extends Omit<RenderOptions, "wrapper"> {
theme?: "light" | "dark";
initialCartItems?: CartItem[];
}Should I test the context provider and consumer separately or together?
Test them together with the real provider whenever possible. Testing separately can miss integration bugs. Only mock the context hook if you need true isolation.
How do I handle Zustand stores with persist middleware in tests?
Clear localStorage before each test:
beforeEach(() => {
localStorage.clear();
useCartStore.setState({ items: [] });
});Can I re-export Testing Library utilities from a custom test utils file?
Yes:
// src/test/utils.tsx
export * from "@testing-library/react";
export { default as userEvent } from "@testing-library/user-event";
export { renderWithProviders };Then import from @/test/utils in all test files.
Related
- Testing Custom Hooks -- testing hooks with context via renderHook wrapper
- Testing Components -- component rendering and interaction
- Mocking in Tests -- mocking modules and context
- React Testing Library Fundamentals -- render and query basics