React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

contextzustandprovidersstate-managementcustom-render

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() and setState()
  • Resetting Zustand state between tests
  • Testing components that read from Zustand

Deep Dive

How It Works

  • The wrapper option in render() 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 setup
  • useCartStore.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: [] }) 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.

Alternatives

AlternativeUse WhenDon't Use When
Mocking the context hookYou want to test a component in isolation without the real providerYou want to test the provider's logic
Zustand getState() unit testsYou want to test store logic without rendering componentsYou need to verify UI reactions to state changes
Integration testsYou want to test multiple components sharing state togetherFast isolated unit tests are sufficient
Dependency injection via propsThe component can accept values as props instead of reading contextThe 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.