Testing MobX Components: From Unit to E2E Tests
MobX is a powerful state management library that uses observable state, derived values, and reactions to keep your application in sync. While MobX reduces boilerplate dramatically compared to other state solutions, testing components that rely on it requires a clear strategy. This tutorial walks you through testing MobX-powered applications at every level — from pure store unit tests to end-to-end user flows.
Why Testing MobX Matters
MobX's reactive programming model introduces implicit dependencies between observables and observers. When something breaks, the failure can surface far from its source. A robust test suite helps you:
- Verify that observable state mutations produce correct computed values
- Ensure actions enforce business rules and invariants
- Confirm that React components re-render correctly when stores change
- Validate full user journeys that span multiple stores and routes
1. Project Setup
Let's start with a typical React + MobX project. We'll use Jest and React Testing Library for unit and integration tests, and Playwright for end-to-end tests.
npm install mobx mobx-react-lite
npm install --save-dev jest @testing-library/react @testing-library/jest-dom
npm install --save-dev @playwright/test
Here is the store we'll be testing throughout this tutorial — a simple todo store:
// src/stores/TodoStore.js
import { makeAutoObservable } from 'mobx';
export class TodoStore {
todos = [];
filter = 'all';
constructor() {
makeAutoObservable(this);
}
addTodo(text) {
if (!text.trim()) return;
this.todos.push({
id: Date.now(),
text: text.trim(),
completed: false,
});
}
toggleTodo(id) {
const todo = this.todos.find((t) => t.id === id);
if (todo) todo.completed = !todo.completed;
}
removeTodo(id) {
this.todos = this.todos.filter((t) => t.id !== id);
}
setFilter(filter) {
this.filter = filter;
}
get filteredTodos() {
switch (this.filter) {
case 'active':
return this.todos.filter((t) => !t.completed);
case 'completed':
return this.todos.filter((t) => t.completed);
default:
return this.todos;
}
}
get remainingCount() {
return this.todos.filter((t) => !t.completed).length;
}
}
2. Unit Testing MobX Stores
Stores are the heart of a MobX application. Because they are plain JavaScript classes, they are straightforward to unit test in isolation — no React rendering required. The key is to wrap asynchronous or reaction-based assertions in when or use runInAction when needed.
// src/stores/__tests__/TodoStore.test.js
import { TodoStore } from '../TodoStore';
describe('TodoStore', () => {
let store;
beforeEach(() => {
store = new TodoStore();
});
describe('addTodo', () => {
it('adds a new todo to the list', () => {
store.addTodo('Buy milk');
expect(store.todos).toHaveLength(1);
expect(store.todos[0].text).toBe('Buy milk');
expect(store.todos[0].completed).toBe(false);
});
it('ignores empty or whitespace-only text', () => {
store.addTodo(' ');
store.addTodo('');
expect(store.todos).toHaveLength(0);
});
});
describe('toggleTodo', () => {
it('flips the completed flag', () => {
store.addTodo('Write tests');
const id = store.todos[0].id;
store.toggleTodo(id);
expect(store.todos[0].completed).toBe(true);
store.toggleTodo(id);
expect(store.todos[0].completed).toBe(false);
});
});
describe('computed values', () => {
it('filters todos based on the current filter', () => {
store.addTodo('Task A');
store.addTodo('Task B');
store.toggleTodo(store.todos[0].id);
store.setFilter('active');
expect(store.filteredTodos).toHaveLength(1);
expect(store.filteredTodos[0].text).toBe('Task B');
store.setFilter('completed');
expect(store.filteredTodos).toHaveLength(1);
expect(store.filteredTodos[0].text).toBe('Task A');
store.setFilter('all');
expect(store.filteredTodos).toHaveLength(2);
});
it('counts remaining todos correctly', () => {
store.addTodo('Task A');
store.addTodo('Task B');
store.addTodo('Task C');
store.toggleTodo(store.todos[0].id);
expect(store.remainingCount).toBe(2);
});
});
});
Notice that we create a fresh store instance in beforeEach. This guarantees test isolation, which is critical because MobX observables are mutable shared state.
3. Testing Asynchronous Actions
Real applications fetch data from APIs. MobX handles async actions cleanly with runInAction or flow. Here's an extended store with async behavior:
// src/stores/UserStore.js
import { makeAutoObservable, runInAction } from 'mobx';
export class UserStore {
users = [];
loading = false;
error = null;
constructor(apiClient) {
this.apiClient = apiClient;
makeAutoObservable(this);
}
async fetchUsers() {
this.loading = true;
this.error = null;
try {
const users = await this.apiClient.get('/users');
runInAction(() => {
this.users = users;
this.loading = false;
});
} catch (err) {
runInAction(() => {
this.error = err.message;
this.loading = false;
});
}
}
}
To test async actions, inject a mock API client. Dependency injection keeps your stores testable and decoupled from network details.
// src/stores/__tests__/UserStore.test.js
import { UserStore } from '../UserStore';
describe('UserStore async', () => {
it('loads users on success', async () => {
const apiClient = {
get: jest.fn().mockResolvedValue([{ id: 1, name: 'Alice' }]),
};
const store = new UserStore(apiClient);
await store.fetchUsers();
expect(store.loading).toBe(false);
expect(store.error).toBeNull();
expect(store.users).toEqual([{ id: 1, name: 'Alice' }]);
});
it('sets error on failure', async () => {
const apiClient = {
get: jest.fn().mockRejectedValue(new Error('Network error')),
};
const store = new UserStore(apiClient);
await store.fetchUsers();
expect(store.loading).toBe(false);
expect(store.error).toBe('Network error');
expect(store.users).toHaveLength(0);
});
});
4. Integration Testing React Components
Once stores are tested in isolation, the next layer is verifying that React components react correctly to observable changes. With mobx-react-lite, the observer HOC subscribes components to observables. React Testing Library pairs naturally with this model.
// src/components/TodoList.jsx
import { observer } from 'mobx-react-lite';
import { useStore } from '../storeContext';
export const TodoList = observer(() => {
const { todoStore } = useStore();
return (
<div>
<ul data-testid="todo-list">
{todoStore.filteredTodos.map((todo) => (
<li key={todo.id} data-testid={`todo-${todo.id}`}>
<span style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
{todo.text}
</span>
<button onClick={() => todoStore.toggleTodo(todo.id)}>Toggle</button>
<button onClick={() => todoStore.removeTodo(todo.id)}>Delete</button>
</li>
))}
</ul>
<p data-testid="remaining">{todoStore.remainingCount} items left</p>
</div>
);
});
For component tests, provide a context wrapper that injects a real store instance. This avoids mocking MobX internals and tests the actual reactivity.
// src/components/__tests__/TodoList.test.jsx
import { render, screen, fireEvent } from '@testing-library/react';
import { TodoList } from '../TodoList';
import { StoreContext } from '../storeContext';
import { TodoStore } from '../../stores/TodoStore';
function renderWithStore(store) {
return render(
<StoreContext.Provider value={{ todoStore: store }}>
<TodoList />
</StoreContext.Provider>
);
}
describe('TodoList', () => {
it('renders todos from the store', () => {
const store = new TodoStore();
store.addTodo('Learn MobX');
store.addTodo('Write tests');
renderWithStore(store);
expect(screen.getByText('Learn MobX')).toBeInTheDocument();
expect(screen.getByText('Write tests')).toBeInTheDocument();
expect(screen.getByTestId('remaining')).toHaveTextContent('2 items left');
});
it('updates the UI when a todo is toggled', () => {
const store = new TodoStore();
store.addTodo('Learn MobX');
renderWithStore(store);
const toggleButton = screen.getByText('Toggle');
fireEvent.click(toggleButton);
expect(screen.getByTestId('remaining')).toHaveTextContent('0 items left');
});
it('removes a todo when delete is clicked', () => {
const store = new TodoStore();
store.addTodo('Learn MobX');
renderWithStore(store);
fireEvent.click(screen.getByText('Delete'));
expect(screen.queryByText('Learn MobX')).not.toBeInTheDocument();
});
it('respects the active filter', () => {
const store = new TodoStore();
store.addTodo('Active task');
store.addTodo('Done task');
store.toggleTodo(store.todos[1].id);
store.setFilter('active');
renderWithStore(store);
expect(screen.getByText('Active task')).toBeInTheDocument();
expect(screen.queryByText('Done task')).not.toBeInTheDocument();
});
});
5. Testing User Interactions with Forms
Forms often bind directly to observable state. Testing these interactions ensures two-way binding works as expected.
// src/components/AddTodoForm.jsx
import { useState } from 'react';
import { observer } from 'mobx-react-lite';
import { useStore } from '../storeContext';
export const AddTodoForm = observer(() => {
const { todoStore } = useStore();
const [text, setText] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
todoStore.addTodo(text);
setText('');
};
return (
<form onSubmit={handleSubmit} data-testid="add-todo-form">
<input
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="What needs to be done?"
data-testid="todo-input"
/>
<button type="submit" data-testid="add-button">Add</button>
</form>
);
});
// src/components/__tests__/AddTodoForm.test.jsx
import { render, screen, fireEvent } from '@testing-library/react';
import { AddTodoForm } from '../AddTodoForm';
import { StoreContext } from '../../storeContext';
import { TodoStore } from '../../stores/TodoStore';
function renderWithStore(store) {
return render(
<StoreContext.Provider value={{ todoStore: store }}>
<AddTodoForm />
</StoreContext.Provider>
);
}
describe('AddTodoForm', () => {
it('adds a todo and clears the input', () => {
const store = new TodoStore();
renderWithStore(store);
const input = screen.getByTestId('todo-input');
fireEvent.change(input, { target: { value: 'New task' } });
fireEvent.click(screen.getByTestId('add-button'));
expect(store.todos).toHaveLength(1);
expect(store.todos[0].text).toBe('New task');
expect(input.value).toBe('');
});
it('does not add empty todos', () => {
const store = new TodoStore();
renderWithStore(store);
fireEvent.click(screen.getByTestId('add-button'));
expect(store.todos).toHaveLength(0);
});
});
6. Testing MobX Reactions and Autorun
Beyond components, MobX reactions (autorun, reaction, when) are common in stores or services. These side effects need explicit testing and cleanup.
// src/stores/PersistenceStore.js
import { makeAutoObservable, reaction } from 'mobx';
export class PersistenceStore {
constructor(todoStore, storage = localStorage) {
this.storage = storage;
this.dispose = reaction(
() => todoStore.todos.map((t) => ({ ...t })),
(todos) => {
this.storage.setItem('todos', JSON.stringify(todos));
}
);
}
}
// src/stores/__tests__/PersistenceStore.test.js
import { TodoStore } from '../TodoStore';
import { PersistenceStore } from '../PersistenceStore';
describe('PersistenceStore', () => {
it('saves todos to storage whenever they change', () => {
const mockStorage = {
store: {},
getItem(key) { return this.store[key] || null; },
setItem(key, value) { this.store[key] = value; },
};
const todoStore = new TodoStore();
const persistence = new PersistenceStore(todoStore, mockStorage);
todoStore.addTodo('Persisted task');
const saved = JSON.parse(mockStorage.getItem('todos'));
expect(saved).toHaveLength(1);
expect(saved[0].text).toBe('Persisted task');
persistence.dispose();
});
});
Always call dispose() after the test to prevent memory leaks and cross-test interference. Reactions that are not disposed will continue firing in subsequent tests.
7. End-to-End Testing with Playwright
End-to-end tests validate the entire stack: browser, React rendering, MobX state, and any backend APIs. Playwright is an excellent choice because it runs real browsers and provides a clean API for user interactions.
First, create a Playwright config:
// playwright.config.js
const { defineConfig } = require('@playwright/test');
module.exports = defineConfig({
testDir: './e2e',
use: {
baseURL: 'http://localhost:3000',
headless: true,
screenshot: 'only-on-failure',
},
webServer: {
command: 'npm start',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
timeout: 30000,
},
});
Here is a full E2E test that exercises the todo application as a real user would:
// e2e/todo.spec.js
const { test, expect } = require('@playwright/test');
test.describe('Todo application', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('user can add and complete a todo', async ({ page }) => {
await page.fill('[data-testid="todo-input"]', 'Buy groceries');
await page.click('[data-testid="add-button"]');
await expect(page.locator('text=Buy groceries')).toBeVisible();
await expect(page.locator('[data-testid="remaining"]')).toContainText('1 items left');
await page.click('text=Toggle');
await expect(page.locator('[data-testid="remaining"]')).toContainText('0 items left');
});
test('user can filter todos', async ({ page }) => {
await page.fill('[data-testid="todo-input"]', 'Active task');
await page.click('[data-testid="add-button"]');
await page.fill('[data-testid="todo-input"]', 'Completed task');
await page.click('[data-testid="add-button"]');
await page.click('text=Toggle >> nth=1');
await page.click('text=Active');
await expect(page.locator('text=Active task')).toBeVisible();
await expect(page.locator('text=Completed task')).not.toBeVisible();
await page.click('text=Completed');
await expect(page.locator('text=Completed task')).toBeVisible();
await expect(page.locator('text=Active task')).not.toBeVisible();
});
test('user can delete a todo', async ({ page }) => {
await page.fill('[data-testid="todo-input"]', 'Temporary task');
await page.click('[data-testid="add-button"]');
await page.click('text=Delete');
await expect(page.locator('text=Temporary task')).not.toBeVisible();
});
test('todos persist across page reloads', async ({ page }) => {
await page.fill('[data-testid="todo-input"]', 'Persistent task');
await page.click('[data-testid="add-button"]');
await page.reload();
await expect(page.locator('text=Persistent task')).toBeVisible();
});
});
E2E tests should focus on user-visible behavior, not internal MobX state. They give you confidence that the entire reactive pipeline works together in a real browser environment.
8. Best Practices
Keep Stores Testable
- Use dependency injection for external services like API clients and storage
- Keep actions pure where possible — avoid mixing side effects with state mutations
- Prefer
makeAutoObservablefor simplicity, but switch tomakeObservablewhen you need explicit control over which members are observable
Test Isolation
- Always create fresh store instances in
beforeEachhooks - Dispose of reactions and autoruns after each test
- Reset mocks between tests to prevent state leakage
Test at the Right Level
- Unit tests for stores cover business logic quickly and reliably
- Integration tests for components verify reactivity and rendering
- E2E tests cover critical user journeys — keep them focused and few
- Avoid testing MobX internals; test the observable outcomes your users care about
Avoid Over-Mocking
Resist the temptation to mock MobX itself. Use real store instances in component tests whenever possible. Mocking observables breaks the reactivity that makes MobX valuable, and you end up testing your mocks instead of your application.
Use act() When Needed
React Testing Library wraps event firing in act() automatically. However, if you mutate observables directly outside of React event handlers in a test, wrap the mutation in act() to ensure React flushes updates synchronously.
import { act } from '@testing-library/react';
it('reflects external store mutations', () => {
const store = new TodoStore();
renderWithStore(store);
act(() => {
store.addTodo('Externally added');
});
expect(screen.getByText('Externally added')).toBeInTheDocument();
});
Conclusion
Testing MobX components is a layered effort that pays off with confidence and maintainability. By unit testing stores for business logic, integration testing components for reactivity, and end-to-end testing for real user flows, you build a safety net that catches regressions at every level. The key principles are isolation, dependency injection, and testing observable outcomes rather than implementation details. With these patterns in place, your MobX applications will remain robust as they grow, and your team will ship features faster knowing that the reactive pipeline is fully covered by automated tests.