Testing MobX Stores: Observables, Computed Values, Reactions, and Actions with Jest

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-typescript

For 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 mode violations — 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, always dispose()
  • Asyncawait the action, assert both intermediate and final state
  • Components → pass store as prop to observer component; change store state directly, assert re-render

Read more