Test actions through the store API, then mount the connected React component and test what the user sees. Reset the stores after every test so one test's updates do not become another test's starting state. Use React Testing Library for components and Mock Service Worker (MSW) for network requests, without replacing your application's actions or fetch.
The examples below render a counter starting at 1. Clicking One Up changes it to 2; clicking Load greeting displays the response text from your greeting endpoint. The tests supply their own greeting through MSW.
1. Add the testing tools
Start in a TypeScript React project with Zustand installed. Choose one runner. Both setups use JSDOM to mount React components in a DOM environment.
Vitest
bashnpm install -D vitest jsdom @testing-library/react @testing-library/user-event @testing-library/jest-dom msw
Jest
bashnpm install -D jest jest-environment-jsdom ts-jest ts-node @types/jest @testing-library/react @testing-library/user-event @testing-library/jest-dom msw
Jest and Vitest have different module-loading conventions. Use the configuration for your runner rather than mixing Jest's CommonJS mock loading with Vitest's ES module loading. For projects with additional transforms, follow the Jest setup documentation or Vitest setup documentation.
2. Create the store and connected component
Keep create and the StateCreator-typed initializer in application code so the tests exercise the real counter and network actions; see Subscribe outside React for the store API used to inspect and reset state.
tsimport { create, type StateCreator } from 'zustand'
export type CounterState = {
count: number
greeting: string
inc: () => void
loadGreeting: () => Promise<void>
}
const counterStoreCreator: StateCreator<CounterState> = (set) => ({
count: 1,
greeting: '',
inc: () => set((state) => ({ count: state.count + 1 })),
loadGreeting: async () => {
const response = await fetch('https://api.example.com/greeting')
if (!response.ok) {
throw new Error(`Greeting request failed: ${response.status}`)
}
const greeting = await response.text()
set({ greeting })
},
})
export const useCounterStore = create<CounterState>()(counterStoreCreator)
Replace https://api.example.com/greeting with your endpoint in the application and the test handler. This example expects a text response.
Select the values and actions the component needs. No provider is required for this bound store.
tsximport * as React from 'react'
import { useCounterStore } from './store'
export function Counter() {
const count = useCounterStore((state) => state.count)
const inc = useCounterStore((state) => state.inc)
const greeting = useCounterStore((state) => state.greeting)
const loadGreeting = useCounterStore((state) => state.loadGreeting)
return (
<section>
<h2>Counter Store</h2>
<p aria-label="Count">{count}</p>
<button onClick={inc}>One Up</button>
<button onClick={() => void loadGreeting()}>Load greeting</button>
<p role="status">{greeting}</p>
</section>
)
}
The tests below mount this component with render(<Counter />). They check both the store's state and the rendered count after an update.
You can also connect a component through useStore, passing the store API and a selector. This alternative counter uses the same store and renders the same count and increment button.
tsximport * as React from 'react'
import { useStore } from 'zustand'
import { useCounterStore } from './store'
export function CounterWithUseStore() {
const count = useStore(useCounterStore, (state) => state.count)
const inc = useStore(useCounterStore, (state) => state.inc)
return (
<section>
<h2>Counter Store</h2>
<p aria-label="Count">{count}</p>
<button onClick={inc}>One Up</button>
</section>
)
}
3. Reset state and intercept requests
Put the shared test support in one file. getInitialState() returns the state created by the initializer; getState() returns the current state. Restore the complete initial state with setState(initialState, true), following the authors' reset pattern. Keep this reset in test teardown, not in application actions.
tsimport { act, cleanup } from '@testing-library/react'
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
import { useCounterStore } from '../src/store'
const initialState = useCounterStore.getInitialState()
const storeResetFns = new Set<() => void>([
() => useCounterStore.setState(initialState, true),
])
export const server = setupServer(
http.get('https://api.example.com/greeting', () =>
HttpResponse.text('Hello from the test'),
),
)
export function resetAfterTest() {
cleanup()
act(() => {
storeResetFns.forEach((reset) => reset())
})
server.resetHandlers()
}
Add a reset function for each additional shared store your tests use. This example registers the real store explicitly: it does not mock the zustand module. MSW intercepts the request while the real loadGreeting() action still runs.
Vitest setup
tsimport '@testing-library/jest-dom/vitest'
import { afterAll, afterEach, beforeAll } from 'vitest'
import { resetAfterTest, server } from './support'
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(resetAfterTest)
afterAll(() => server.close())
tsimport { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'jsdom',
setupFiles: ['./tests/setup-vitest.ts'],
include: ['tests/**/*.vitest.test.tsx'],
},
})
Jest setup
tsimport '@testing-library/jest-dom/jest-globals'
import { afterAll, afterEach, beforeAll } from '@jest/globals'
import { resetAfterTest, server } from './support'
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(resetAfterTest)
afterAll(() => server.close())
tsconst config = {
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['./tests/setup-jest.ts'],
testMatch: ['**/tests/**/*.jest.test.tsx'],
transform: {
'^.+\\.tsx?$': [
'ts-jest',
{ tsconfig: { strict: true, jsx: 'react-jsx', module: 'CommonJS' } },
],
},
}
export default config
Use a Node runtime and test environment compatible with your installed MSW version. Follow MSW's Node integration instructions for any required environment support.
4. Assert actions, rendered updates, and asynchronous results
Use your runner's test file below. The first test calls the real action inside act() on a mounted component. The second uses the button, checking that the counter starts at 1 again. The third waits for the network action's result in the DOM rather than sleeping for a fixed duration.
Vitest tests
tsximport * as React from 'react'
import { expect, test } from 'vitest'
import { act, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Counter } from '../src/Counter'
import { useCounterStore } from '../src/store'
test('the action updates the store and its connected component', () => {
render(<Counter />)
expect(useCounterStore.getState().count).toBe(1)
act(() => useCounterStore.getState().inc())
expect(useCounterStore.getState().count).toBe(2)
expect(screen.getByLabelText('Count')).toHaveTextContent('2')
})
test('a button updates the counter from its initial state', async () => {
const user = userEvent.setup()
render(<Counter />)
expect(screen.getByLabelText('Count')).toHaveTextContent('1')
await user.click(screen.getByRole('button', { name: 'One Up' }))
expect(screen.getByLabelText('Count')).toHaveTextContent('2')
expect(useCounterStore.getState().count).toBe(2)
})
test('the real network action displays the intercepted response', async () => {
const user = userEvent.setup()
render(<Counter />)
await user.click(screen.getByRole('button', { name: 'Load greeting' }))
expect(await screen.findByText('Hello from the test')).toBeInTheDocument()
expect(useCounterStore.getState().greeting).toBe('Hello from the test')
})
bashnpx vitest run
Jest tests
tsximport * as React from 'react'
import { expect, test } from '@jest/globals'
import { act, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Counter } from '../src/Counter'
import { useCounterStore } from '../src/store'
test('the action updates the store and its connected component', () => {
render(<Counter />)
expect(useCounterStore.getState().count).toBe(1)
act(() => useCounterStore.getState().inc())
expect(useCounterStore.getState().count).toBe(2)
expect(screen.getByLabelText('Count')).toHaveTextContent('2')
})
test('a button updates the counter from its initial state', async () => {
const user = userEvent.setup()
render(<Counter />)
expect(screen.getByLabelText('Count')).toHaveTextContent('1')
await user.click(screen.getByRole('button', { name: 'One Up' }))
expect(screen.getByLabelText('Count')).toHaveTextContent('2')
expect(useCounterStore.getState().count).toBe(2)
})
test('the real network action displays the intercepted response', async () => {
const user = userEvent.setup()
render(<Counter />)
await user.click(screen.getByRole('button', { name: 'Load greeting' }))
expect(await screen.findByText('Hello from the test')).toBeInTheDocument()
expect(useCounterStore.getState().greeting).toBe('Hello from the test')
})
bashnpx jest
Hello from the test is the fixture supplied by the handler, not a promise about what your real service returns. In your application, the status paragraph displays your endpoint's response text.
Options that matter
| Option | Type | Default | What it does |
|---|---|---|---|
setState's replace argument | boolean | For object updates, omitted means merge | Pass true in teardown to restore the complete initial state rather than shallow-merge it with the current state. |
Pitfalls
- A module-level store survives component unmounts. Reset every shared store used by the suite; DOM cleanup alone does not reset its state.
- Wrap direct store updates affecting mounted components in
act(), including teardown resets. Await user interactions and asynchronous DOM assertions. - If you adopt automatic Zustand mocks, put Vitest's manual-mock directory under its configured
root. The explicit-reset setup above needs no manual-mock directory. - For replacement-state typing and preservation of actions, see Type stores and middleware. For selector stability, see Selectors and subscriptions.
Live demos
Explore the authors' Jest testing demo or Vitest testing demo for their automatic store-reset mock and counter component tests.
Related
- Reset store state — reset one store or multiple stores.
- Initialize scoped stores — supply a scoped store through React context.
- State and actions — immutable updates and colocated actions.
Was this page helpful?