Testing MobX Stores: Observables, Computed Values, Reactions, and Actions with Jest
MobX uses observable state, computed values, and reactions to build reactive systems. Testing MobX is straightforward because stores are plain classes with transparent state — no providers, no reducers, no dispatch. You call methods, read properties, and assert.
Setup
npm install mobx mobx-react-lite
npm install -D jest @testing-library/react @testing-library/user-event @testing-library/jest-dom babel-jest @babel/preset-env @babel/preset-react @babel/preset-typescriptFor decorators (MobX class-based stores):
// tsconfig.json
{
"compilerOptions": {
"experimentalDecorators": true,
"useDefineForClassFields": true
}
}Testing Observables and Actions
MobX stores are classes. Create a new instance per test:
// stores/ProductStore.ts
import { makeAutoObservable, runInAction } from 'mobx'
interface Product {
id: string
name: string
price: number
inStock: boolean
}
export class ProductStore {
products: Product[] = []
isLoading = false
error: string | null = null
constructor() {
makeAutoObservable(this)
}
get inStockProducts() {
return this.products.filter((p) => p.inStock)
}
get totalValue() {
return this.products.reduce((sum, p) => sum + p.price, 0)
}
addProduct(product: Product) {
this.products.push(product)
}
removeProduct(id: string) {
this.products = this.products.filter((p) => p.id !== id)
}
async fetchProducts() {
this.isLoading = true
this.error = null
try {
const res = await fetch('/api/products')
const data: Product[] = await res.json()
runInAction(() => {
this.products = data
this.isLoading = false
})
} catch (e) {
runInAction(() => {
this.error = 'Failed to load products'
this.isLoading = false
})
}
}
}// stores/ProductStore.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { ProductStore } from './ProductStore'
const headphones = { id: 'p1', name: 'Headphones', price: 79.99, inStock: true }
const hub = { id: 'p2', name: 'USB-C Hub', price: 39.99, inStock: false }
describe('ProductStore', () => {
let store: ProductStore
beforeEach(() => {
store = new ProductStore() // Fresh instance per test
global.fetch = vi.fn()
})
it('starts empty', () => {
expect(store.products).toHaveLength(0)
expect(store.isLoading).toBe(false)
expect(store.error).toBeNull()
})
it('adds a product', () => {
store.addProduct(headphones)
expect(store.products).toHaveLength(1)
expect(store.products[0].name).toBe('Headphones')
})
it('removes a product by id', () => {
store.addProduct(headphones)
store.addProduct(hub)
store.removeProduct('p1')
expect(store.products).toHaveLength(1)
expect(store.products[0].id).toBe('p2')
})
})Testing Computed Values
Computed values are derived from observables and cached until dependencies change:
describe('ProductStore computed values', () => {
let store: ProductStore
beforeEach(() => {
store = new ProductStore()
})
it('inStockProducts filters out out-of-stock items', () => {
store.addProduct(headphones) // inStock: true
store.addProduct(hub) // inStock: false
expect(store.inStockProducts).toHaveLength(1)
expect(store.inStockProducts[0].id).toBe('p1')
})
it('inStockProducts updates when stock changes', () => {
store.addProduct(headphones)
store.addProduct(hub)
expect(store.inStockProducts).toHaveLength(1)
// Update stock status
store.products[1].inStock = true
expect(store.inStockProducts).toHaveLength(2)
})
it('totalValue sums all product prices', () => {
store.addProduct(headphones) // 79.99
store.addProduct(hub) // 39.99
expect(store.totalValue).toBeCloseTo(119.98, 2)
})
it('totalValue is 0 for empty store', () => {
expect(store.totalValue).toBe(0)
})
})Testing Reactions
MobX reactions run automatically when observables change. Test them by setting up the reaction, triggering changes, and verifying the reaction ran:
import { reaction } from 'mobx'
import { vi } from 'vitest'
describe('ProductStore reactions', () => {
let store: ProductStore
beforeEach(() => {
store = new ProductStore()
})
it('calls reaction when products change', () => {
const handler = vi.fn()
const disposer = reaction(
() => store.products.length,
handler
)
store.addProduct(headphones)
expect(handler).toHaveBeenCalledWith(1, 0, expect.anything())
disposer() // Always dispose
})
it('calls reaction when in-stock count changes', () => {
store.addProduct(headphones)
store.addProduct(hub)
const handler = vi.fn()
const disposer = reaction(
() => store.inStockProducts.length,
handler
)
// Make hub in-stock
store.products[1].inStock = true
expect(handler).toHaveBeenCalledWith(2, 1, expect.anything())
disposer()
})
it('disposes reaction correctly (does not fire after disposal)', () => {
const handler = vi.fn()
const disposer = reaction(
() => store.products.length,
handler
)
disposer()
store.addProduct(headphones)
expect(handler).not.toHaveBeenCalled()
})
})Testing Async Actions with runInAction
MobX requires state changes inside runInAction for async operations. Verify that the state updates happen:
describe('ProductStore async fetchProducts', () => {
let store: ProductStore
beforeEach(() => {
store = new ProductStore()
global.fetch = vi.fn()
})
it('loads products and sets isLoading correctly', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: true,
json: async () => [headphones, hub],
} as Response)
const fetchPromise = store.fetchProducts()
// During fetch
expect(store.isLoading).toBe(true)
await fetchPromise
// After fetch
expect(store.isLoading).toBe(false)
expect(store.products).toHaveLength(2)
expect(store.products[0].name).toBe('Headphones')
})
it('sets error state on fetch failure', async () => {
vi.mocked(fetch).mockRejectedValue(new Error('Network error'))
await store.fetchProducts()
expect(store.isLoading).toBe(false)
expect(store.error).toBe('Failed to load products')
expect(store.products).toHaveLength(0)
})
})React Component Integration
Test MobX store integration with observer components:
// components/ProductList.tsx
import { observer } from 'mobx-react-lite'
import { ProductStore } from '@/stores/ProductStore'
interface Props {
store: ProductStore
}
export const ProductList = observer(({ store }: Props) => {
if (store.isLoading) return <p>Loading products…</p>
if (store.error) return <p role="alert">{store.error}</p>
return (
<ul>
{store.inStockProducts.map((product) => (
<li key={product.id}>
{product.name} — ${product.price}
<button onClick={() => store.removeProduct(product.id)}>
Remove
</button>
</li>
))}
</ul>
)
})// components/ProductList.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, it, expect } from 'vitest'
import { ProductStore } from '@/stores/ProductStore'
import { ProductList } from './ProductList'
describe('ProductList', () => {
it('shows loading state', () => {
const store = new ProductStore()
store.isLoading = true
render(<ProductList store={store} />)
expect(screen.getByText('Loading products…')).toBeInTheDocument()
})
it('shows error state', () => {
const store = new ProductStore()
store.error = 'Failed to load'
render(<ProductList store={store} />)
expect(screen.getByRole('alert')).toHaveTextContent('Failed to load')
})
it('renders in-stock products only', () => {
const store = new ProductStore()
store.addProduct(headphones) // inStock: true
store.addProduct(hub) // inStock: false
render(<ProductList store={store} />)
expect(screen.getByText(/Headphones/)).toBeInTheDocument()
expect(screen.queryByText(/USB-C Hub/)).not.toBeInTheDocument()
})
it('removes product from list when Remove is clicked', async () => {
const store = new ProductStore()
store.addProduct(headphones)
render(<ProductList store={store} />)
await userEvent.click(screen.getByRole('button', { name: 'Remove' }))
expect(screen.queryByText(/Headphones/)).not.toBeInTheDocument()
expect(store.products).toHaveLength(0)
})
it('updates when store changes', () => {
const store = new ProductStore()
render(<ProductList store={store} />)
expect(screen.queryAllByRole('listitem')).toHaveLength(0)
// MobX reactivity updates the component
store.addProduct(headphones)
expect(screen.getByText(/Headphones/)).toBeInTheDocument()
})
})Using a MobX Root Store
In larger apps, stores depend on each other. Test them via a root store:
// stores/RootStore.ts
import { ProductStore } from './ProductStore'
import { CartStore } from './CartStore'
export class RootStore {
products: ProductStore
cart: CartStore
constructor() {
this.products = new ProductStore()
this.cart = new CartStore(this)
}
}
// Testing with root store
describe('Cart with product store dependency', () => {
it('cannot add out-of-stock products', () => {
const root = new RootStore()
root.products.addProduct({ id: 'p1', name: 'A', price: 10, inStock: false })
root.cart.addItem('p1')
expect(root.cart.items).toHaveLength(0)
expect(root.cart.error).toBe('Product is out of stock')
})
})What Automated Tests Miss
MobX store tests cover reactive logic but not:
- Memory leaks from undisposed reactions — in component tests, reactions need cleanup
- MobX
strict modeviolations — mutations outside actions in strict mode - Cross-store cascades — computed values across many interdependent stores under load
- Rendering performance — number of re-renders when observables change
HelpMeTest runs full browser tests on schedule, catching MobX-driven UI regressions that store tests miss.
Summary
MobX testing:
- Stores → create a fresh class instance per test; no factory functions needed
- Actions → call the action, assert observable state changed
- Computed → change dependencies, assert computed value updated
- Reactions → set up with
reaction(), assert handler called, alwaysdispose() - Async →
awaitthe action, assert both intermediate and final state - Components → pass store as prop to
observercomponent; change store state directly, assert re-render