Testing Context & State Management
Test components that depend on React Context, Zustand stores, or other global state providers.
Search across all documentation pages
Test components that depend on React Context, Zustand stores, or other global state providers.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
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.
// 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:
getState() and setState()wrapper option in render() wraps the test component in providers without needing to do it in every testuseCartStore.setState() directly sets store state without triggering actions, useful for test setupuseCartStore.getState() reads the current state synchronously, useful for store-level unit testsReusable 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: [] });
});// 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>;
}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: [] }) in beforeEach.
Missing provider wrapper -- Components using useContext throw 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 QueryClient in the wrapper for each test, with retry: false to 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: Use act() around store modifications that should trigger re-renders, or set state before rendering.
| 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 |
function renderWithProviders(ui: React.ReactElement) {
return render(ui, {
wrapper: ({ children }) => (
<ThemeProvider><AuthProvider>{children}</AuthProvider></ThemeProvider>
),
});
}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: [] });
});Use getState() and setState() directly:
useCartStore.getState().addItem({ id: "1", name: "W", price: 10 });
expect(useCartStore.getState().items).toHaveLength(1);It throws an error. Always wrap context-dependent components in their required provider, either directly or via a custom render wrapper.
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");Create a new QueryClient in the wrapper for each test with retry: false:
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});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();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.
interface CustomRenderOptions extends Omit<RenderOptions, "wrapper"> {
theme?: "light" | "dark";
initialCartItems?: CartItem[];
}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.
Clear localStorage before each test:
beforeEach(() => {
localStorage.clear();
useCartStore.setState({ items: [] });
});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.
Reviewed by Chris St. John·Last updated Jul 16, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥