Testing Zustand Components: From Unit to E2E Tests
Zustand has become one of the most popular state management libraries in the React ecosystem thanks to its minimal API, lack of boilerplate, and hook-based design. However, the very flexibility that makes Zustand delightful to use can also make it tricky to test if you don't establish clear patterns. In this tutorial, we'll walk through a complete testing strategy that covers unit tests for your stores, integration tests for components that consume them, and end-to-end tests that validate entire user flows.
Why Testing Zustand Matters
State is the heart of any React application. When state changes silently or behaves unexpectedly, debugging becomes painful. Testing Zustand gives you confidence that:
- Your store actions produce the expected state transitions.
- Selectors return the correct slices of state under different conditions.
- Components re-render appropriately when state changes.
- Asynchronous flows such as API calls update state correctly.
- User journeys from login to checkout work end to end.
Without a layered testing strategy, you risk either over-testing implementation details or missing critical regressions. Let's build that strategy from the ground up.
Setting Up the Example Store
Before we write tests, we need a store to test. We'll build a small cart store that demonstrates synchronous actions, asynchronous actions, and derived selectors.
The Cart Store
Create a file named cartStore.js:
import { create } from 'zustand';
export const useCartStore = create((set, get) => ({
items: [],
coupon: null,
isLoading: false,
error: null,
addItem: (product) =>
set((state) => {
const existing = state.items.find((item) => item.id === product.id);
if (existing) {
return {
items: state.items.map((item) =>
item.id === product.id
? { ...item, quantity: item.quantity + 1 }
: item
),
};
}
return { items: [...state.items, { ...product, quantity: 1 }] };
}),
removeItem: (productId) =>
set((state) => ({
items: state.items.filter((item) => item.id !== productId),
})),
updateQuantity: (productId, quantity) =>
set((state) => ({
items: state.items.map((item) =>
item.id === productId
? { ...item, quantity: Math.max(1, quantity) }
: item
),
})),
applyCoupon: async (code) => {
set({ isLoading: true, error: null });
try {
const response = await fetch(`/api/coupons/${code}`);
if (!response.ok) throw new Error('Invalid coupon');
const coupon = await response.json();
set({ coupon, isLoading: false });
} catch (err) {
set({ error: err.message, isLoading: false, coupon: null });
}
},
clearCart: () => set({ items: [], coupon: null }),
// Derived selector
getTotalItems: () =>
get().items.reduce((sum, item) => sum + item.quantity, 0),
getTotalPrice: () => {
const subtotal = get().items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
if (get().coupon) {
return subtotal * (1 - get().coupon.discount);
}
return subtotal;
},
}));
This store has everything we need: simple state mutations, an async action with loading and error states, and derived selectors. Now let's test each layer.
Unit Testing the Store
Unit tests focus on the store in isolation. The key challenge with Zustand is that a store created with create() is a singleton. If one test mutates state, the next test will see the mutated state. To avoid this, we have two main options: reset the store between tests or create a fresh store instance per test.
Approach 1: Resetting State Between Tests
The simplest approach is to reset the store in a beforeEach hook. Zustand exposes the underlying store via the getState and setState methods on the hook.
import { describe, it, expect, beforeEach } from 'vitest';
import { useCartStore } from './cartStore';
describe('Cart Store', () => {
beforeEach(() => {
useCartStore.setState({
items: [],
coupon: null,
isLoading: false,
error: null,
});
});
it('should add a new item to the cart', () => {
const { addItem } = 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('should increment quantity when adding an existing item', () => {
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('should remove an item by id', () => {
useCartStore.setState({
items: [
{ id: 1, name: 'Widget', price: 10, quantity: 1 },
{ id: 2, name: 'Gadget', price: 20, quantity: 1 },
],
});
useCartStore.getState().removeItem(1);
const { items } = useCartStore.getState();
expect(items).toHaveLength(1);
expect(items[0].id).toBe(2);
});
it('should not allow quantity below 1', () => {
useCartStore.setState({
items: [{ id: 1, name: 'Widget', price: 10, quantity: 2 }],
});
useCartStore.getState().updateQuantity(1, 0);
const { items } = useCartStore.getState();
expect(items[0].quantity).toBe(1);
});
it('should calculate total items correctly', () => {
useCartStore.setState({
items: [
{ id: 1, name: 'Widget', price: 10, quantity: 3 },
{ id: 2, name: 'Gadget', price: 20, quantity: 2 },
],
});
expect(useCartStore.getState().getTotalItems()).toBe(5);
});
it('should apply coupon discount to total price', () => {
useCartStore.setState({
items: [{ id: 1, name: 'Widget', price: 100, quantity: 1 }],
coupon: { code: 'SAVE10', discount: 0.1 },
});
expect(useCartStore.getState().getTotalPrice()).toBe(90);
});
it('should clear the cart', () => {
useCartStore.setState({
items: [{ id: 1, name: 'Widget', price: 10, quantity: 1 }],
coupon: { code: 'SAVE10', discount: 0.1 },
});
useCartStore.getState().clearCart();
const state = useCartStore.getState();
expect(state.items).toHaveLength(0);
expect(state.coupon).toBeNull();
});
});
Approach 2: Factory Function for Fresh Stores
For more complex applications, a cleaner approach is to export a factory function that creates a new store instance. This makes tests fully isolated and also enables server-side rendering where each request needs its own store.
// cartStore.js
import { create } from 'zustand';
export const createCartStore = () =>
create((set, get) => ({
items: [],
coupon: null,
isLoading: false,
error: null,
// ... same implementation as before
}));
// Default singleton for app usage
export const useCartStore = createCartStore();
Now in tests, you can create a fresh store for each test:
import { describe, it, expect } from 'vitest';
import { createCartStore } from './cartStore';
describe('Cart Store (factory)', () => {
it('should add an item', () => {
const useStore = createCartStore();
useStore.getState().addItem({ id: 1, name: 'Widget', price: 10 });
expect(useStore.getState().items).toHaveLength(1);
});
it('should start with empty items', () => {
const useStore = createCartStore();
expect(useStore.getState().items).toHaveLength(0);
});
});
Testing Asynchronous Actions
Async actions require mocking the network layer. Using vi.fn() from Vitest (or jest.fn() in Jest), you can control what fetch returns and verify error handling.
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { useCartStore } from './cartStore';
describe('Cart Store - applyCoupon', () => {
beforeEach(() => {
useCartStore.setState({
items: [],
coupon: null,
isLoading: false,
error: null,
});
vi.restoreAllMocks();
});
it('should apply a valid coupon', async () => {
vi.spyOn(global, 'fetch').mockResolvedValue({
ok: true,
json: async () => ({ code: 'SAVE10', discount: 0.1 }),
});
await useCartStore.getState().applyCoupon('SAVE10');
const state = useCartStore.getState();
expect(state.coupon).toEqual({ code: 'SAVE10', discount: 0.1 });
expect(state.isLoading).toBe(false);
expect(state.error).toBeNull();
expect(fetch).toHaveBeenCalledWith('/api/coupons/SAVE10');
});
it('should set error on invalid coupon', async () => {
vi.spyOn(global, 'fetch').mockResolvedValue({
ok: false,
status: 404,
});
await useCartStore.getState().applyCoupon('INVALID');
const state = useCartStore.getState();
expect(state.coupon).toBeNull();
expect(state.error).toBe('Invalid coupon');
expect(state.isLoading).toBe(false);
});
it('should handle network errors', async () => {
vi.spyOn(global, 'fetch').mockRejectedValue(new Error('Network failure'));
await useCartStore.getState().applyCoupon('SAVE10');
const state = useCartStore.getState();
expect(state.error).toBe('Network failure');
expect(state.isLoading).toBe(false);
});
});
Integration Testing Components
Unit tests verify the store logic, but they don't tell you whether your React components correctly interact with that store. Integration tests bridge that gap by rendering components and simulating user interactions.
Setting Up Testing Library
We'll use @testing-library/react along with @testing-library/user-event for realistic interaction simulation. Make sure you have these installed:
npm install -D @testing-library/react @testing-library/user-event @testing-library/jest-dom
A Cart Component
Here's a simple cart component that consumes our store:
// Cart.jsx
import { useCartStore } from './cartStore';
export function Cart() {
const items = useCartStore((state) => state.items);
const removeItem = useCartStore((state) => state.removeItem);
const updateQuantity = useCartStore((state) => state.updateQuantity);
const totalPrice = useCartStore((state) => state.getTotalPrice());
if (items.length === 0) {
return <div data-testid="empty-cart">Your cart is empty</div>;
}
return (
<div data-testid="cart">
<ul>
{items.map((item) => (
<li key={item.id} data-testid={`cart-item-${item.id}`}>
<span>{item.name}</span>
<span>${item.price}</span>
<input
type="number"
min="1"
value={item.quantity}
onChange={(e) =>
updateQuantity(item.id, parseInt(e.target.value, 10))
}
data-testid={`quantity-${item.id}`}
/>
<button
onClick={() => removeItem(item.id)}
data-testid={`remove-${item.id}`}
>
Remove
</button>
</li>
))}
</ul>
<div data-testid="total">Total: ${totalPrice}</div>
</div>
);
}
Writing Integration Tests
The most important principle here is to interact with the component as a user would, not by directly manipulating the store. However, you may need to seed initial state before rendering.
import { describe, it, expect, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Cart } from './Cart';
import { useCartStore } from './cartStore';
describe('Cart Component', () => {
beforeEach(() => {
useCartStore.setState({
items: [],
coupon: null,
isLoading: false,
error: null,
});
});
it('should show empty message when cart is empty', () => {
render(<Cart />);
expect(screen.getByTestId('empty-cart')).toHaveTextContent(
'Your cart is empty'
);
});
it('should render items from the store', () => {
useCartStore.setState({
items: [
{ id: 1, name: 'Widget', price: 10, quantity: 2 },
{ id: 2, name: 'Gadget', price: 25, quantity: 1 },
],
});
render(<Cart />);
expect(screen.getByTestId('cart-item-1')).toHaveTextContent('Widget');
expect(screen.getByTestId('cart-item-2')).toHaveTextContent('Gadget');
expect(screen.getByTestId('total')).toHaveTextContent('Total: $45');
});
it('should remove an item when remove button is clicked', async () => {
const user = userEvent.setup();
useCartStore.setState({
items: [{ id: 1, name: 'Widget', price: 10, quantity: 1 }],
});
render(<Cart />);
await user.click(screen.getByTestId('remove-1'));
expect(screen.queryByTestId('cart-item-1')).not.toBeInTheDocument();
expect(screen.getByTestId('empty-cart')).toBeInTheDocument();
});
it('should update quantity when input changes', async () => {
const user = userEvent.setup();
useCartStore.setState({
items: [{ id: 1, name: 'Widget', price: 10, quantity: 1 }],
});
render(<Cart />);
const input = screen.getByTestId('quantity-1');
await user.clear(input);
await user.type(input, '3');
expect(screen.getByTestId('total')).toHaveTextContent('Total: $30');
});
it('should reflect store changes from external updates', () => {
useCartStore.setState({
items: [{ id: 1, name: 'Widget', price: 10, quantity: 1 }],
});
render(<Cart />);
expect(screen.getByTestId('total')).toHaveTextContent('Total: $10');
// Simulate an external state change
useCartStore.getState().addItem({ id: 2, name: 'Gadget', price: 20 });
expect(screen.getByTestId('total')).toHaveTextContent('Total: $30');
});
});
Testing Components with a Custom Store Instance
If you used the factory pattern, you may want to test a component with a specific store instance rather than the global singleton. You can achieve this with a context provider pattern.
// CartContext.jsx
import { createContext, useContext } from 'react';
const CartStoreContext = createContext(null);
export function CartStoreProvider({ store, children }) {
return (
<CartStoreContext.Provider value={store}>
{children}
</CartStoreContext.Provider>
);
}
export function useCartStoreContext() {
const store = useContext(CartStoreContext);
if (!store) throw new Error('useCartStoreContext must be used within provider');
return store;
}
Then update your component to use the context-based store, and in tests you can provide a fresh store:
import { render, screen } from '@testing-library/react';
import { createCartStore } from './cartStore';
import { CartStoreProvider } from './CartContext';
import { Cart } from './Cart';
it('should render with a custom store instance', () => {
const store = createCartStore();
store.setState({
items: [{ id: 1, name: 'Widget', price: 10, quantity: 1 }],
});
render(
<CartStoreProvider store={store}>
<Cart />
</CartStoreProvider>
);
expect(screen.getByTestId('total')).toHaveTextContent('Total: $10');
});
End-to-End Testing with Playwright
End-to-end tests validate complete user journeys through the real application, including the browser, network calls, and routing. We'll use Playwright, which is fast, reliable, and has excellent developer experience.
Installing Playwright
npm install -D @playwright/test
npx playwright install
Playwright Configuration
Create playwright.config.js:
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
},
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});
Writing E2E Tests
Unlike unit and integration tests, E2E tests should not directly manipulate the Zustand store. Instead, they should interact with the application exactly as a user would: clicking buttons, typing in inputs, and navigating between pages.
// e2e/cart.spec.js
import { test, expect } from '@playwright/test';
test.describe('Shopping Cart E2E', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('should add a product to the cart', async ({ page }) => {
await page.getByTestId('product-1').getByRole('button', { name: 'Add to Cart' }).click();
await expect(page.getByTestId('cart-count')).toHaveText('1');
await expect(page.getByTestId('cart-item-1')).toBeVisible();
});
test('should update quantity and reflect new total', async ({ page }) => {
// Add two different products
await page.getByTestId('product-1').getByRole('button', { name: 'Add to Cart' }).click();
await page.getByTestId('product-2').getByRole('button', { name: 'Add to Cart' }).click();
// Go to cart page
await page.getByRole('link', { name: 'Cart' }).click();
// Update quantity of first item
const quantityInput = page.getByTestId('quantity-1');
await quantityInput.fill('3');
// Verify total updates
await expect(page.getByTestId('total')).toContainText('$');
});
test('should apply a coupon and show discounted total', async ({ page }) => {
await page.getByTestId('product-1').getByRole('button', { name: 'Add to Cart' }).click();
await page.getByRole('link', { name: 'Cart' }).click();
await page.getByTestId('coupon-input').fill('SAVE10');
await page.getByTestId('apply-coupon').click();
await expect(page.getByTestId('coupon-badge')).toHaveText('SAVE10');
await expect(page.getByTestId('total')).toBeVisible();
});
test('should show error for invalid coupon', async ({ page }) => {
await page.getByTestId('product-1').getByRole('button', { name: 'Add to Cart' }).click();
await page.getByRole('link', { name: 'Cart' }).click();
await page.getByTestId('coupon-input').fill('INVALID');
await page.getByTestId('apply-coupon').click();
await expect(page.getByTestId('coupon-error')).toHaveText('Invalid coupon');
});
test('should complete checkout flow', async ({ page }) => {
await page.getByTestId('product-1').getByRole('button', { name: 'Add to Cart' }).click();
await page.getByRole('link', { name: 'Cart' }).click();
await page.getByRole('button', { name: 'Checkout' }).click();
await expect(page).toHaveURL(/\/checkout/);
await page.getByTestId('email').fill('test@example.com');
await page.getByTestId('address').fill('123 Test Street');
await page.getByRole('button', { name: 'Place Order' }).click();
await expect(page.getByTestId('order-confirmation')).toBeVisible();
await expect(page.getByTestId('cart-count')).toHaveText('0');
});
});
Mocking API Responses in E2E Tests
Sometimes you want deterministic E2E tests without hitting a real backend. Playwright lets you intercept network requests with page.route().
test('should apply coupon with mocked API', async ({ page }) => {
await page.route('**/api/coupons/*', async (route) => {
const code = route.request().url().split('/').pop();
if (code === 'SAVE10') {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ code: 'SAVE10', discount: 0.1 }),
});
} else {
await route.fulfill({ status: 404 });
}
});
await page.goto('/');
await page.getByTestId('product-1').getByRole('button', { name: 'Add to Cart' }).click();
await page.getByRole('link', { name: 'Cart' }).click();
await page.getByTestId('coupon-input').fill('SAVE10');
await page.getByTestId('apply-coupon').click();
await expect(page.getByTestId('coupon-badge')).toHaveText('SAVE10');
});
Best Practices
1. Test Behavior, Not Implementation
Avoid asserting on internal store structure in component tests. Instead, verify what the user sees. If you test that a component calls addItem, you're coupling your test to implementation. If you test that the item appears in the cart, you're testing behavior that survives refactors.
2. Reset State Between Tests
Zustand stores are singletons by default. Always reset state in beforeEach or use the factory pattern to ensure tests don't leak state into each other. Flaky tests caused by shared state are among the hardest bugs to diagnose.
3. Use Selectors Wisely
When testing components, use specific selectors with data-testid attributes. This keeps tests resilient to CSS and markup changes. Avoid selecting by text content alone when the text might be dynamic or localized.
4. Mock at the Right Level
- In unit tests, mock
fetchor API modules to test store logic in isolation. - In integration tests, mock API calls but let the store and components run for real.
- In E2E tests, mock APIs only when necessary for determinism; prefer running against a real or staging backend.
5. Keep Tests Fast and Focused
Unit tests should run in milliseconds. Integration tests should complete in under a second each. Reserve slow E2E tests for critical user journeys. A healthy test pyramid has many unit tests, a moderate number of integration tests, and a few targeted E2E tests.
6. Test Error States Explicitly
Happy paths are easy to test but error states are where bugs hide. Always test loading states, failed API calls, empty data, and edge cases like zero quantities or expired coupons.
7. Avoid Testing React's Re-render Mechanism
Don't write tests that count re-renders or assert on internal component state. Zustand's integration with React is well-tested by the library itself. Focus on whether the UI reflects the correct state after interactions.
Conclusion
Testing Zustand components effectively requires a layered approach: unit tests validate your store logic in isolation, integration tests confirm that components correctly consume and react to store changes, and end-to-end tests verify that complete user journeys work in a real browser environment. By resetting state between tests, mocking at the appropriate level for each test type, and focusing on user-visible behavior rather than implementation details, you can build a robust test suite that catches regressions early without becoming a maintenance burden. The patterns shown here scale from small projects to large applications, giving you confidence every time you ship.