MobX testing strategies and tools are crucial for building reliable applications. Here's a comprehensive guide to MobX testing:
1. Testing Stores
Basic Testing
javascriptimport { UserStore } from './UserStore'; describe('UserStore', () => { let store; beforeEach(() => { store = new UserStore(); }); it('should initialize with default values', () => { expect(store.user).toBeNull(); expect(store.isAuthenticated).toBe(false); }); it('should login user', async () => { await store.login({ username: 'test', password: 'test' }); expect(store.user).not.toBeNull(); expect(store.isAuthenticated).toBe(true); }); it('should logout user', () => { store.user = { id: 1, name: 'Test' }; store.isAuthenticated = true; store.logout(); expect(store.user).toBeNull(); expect(store.isAuthenticated).toBe(false); }); });
Testing Computed Properties
javascriptdescribe('ProductStore', () => { let store; beforeEach(() => { store = new ProductStore(); }); it('should compute featured products', () => { store.products = [ { id: 1, name: 'Product 1', featured: true }, { id: 2, name: 'Product 2', featured: false }, { id: 3, name: 'Product 3', featured: true } ]; expect(store.featuredProducts).toHaveLength(2); expect(store.featuredProducts[0].name).toBe('Product 1'); expect(store.featuredProducts[1].name).toBe('Product 3'); }); it('should update when products change', () => { store.products = [{ id: 1, name: 'Product 1', featured: true }]; expect(store.featuredProducts).toHaveLength(1); store.products.push({ id: 2, name: 'Product 2', featured: true }); expect(store.featuredProducts).toHaveLength(2); }); });
Testing Async Actions
javascriptdescribe('AsyncStore', () => { let store; let mockApi; beforeEach(() => { mockApi = { fetchData: jest.fn().mockResolvedValue({ data: 'test' }) }; store = new AsyncStore(mockApi); }); it('should fetch data successfully', async () => { await store.fetchData(); expect(store.data).toEqual({ data: 'test' }); expect(store.loading).toBe(false); expect(mockApi.fetchData).toHaveBeenCalled(); }); it('should handle errors', async () => { mockApi.fetchData.mockRejectedValue(new Error('Network error')); await expect(store.fetchData()).rejects.toThrow('Network error'); expect(store.error).toBe('Network error'); expect(store.loading).toBe(false); }); });
2. Testing React Components
Testing Observer Components
javascriptimport { render, screen, fireEvent } from '@testing-library/react'; import { observer } from 'mobx-react-lite'; import { UserStore } from './UserStore'; const TestComponent = observer(({ store }) => ( <div> {store.isAuthenticated ? ( <div>Welcome, {store.user?.name}</div> ) : ( <div>Please login</div> )} <button onClick={store.login}>Login</button> </div> )); describe('TestComponent', () => { it('should show login message when not authenticated', () => { const store = new UserStore(); render(<TestComponent store={store} />); expect(screen.getByText('Please login')).toBeInTheDocument(); }); it('should show welcome message when authenticated', () => { const store = new UserStore(); store.user = { id: 1, name: 'Test' }; store.isAuthenticated = true; render(<TestComponent store={store} />); expect(screen.getByText('Welcome, Test')).toBeInTheDocument(); }); it('should update when state changes', () => { const store = new UserStore(); render(<TestComponent store={store} />); expect(screen.getByText('Please login')).toBeInTheDocument(); store.user = { id: 1, name: 'Test' }; store.isAuthenticated = true; expect(screen.getByText('Welcome, Test')).toBeInTheDocument(); }); });
Testing Form Components
javascriptdescribe('FormComponent', () => { it('should update form data', () => { const store = new FormStore(); render(<FormComponent store={store} />); const input = screen.getByLabelText('Name'); fireEvent.change(input, { target: { value: 'John' } }); expect(store.formData.name).toBe('John'); }); it('should submit form', async () => { const store = new FormStore(); store.submit = jest.fn(); render(<FormComponent store={store} />); const button = screen.getByText('Submit'); fireEvent.click(button); expect(store.submit).toHaveBeenCalled(); }); });
3. Using MobX Testing Tools
Using spy
javascriptimport { spy } from 'mobx'; describe('Spy Usage', () => { it('should spy on observable changes', () => { const store = observable({ count: 0 }); const countSpy = jest.fn(); spy(store, 'count', (change) => { countSpy(change); }); store.count = 1; store.count = 2; expect(countSpy).toHaveBeenCalledTimes(2); }); });
Using trace
javascriptimport { trace } from 'mobx'; describe('Trace Usage', () => { it('should trace computed dependencies', () => { const store = observable({ firstName: 'John', lastName: 'Doe' }); const fullName = computed(() => `${store.firstName} ${store.lastName}`); // Trace dependencies trace(fullName); expect(fullName.get()).toBe('John Doe'); }); });
Using isObservable
javascriptimport { isObservable } from 'mobx'; describe('IsObservable Usage', () => { it('should check if object is observable', () => { const observableObj = observable({ count: 0 }); const plainObj = { count: 0 }; expect(isObservable(observableObj)).toBe(true); expect(isObservable(plainObj)).toBe(false); }); });
4. Mocking API Calls
Using Jest Mock
javascriptimport { UserStore } from './UserStore'; describe('UserStore with API', () => { let store; let mockApi; beforeEach(() => { mockApi = { login: jest.fn(), logout: jest.fn(), fetchUser: jest.fn() }; store = new UserStore(mockApi); }); it('should call API on login', async () => { mockApi.login.mockResolvedValue({ id: 1, name: 'Test' }); await store.login({ username: 'test', password: 'test' }); expect(mockApi.login).toHaveBeenCalledWith({ username: 'test', password: 'test' }); }); it('should handle API errors', async () => { mockApi.login.mockRejectedValue(new Error('Invalid credentials')); await expect(store.login({ username: 'test', password: 'test' })) .rejects.toThrow('Invalid credentials'); expect(store.error).toBe('Invalid credentials'); }); });
Using MSW (Mock Service Worker)
javascriptimport { setupServer, rest } from 'msw'; import { UserStore } from './UserStore'; const server = setupServer( rest.post('/api/login', (req, res, ctx) => { return res( ctx.status(200), ctx.json({ id: 1, name: 'Test' }) ); }) ); describe('UserStore with MSW', () => { let store; beforeAll(() => server.listen()); afterEach(() => server.resetHandlers()); afterAll(() => server.close()); beforeEach(() => { store = new UserStore(); }); it('should login successfully', async () => { await store.login({ username: 'test', password: 'test' }); expect(store.user).toEqual({ id: 1, name: 'Test' }); expect(store.isAuthenticated).toBe(true); }); });
5. Testing Reactions
javascriptdescribe('Reaction Testing', () => { it('should trigger reaction when observable changes', () => { const store = observable({ count: 0 }); const reactionSpy = jest.fn(); reaction( () => store.count, (count) => { reactionSpy(count); } ); store.count = 1; expect(reactionSpy).toHaveBeenCalledWith(1); store.count = 2; expect(reactionSpy).toHaveBeenCalledWith(2); }); it('should not trigger when value is same', () => { const store = observable({ count: 0 }); const reactionSpy = jest.fn(); reaction( () => store.count, (count) => { reactionSpy(count); } ); store.count = 0; expect(reactionSpy).not.toHaveBeenCalled(); }); });
6. Testing Middleware
javascriptdescribe('Middleware Testing', () => { it('should call middleware before action', () => { const middlewareSpy = jest.fn(); const actionSpy = jest.fn(); const store = { @observable count: 0, @action increment() { this.count++; } }; const originalIncrement = store.increment; store.increment = function(...args) { middlewareSpy(...args); return originalIncrement.apply(this, args); }; store.increment(); expect(middlewareSpy).toHaveBeenCalled(); expect(actionSpy).toHaveBeenCalled(); }); });
7. Integration Testing
javascriptdescribe('Integration Tests', () => { it('should handle complete user flow', async () => { const store = new RootStore(); // Login await store.userStore.login({ username: 'test', password: 'test' }); expect(store.userStore.isAuthenticated).toBe(true); // Load data await store.dataStore.loadData(); expect(store.dataStore.data).not.toBeNull(); // Add to cart store.cartStore.addItem(store.dataStore.data[0]); expect(store.cartStore.items).toHaveLength(1); // Checkout await store.cartStore.checkout(); expect(store.cartStore.items).toHaveLength(0); }); });
8. Testing Best Practices
1. Isolate Tests
javascript// Each test should be independent beforeEach(() => { store = new Store(); }); // Clean up side effects afterEach(() => { if (store.dispose) { store.dispose(); } });
2. Use Snapshot Testing
javascriptit('should match snapshot', () => { const store = new Store(); store.data = { id: 1, name: 'Test' }; expect(toJS(store.data)).toMatchSnapshot(); });
3. Test Edge Cases
javascriptit('should handle empty array', () => { const store = new Store(); store.items = []; expect(store.itemCount).toBe(0); }); it('should handle null values', () => { const store = new Store(); store.user = null; expect(store.userName).toBe('Guest'); });
4. Test Error Handling
javascriptit('should handle network errors gracefully', async () => { const store = new Store(); mockApi.fetchData.mockRejectedValue(new Error('Network error')); await expect(store.fetchData()).rejects.toThrow('Network error'); expect(store.error).toBe('Network error'); expect(store.loading).toBe(false); });
Summary
Key points for MobX testing:
- Test Stores: Verify behavior of observables, computed, and actions
- Test Components: Verify responsiveness of observer components
- Use Testing Tools: spy, trace, isObservable
- Mock APIs: Use Jest mock or MSW
- Test Reactions: Verify side effects trigger correctly
- Test Middleware: Verify middleware executes correctly
- Integration Tests: Verify interactions between multiple stores
- Best Practices: Isolate tests, snapshot testing, edge cases, error handling
Following these testing strategies can build reliable, maintainable MobX applications.