Testing Jotai Components: From Unit to E2E Tests
Jotai is a primitive and flexible state management library for React that takes an atomic approach to global state. Unlike Redux's single-store model or Context API's tree-wide re-renders, Jotai lets you define independent atoms of state that components can subscribe to. While this model simplifies state management, it introduces unique testing challenges. Atoms can be composed, derived, and shared across components, making it essential to test them in isolation as well as within the context of your full application.
This tutorial walks you through a complete testing strategy for Jotai-powered applications, covering unit tests for atoms, integration tests for components, and end-to-end tests for user flows. By the end, you will have a robust toolkit for verifying that your state logic and UI behave correctly at every level.
Why Testing Jotai Matters
Testing state management is critical because state is the single source of truth for your application's behavior. With Jotai, a few specific risks make testing especially valuable:
- Derived atoms can introduce subtle bugs — a small change in a base atom can cascade through derived atoms in unexpected ways.
- Async atoms are easy to misuse — race conditions, error handling, and loading states all need verification.
- Atom composition can become complex — when atoms depend on other atoms, the dependency graph must be tested for correctness.
- Provider isolation matters — tests must ensure that state does not leak between test cases.
Project Setup
Before writing tests, set up a project with Jotai and a testing stack. We will use Vitest for unit and integration tests, React Testing Library for component tests, and Playwright for end-to-end tests.
npm install jotai react react-dom
npm install -D vitest @testing-library/react @testing-library/jest-dom jsdom
npm install -D @playwright/test
Create a vitest.config.ts file to configure the test environment:
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: './test/setup.ts',
},
});
In test/setup.ts, import the custom matchers from Testing Library:
import '@testing-library/jest-dom';
Unit Testing Atoms in Isolation
The simplest unit tests target individual atoms. Jotai provides a createStore function and a useAtomValue hook, but for pure unit tests we can use the useSetAtom and useAtomValue hooks inside a test harness, or we can test atoms using the store API directly.
Let's define a few atoms for a simple counter application:
// src/atoms/counter.ts
import { atom } from 'jotai';
export const countAtom = atom(0);
export const doubleCountAtom = atom((get) => get(countAtom) * 2);
export const isEvenAtom = atom((get) => get(countAtom) % 2 === 0);
To test these atoms without rendering React components, we can use Jotai's createStore and read or write atoms directly:
// src/atoms/counter.test.ts
import { describe, it, expect } from 'vitest';
import { createStore } from 'jotai';
import { countAtom, doubleCountAtom, isEvenAtom } from './counter';
describe('counter atoms', () => {
it('countAtom starts at 0', () => {
const store = createStore();
expect(store.get(countAtom)).toBe(0);
});
it('doubleCountAtom reflects twice the count', () => {
const store = createStore();
store.set(countAtom, 5);
expect(store.get(doubleCountAtom)).toBe(10);
});
it('isEvenAtom correctly identifies even numbers', () => {
const store = createStore();
store.set(countAtom, 4);
expect(store.get(isEvenAtom)).toBe(true);
store.set(countAtom, 7);
expect(store.get(isEvenAtom)).toBe(false);
});
it('updating countAtom updates derived atoms', () => {
const store = createStore();
store.set(countAtom, 3);
expect(store.get(doubleCountAtom)).toBe(6);
store.set(countAtom, 10);
expect(store.get(doubleCountAtom)).toBe(20);
});
});
This approach is fast and does not require a React rendering environment. It is ideal for testing pure logic in derived atoms.
Testing Writable Atoms and Actions
Many applications use writable atoms or action atoms that encapsulate state updates. Consider a todo list with an action atom that adds items:
// src/atoms/todos.ts
import { atom } from 'jotai';
export interface Todo {
id: number;
text: string;
done: boolean;
}
export const todosAtom = atom<Todo[]>([]);
export const addTodoAtom = atom(null, (get, set, text: string) => {
const current = get(todosAtom);
const newTodo: Todo = {
id: Date.now(),
text,
done: false,
};
set(todosAtom, [...current, newTodo]);
});
export const toggleTodoAtom = atom(null, (get, set, id: number) => {
const current = get(todosAtom);
set(
todosAtom,
current.map((todo) =>
todo.id === id ? { ...todo, done: !todo.done } : todo
)
);
});
Test these action atoms by calling store.set with the action atom and a payload:
// src/atoms/todos.test.ts
import { describe, it, expect } from 'vitest';
import { createStore } from 'jotai';
import { todosAtom, addTodoAtom, toggleTodoAtom } from './todos';
describe('todo atoms', () => {
it('addTodoAtom adds a new todo', () => {
const store = createStore();
store.set(addTodoAtom, 'Buy groceries');
const todos = store.get(todosAtom);
expect(todos).toHaveLength(1);
expect(todos[0].text).toBe('Buy groceries');
expect(todos[0].done).toBe(false);
});
it('toggleTodoAtom flips the done flag', () => {
const store = createStore();
store.set(addTodoAtom, 'Walk the dog');
const [todo] = store.get(todosAtom);
store.set(toggleTodoAtom, todo.id);
expect(store.get(todosAtom)[0].done).toBe(true);
store.set(toggleTodoAtom, todo.id);
expect(store.get(todosAtom)[0].done).toBe(false);
});
});
Testing Async Atoms
Async atoms are common when fetching data. They require careful testing because their values are promises. Here is an async atom that fetches a user from an API:
// src/atoms/user.ts
import { atom } from 'jotai';
export const userIdAtom = atom(1);
export const userAtom = atom(async (get) => {
const id = get(userIdAtom);
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
throw new Error('Failed to fetch user');
}
return response.json();
});
To test async atoms, mock fetch and await the resolved value from the store:
// src/atoms/user.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createStore } from 'jotai';
import { userIdAtom, userAtom } from './user';
describe('userAtom', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it('fetches user data for the current user id', async () => {
const mockUser = { id: 1, name: 'Alice' };
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockUser),
}));
const store = createStore();
const user = await store.get(userAtom);
expect(user).toEqual(mockUser);
expect(fetch).toHaveBeenCalledWith('/api/users/1');
});
it('throws when the response is not ok', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: false,
status: 404,
}));
const store = createStore();
await expect(store.get(userAtom)).rejects.toThrow('Failed to fetch user');
});
it('refetches when userIdAtom changes', async () => {
const users = {
1: { id: 1, name: 'Alice' },
2: { id: 2, name: 'Bob' },
};
vi.stubGlobal('fetch', vi.fn().mockImplementation(async (url) => {
const id = Number(url.split('/').pop());
return { ok: true, json: () => Promise.resolve(users[id]) };
}));
const store = createStore();
store.set(userIdAtom, 2);
const user = await store.get(userAtom);
expect(user.name).toBe('Bob');
});
});
Integration Testing Components with Jotai
Unit tests cover atom logic, but you also need to verify that components interact with atoms correctly. For this, use React Testing Library with a Jotai Provider. Each test should wrap the component in a fresh Provider to ensure state isolation.
Here is a counter component that reads and updates the count atom:
// src/components/Counter.tsx
import { useAtomValue, useSetAtom } from 'jotai';
import { countAtom, doubleCountAtom } from '../atoms/counter';
export function Counter() {
const count = useAtomValue(countAtom);
const double = useAtomValue(doubleCountAtom);
const setCount = useSetAtom(countAtom);
return (
<div>
<p data-testid="count">{count}</p>
<p data-testid="double">{double}</p>
<button onClick={() => setCount((c) => c + 1)}>Increment</button>
<button onClick={() => setCount((c) => c - 1)}>Decrement</button>
</div>
);
}
Test the component by rendering it inside a Provider and simulating user interactions:
// src/components/Counter.test.tsx
import { describe, it, expect } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { Provider } from 'jotai';
import { Counter } from './Counter';
function renderWithProvider(ui: React.ReactElement) {
return render(<Provider>{ui}</Provider>);
}
describe('Counter', () => {
it('displays the initial count and double', () => {
renderWithProvider(<Counter />);
expect(screen.getByTestId('count')).toHaveTextContent('0');
expect(screen.getByTestId('double')).toHaveTextContent('0');
});
it('increments the count and updates the double', () => {
renderWithProvider(<Counter />);
fireEvent.click(screen.getByText('Increment'));
expect(screen.getByTestId('count')).toHaveTextContent('1');
expect(screen.getByTestId('double')).toHaveTextContent('2');
});
it('decrements the count', () => {
renderWithProvider(<Counter />);
fireEvent.click(screen.getByText('Decrement'));
expect(screen.getByTestId('count')).toHaveTextContent('-1');
});
});
Testing Components with Initial Atom Values
Sometimes you need to seed atoms with specific values before rendering. Jotai's Provider accepts an initialValues prop for this purpose:
// src/components/UserProfile.test.tsx
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { Provider, atom } from 'jotai';
import { UserProfile } from './UserProfile';
const userAtom = atom<{ name: string } | null>(null);
describe('UserProfile', () => {
it('displays the seeded user name', () => {
render(
<Provider initialValues={[[userAtom, { name: 'Alice' }]]}>
<UserProfile />
</Provider>
);
expect(screen.getByText('Welcome, Alice')).toBeInTheDocument();
});
it('shows a fallback when no user is set', () => {
render(
<Provider>
<UserProfile />
</Provider>
);
expect(screen.getByText('Please log in')).toBeInTheDocument();
});
});
Testing with a Custom Store
For more control, you can create a store with createStore and pass it to the Provider. This is useful when you want to assert on atom values after user interactions:
// src/components/TodoList.test.tsx
import { describe, it, expect } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { Provider, createStore } from 'jotai';
import { TodoList } from './TodoList';
import { todosAtom } from '../atoms/todos';
describe('TodoList', () => {
it('adds a todo when the form is submitted', () => {
const store = createStore();
render(
<Provider store={store}>
<TodoList />
</Provider>
);
fireEvent.change(screen.getByPlaceholderText('New todo'), {
target: { value: 'Write tests' },
});
fireEvent.click(screen.getByText('Add'));
expect(screen.getByText('Write tests')).toBeInTheDocument();
expect(store.get(todosAtom)).toHaveLength(1);
});
});
Testing Error Boundaries and Suspense
Async atoms work with React Suspense and error boundaries. To test loading and error states, wrap your component in Suspense and an error boundary, then control the async behavior with mocks:
// src/components/UserView.test.tsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { Provider } from 'jotai';
import { Suspense } from 'react';
import { UserView } from './UserView';
class ErrorBoundary extends React.Component {
state = { hasError: false };
static getDerivedStateFromError() {
return { hasError: true };
}
render() {
if (this.state.hasError) return <p>Something went wrong</p>;
return this.props.children;
}
}
describe('UserView', () => {
it('shows loading state then user data', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ name: 'Alice' }),
}));
render(
<Provider>
<Suspense fallback={<p>Loading...</p>}>
<UserView />
</Suspense>
</Provider>
);
expect(screen.getByText('Loading...')).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText('Alice')).toBeInTheDocument();
});
});
it('shows error message on fetch failure', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }));
render(
<Provider>
<ErrorBoundary>
<Suspense fallback={<p>Loading...</p>}>
<UserView />
</Suspense>
</ErrorBoundary>
</Provider>
);
await waitFor(() => {
expect(screen.getByText('Something went wrong')).toBeInTheDocument();
});
});
});
End-to-End Testing with Playwright
End-to-end tests verify full user flows through the real application. They do not mock atoms or stores; instead, they interact with the rendered UI just like a real user. Set up Playwright with a config file:
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
use: {
baseURL: 'http://localhost:5173',
headless: true,
},
webServer: {
command: 'npm run dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
},
});
Write an E2E test for the counter application:
// e2e/counter.spec.ts
import { test, expect } from '@playwright/test';
test('counter increments and decrements', async ({ page }) => {
await page.goto('/');
await expect(page.getByTestId('count')).toHaveText('0');
await expect(page.getByTestId('double')).toHaveText('0');
await page.getByText('Increment').click();
await page.getByText('Increment').click();
await page.getByText('Increment').click();
await expect(page.getByTestId('count')).toHaveText('3');
await expect(page.getByTestId('double')).toHaveText('6');
await page.getByText('Decrement').click();
await expect(page.getByTestId('count')).toHaveText('2');
await expect(page.getByTestId('double')).toHaveText('4');
});
For the todo application, test the full flow of adding and toggling todos:
// e2e/todos.spec.ts
import { test, expect } from '@playwright/test';
test('user can add and complete todos', async ({ page }) => {
await page.goto('/todos');
await page.getByPlaceholder('New todo').fill('Buy milk');
await page.getByText('Add').click();
await page.getByPlaceholder('New todo').fill('Walk dog');
await page.getByText('Add').click();
await expect(page.getByText('Buy milk')).toBeVisible();
await expect(page.getByText('Walk dog')).toBeVisible();
await page.getByText('Buy milk').click();
await expect(page.getByText('Buy milk')).toHaveClass(/completed/);
});
E2E tests are slower than unit tests, so focus them on critical user journeys rather than every edge case.
Best Practices for Testing Jotai
- Always use a fresh Provider per test — never share a store across tests, or state will leak and cause flaky results.
- Test atoms in isolation first — use
createStorefor fast, React-free unit tests of atom logic. - Test derived atoms thoroughly — they are where subtle bugs hide. Verify behavior when base atoms change.
- Mock async dependencies at the boundary — mock
fetchor API modules, not Jotai itself. - Use
initialValuesfor targeted component tests — this avoids setting up complex chains of atoms just to render a component. - Keep E2E tests focused on user flows — do not assert on internal atom state in E2E tests; interact only with the UI.
- Test error and loading states explicitly — async atoms introduce these states, and users will encounter them.
- Co-locate atom tests with atom definitions — keeping
counter.tsandcounter.test.tstogether makes maintenance easier. - Avoid testing implementation details — prefer querying by role, label, or test ID rather than internal atom names in component tests.
Conclusion
Testing Jotai components effectively requires a layered approach. Unit tests with createStore give you fast, focused verification of atom logic, including derived and async atoms. Integration tests with React Testing Library and a fresh Provider confirm that components read and write state correctly. End-to-end tests with Playwright validate that the entire application behaves as expected from the user's perspective. By applying these techniques and following the best practices outlined above, you can build confidence in your Jotai-powered applications and catch bugs early across every layer of your state management architecture.