Chapter 19: Testing MVVM Applications
"The best architecture is the one you can test."
We've built framework-agnostic ViewModels, reactive Models, and clean View layers across React, Vue, Angular, Lit, and Vanilla JavaScript. Now comes the payoff: testing becomes dramatically simpler when your architecture enforces separation of concerns.
This chapter shows you how to test MVVM applications using real examples from the Web Loom monorepo. We'll extract actual test files from packages/mvvm-core/, demonstrate unit testing ViewModels in isolation, show integration testing across layers, and prove that MVVM's separation of concerns makes testing faster, more reliable, and more maintainable.
19.1 Why MVVM Makes Testing Easier
Before diving into code, let's understand why MVVM architecture improves testability.
The Problem with Tightly Coupled Code
In traditional frontend applications, business logic lives inside components:
// ❌ Tightly coupled: business logic mixed with UI
function SensorDashboard() {
const [sensors, setSensors] = useState<Sensor[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setLoading(true);
fetch('/api/sensors')
.then(res => res.json())
.then(data => {
setSensors(data);
setLoading(false);
})
.catch(err => {
setError(err.message);
setLoading(false);
});
}, []);
const handleCalibrate = (sensorId: string) => {
fetch(`/api/sensors/${sensorId}/calibrate`, { method: 'POST' })
.then(() => alert('Calibrated!'))
.catch(err => alert(err.message));
};
if (loading) return <Spinner />;
if (error) return <ErrorDisplay message={error} />;
return (
<div>
{sensors.map(sensor => (
<SensorCard
key={sensor.id}
sensor={sensor}
onCalibrate={() => handleCalibrate(sensor.id)}
/>
))}
</div>
);
}How do you test this?
- You need a DOM: Can't test business logic without rendering React components
- You need to mock fetch: Every test requires mocking network calls
- You can't test in isolation: Business logic is tangled with UI rendering
- Tests are slow: Rendering components and mocking DOM APIs takes time
- Tests are brittle: Changes to UI break business logic tests
The MVVM Solution
With MVVM, business logic lives in framework-agnostic ViewModels:
// ✅ Separated: business logic in ViewModel
export class SensorViewModel extends BaseViewModel<SensorModel> {
readonly sensors$ = this.model.data$;
readonly isLoading$ = this.model.isLoading$;
readonly error$ = this.model.error$;
constructor(model: SensorModel) {
super(model);
}
async loadSensors(): Promise<void> {
await this.model.fetch();
}
async calibrateSensor(sensorId: string): Promise<void> {
await this.model.calibrate(sensorId);
}
}
// View is now "dumb" - just renders ViewModel state
function SensorDashboard() {
const viewModel = useViewModel(SensorViewModel);
const sensors = useSignal(viewModel.sensors$);
const loading = useSignal(viewModel.isLoading$);
const error = useSignal(viewModel.error$);
if (loading) return <Spinner />;
if (error) return <ErrorDisplay message={error} />;
return (
<div>
{sensors?.map(sensor => (
<SensorCard
key={sensor.id}
sensor={sensor}
onCalibrate={() => viewModel.calibrateSensor(sensor.id)}
/>
))}
</div>
);
}Now testing is straightforward:
- No DOM needed: Test ViewModels as plain TypeScript classes
- Mock at the Model layer: Inject mock Models into ViewModels
- Test in isolation: ViewModels have no UI dependencies
- Tests are fast: No rendering, no DOM, just pure logic
- Tests are stable: UI changes don't break ViewModel tests
Let's see this in practice with real tests from the Web Loom monorepo.
19.2 Testing Models with Zod Validation
Models contain domain logic and data validation. Let's test BaseModel from packages/mvvm-core/src/models/BaseModel.ts.
BaseModel Test Structure
Here's the actual test file from the monorepo (packages/mvvm-core/src/models/BaseModel.test.ts):
// packages/mvvm-core/src/models/BaseModel.test.ts
import { describe, it, beforeEach, expect, vi } from 'vitest';
import { observe } from '@web-loom/signals-core';
import { BaseModel } from './BaseModel';
import { z } from 'zod';
describe('BaseModel', () => {
// Define a simple Zod schema for testing
const TestSchema = z.object({
id: z.string(),
name: z.string(),
age: z.number().min(0),
});
type TestDataType = z.infer<typeof TestSchema>;
let model: BaseModel<TestDataType, typeof TestSchema>;
beforeEach(() => {
model = new BaseModel<TestDataType, typeof TestSchema>({
initialData: null,
schema: TestSchema,
});
});
it('should initialize with null data, not loading, and no error', () => {
expect(model.data$.get()).toBeNull();
expect(model.isLoading$.get()).toBe(false);
expect(model.error$.get()).toBeNull();
});
it('should set initial data correctly', () => {
const initialData = { id: '1', name: 'Initial', age: 30 };
const newModel = new BaseModel<TestDataType, typeof TestSchema>({
initialData,
schema: TestSchema,
});
expect(newModel.data$.get()).toEqual(initialData);
});
it('should update data using setData', () => {
const newData = { id: '2', name: 'Updated', age: 25 };
model.setData(newData);
expect(model.data$.get()).toEqual(newData);
});
it('should validate data successfully using the provided schema', () => {
const validData = { id: 'abc', name: 'Test User', age: 42 };
expect(model.validate(validData)).toEqual(validData);
});
it('should throw ZodError for invalid data when schema is provided', () => {
const invalidData = { id: 'def', name: 'Another User', age: -5 }; // Invalid age
expect(() => model.validate(invalidData)).toThrow(z.ZodError);
});
it('should emit changes to data$ when setData is called multiple times', () => {
const emittedData: (TestDataType | null)[] = [];
// observe() delivers the current value immediately, then every future
// change — the signals-core equivalent of a BehaviorSubject subscription.
observe(model.data$, (data) => emittedData.push(data));
model.setData({ id: 'a', name: 'A', age: 1 });
model.setData({ id: 'b', name: 'B', age: 2 });
model.setData(null);
expect(emittedData).toEqual([
null, // Initial state, delivered immediately by observe()
{ id: 'a', name: 'A', age: 1 },
{ id: 'b', name: 'B', age: 2 },
null,
]);
});
});Key testing patterns:
- Test reactive state: Read signals synchronously with
.get()—no async wrapper needed - Test validation: Verify Zod schema validation works correctly
- Test state transitions: Verify
setData,setLoading,setErrorupdate the underlying signals - Test edge cases: Invalid data, null data, schema violations
Testing Model Disposal
Models must stop notifying subscribers when disposed. Here's the disposal test:
describe('dispose', () => {
it('should prevent further emissions after dispose', () => {
const dataSpy = vi.fn();
const isLoadingSpy = vi.fn();
const errorSpy = vi.fn();
model.data$.subscribe(dataSpy);
model.isLoading$.subscribe(isLoadingSpy);
model.error$.subscribe(errorSpy);
// Call dispose
model.dispose();
// Attempt to emit new values — setters no-op after dispose
model.setData({ id: '3', name: 'Disposed', age: 50 });
model.setLoading(true);
model.setError(new Error('Disposed error'));
// Verify that no new values were emitted
expect(dataSpy).not.toHaveBeenCalled();
expect(isLoadingSpy).not.toHaveBeenCalled();
expect(errorSpy).not.toHaveBeenCalled();
// Current values remain readable and unchanged
expect(model.data$.get()).toBeNull();
expect(model.isLoading$.get()).toBe(false);
expect(model.error$.get()).toBeNull();
});
});This test verifies:
- Setters become no-ops once
dispose()is called, so subscribers get no further notifications - Signal values remain synchronously readable (via
.get()) even after disposal - No special "completion" event is needed—signals don't have a completed state, they just stop changing
19.3 Testing ViewModels in Isolation
ViewModels connect Models to Views. Testing them in isolation proves they correctly expose Model state and handle user actions.
BaseViewModel Test Structure
Here's the actual test from packages/mvvm-core/src/viewmodels/BaseViewModel.test.ts:
// packages/mvvm-core/src/viewmodels/BaseViewModel.test.ts
import { describe, it, beforeEach, expect, afterEach } from 'vitest';
import { signal } from '@web-loom/signals-core';
import { BaseViewModel } from './BaseViewModel';
import { BaseModel } from '../models/BaseModel';
import { z, ZodError } from 'zod';
// Define a test model and schema
const TestSchema = z.object({
id: z.string(),
name: z.string(),
});
type TestDataType = z.infer<typeof TestSchema>;
class MockBaseModel extends BaseModel<TestDataType, typeof TestSchema> {
constructor(initialData: TestDataType | null = null) {
super({
initialData,
schema: TestSchema,
});
}
}
describe('BaseViewModel', () => {
let mockModel: MockBaseModel;
let viewModel: BaseViewModel<MockBaseModel>;
beforeEach(() => {
mockModel = new MockBaseModel();
viewModel = new BaseViewModel(mockModel);
});
afterEach(() => {
viewModel.dispose(); // Ensure dispose is called after each test
});
it('should initialize with null data, not loading, and no error from model', () => {
expect(viewModel.data$.get()).toBeNull();
expect(viewModel.isLoading$.get()).toBe(false);
expect(viewModel.error$.get()).toBeNull();
expect(viewModel.validationErrors$.get()).toBeNull();
});
it('should expose data$ from the model', () => {
const testData = { id: '1', name: 'Test' };
mockModel.setData(testData);
expect(viewModel.data$.get()).toEqual(testData);
});
it('should expose isLoading$ from the model', () => {
mockModel.setLoading(true);
expect(viewModel.isLoading$.get()).toBe(true);
mockModel.setLoading(false);
expect(viewModel.isLoading$.get()).toBe(false);
});
it('should derive validationErrors$ from model error$ if it is a ZodError', () => {
const nonZodError = new Error('Generic error');
const zodError = new ZodError([]);
// Initially null
expect(viewModel.validationErrors$.get()).toBeNull();
// Set generic error, validationErrors$ should remain null
mockModel.setError(nonZodError);
expect(viewModel.validationErrors$.get()).toBeNull();
// Set ZodError, validationErrors$ should update
mockModel.setError(zodError);
expect(viewModel.validationErrors$.get()).toBe(zodError);
// Clear error, validationErrors$ should become null again
mockModel.clearError();
expect(viewModel.validationErrors$.get()).toBeNull();
});
it('should run registered teardowns on dispose', () => {
const source = signal('value1');
const emittedValues: string[] = [];
const unsubscribe = source.subscribe((val) => emittedValues.push(val));
// Register the teardown with the ViewModel for disposal
viewModel['addSubscription'](unsubscribe);
source.set('value2');
expect(emittedValues).toEqual(['value2']);
viewModel.dispose();
// After dispose the subscription is torn down — no more deliveries
source.set('value3');
expect(emittedValues).toEqual(['value2']);
});
});Key testing patterns:
- Mock the Model: Create a simple mock Model for testing
- Test signal exposure: Verify ViewModel exposes Model signals correctly, read synchronously via
.get() - Test derived state: Verify
validationErrors$derives fromerror$viacomputed() - Test teardown registration: Verify
addSubscription-registered teardowns run ondispose() - Clean up after tests: Always call
dispose()inafterEach
Testing ViewModel Command Registration
ViewModels can register Commands for user actions. Here's how to test command registration and disposal:
describe('BaseViewModel Command Registration', () => {
class TestViewModel extends BaseViewModel<MockBaseModel> {
public readonly cmd1: Command<void, string>;
public readonly cmd2: Command<void, string>;
constructor(model: MockBaseModel) {
super(model);
this.cmd1 = this.registerCommand(new Command(async () => 'result1'));
this.cmd2 = this.registerCommand(new Command(async () => 'result2'));
}
public getRegisteredCommandCount(): number {
return (this as any)._registeredCommands.length;
}
}
let model: MockBaseModel;
let viewModel: TestViewModel;
beforeEach(() => {
model = new MockBaseModel();
viewModel = new TestViewModel(model);
});
afterEach(() => {
viewModel.dispose();
});
it('should track registered commands', () => {
expect(viewModel.getRegisteredCommandCount()).toBe(2);
});
it('should allow command execution after registration', async () => {
const result = await viewModel.cmd1.execute();
expect(result).toBe('result1');
});
it('should dispose all registered commands', () => {
const disposeSpy1 = vi.spyOn(viewModel.cmd1 as any, 'dispose');
const disposeSpy2 = vi.spyOn(viewModel.cmd2 as any, 'dispose');
viewModel.dispose();
expect(disposeSpy1).toHaveBeenCalled();
expect(disposeSpy2).toHaveBeenCalled();
});
it('should prevent command execution after disposal', async () => {
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
viewModel.dispose();
const result = await viewModel.cmd1.execute();
expect(result).toBeUndefined();
expect(consoleSpy).toHaveBeenCalledWith('Command is disposed. Cannot execute.');
consoleSpy.mockRestore();
});
});This demonstrates:
- Commands are tracked by the ViewModel
- Commands execute correctly when registered
- Commands are disposed when ViewModel is disposed
- Commands cannot execute after disposal
19.4 Testing RestfulApiViewModel with CRUD Operations
RestfulApiViewModel extends BaseViewModel with CRUD operations. Let's test it with real examples from packages/mvvm-core/src/viewmodels/RestfulApiViewModel.test.ts.
Testing Fetch Command
describe('fetchCommand', () => {
it('should call model.fetch without ID when executed without parameter', async () => {
const loadingStates: boolean[] = [];
// observe() delivers the current value immediately, then every future
// change — so this captures the full false → true → false transition.
observe(viewModel.isLoading$, (val) => loadingStates.push(val));
const dataStates: (ItemArray | null)[] = [];
observe(viewModel.data$, (val) => dataStates.push(val));
await viewModel.fetchCommand.execute();
expect(mockModel.fetch).toHaveBeenCalledWith(undefined);
expect(viewModel.fetchCommand.isExecuting$.get()).toBe(false);
expect(loadingStates).toEqual([false, true, false]);
expect(dataStates[dataStates.length - 1]).toEqual([
{ id: '1', name: 'Item 1' },
{ id: '2', name: 'Item 2' },
]);
expect(viewModel.error$.get()).toBeNull();
});
it('should call model.fetch with ID when executed with a string parameter', async () => {
await viewModel.fetchCommand.execute('item-id-3');
expect(mockModel.fetch).toHaveBeenCalledWith(['item-id-3']);
expect(viewModel.data$.get()).toEqual({
id: 'item-id-3',
name: 'Fetched item-id-3',
});
});
it('should set error$ if fetch fails', async () => {
const fetchError = new Error('Fetch failed');
mockModel.fetch.mockImplementation(async () => {
mockModel.setLoading(true);
mockModel.setError(fetchError);
mockModel.setLoading(false);
throw fetchError;
});
await expect(viewModel.fetchCommand.execute()).rejects.toThrow(fetchError);
expect(viewModel.error$.get()).toBe(fetchError);
expect(viewModel.isLoading$.get()).toBe(false);
});
});Key patterns:
- Test loading state transitions (
false → true → false) - Test successful data fetching
- Test error handling
- Verify command execution state
Testing Create, Update, Delete Commands
describe('createCommand', () => {
const payload: Partial<Item> = { name: 'New Test Item' };
it('should call model.create and update data', async () => {
mockModel.setData([]);
await viewModel.createCommand.execute(payload);
expect(mockModel.create).toHaveBeenCalledWith(payload);
expect(viewModel.createCommand.isExecuting$.get()).toBe(false);
const data = viewModel.data$.get();
expect(Array.isArray(data) && data.length).toBe(1);
expect(Array.isArray(data) && data[0].name).toBe('New Test Item');
});
it('should set error$ if create fails', async () => {
const createError = new Error('Create failed');
mockModel.create.mockImplementation(async () => {
mockModel.setLoading(true);
mockModel.setError(createError);
mockModel.setLoading(false);
throw createError;
});
await expect(viewModel.createCommand.execute(payload)).rejects.toThrow(createError);
expect(viewModel.error$.get()).toBe(createError);
});
});
describe('updateCommand', () => {
const existingItem: Item = { id: '1', name: 'Original Name' };
const payload: Partial<Item> = { name: 'Updated Name' };
beforeEach(() => {
mockModel.setData([existingItem]);
});
it('should call model.update and update data', async () => {
await viewModel.updateCommand.execute({ id: existingItem.id, payload });
expect(mockModel.update).toHaveBeenCalledWith(existingItem.id, payload);
const data = viewModel.data$.get();
expect(Array.isArray(data) && data[0].name).toBe('Updated Name');
});
});
describe('deleteCommand', () => {
const itemToDelete: Item = { id: '1', name: 'To Be Deleted' };
beforeEach(() => {
mockModel.setData([itemToDelete, { id: '2', name: 'Keep Me' }]);
});
it('should call model.delete and update data', async () => {
await viewModel.deleteCommand.execute(itemToDelete.id);
expect(mockModel.delete).toHaveBeenCalledWith(itemToDelete.id);
const data = viewModel.data$.get();
expect(Array.isArray(data) && data.length).toBe(1);
expect(Array.isArray(data) && data[0].id).toBe('2');
});
});These tests demonstrate:
- CRUD operations update ViewModel state correctly
- Commands handle errors and propagate them to
error$ - Loading states transition properly during operations
- Data updates are reflected in observables
Testing Selected Item State
RestfulApiViewModel includes item selection logic:
describe('selectedItem$ and selectItem method', () => {
const items: ItemArray = [
{ id: 'a', name: 'Alice' },
{ id: 'b', name: 'Bob' },
{ id: 'c', name: 'Charlie' },
];
beforeEach(() => {
mockModel.setData(items);
});
it('should emit null initially for selectedItem$', () => {
expect(viewModel.selectedItem$.get()).toBeNull();
});
it('should update selectedItem$ when selectItem is called with a valid ID', () => {
viewModel.selectItem('b');
expect(viewModel.selectedItem$.get()).toEqual(items[1]);
});
it('should return null for selectedItem$ if ID is not found in the array', () => {
viewModel.selectItem('non-existent-id');
expect(viewModel.selectedItem$.get()).toBeNull();
});
it('should react to changes in data$ and update selectedItem$', () => {
viewModel.selectItem('a');
expect(viewModel.selectedItem$.get()).toEqual(items[0]);
viewModel.selectItem(null);
expect(viewModel.selectedItem$.get()).toBeNull();
});
});This tests:
- Initial selection state is null
- Selection updates when valid ID is provided
- Selection resolves to null when ID is not found
- Selection can be explicitly cleared with
null - Because
selectedItem$is acomputed(), every assertion is a plain synchronous.get()—no waiting for an emission is needed
19.5 Integration Testing Across Layers
Unit tests verify individual components work correctly. Integration tests verify they work together correctly.
Testing ViewModel with Real Model
Here's an integration test from packages/mvvm-core/src/viewmodels/RestfulApiViewModel.test.ts that uses a real RestfulApiModel instead of a mock:
describe('RestfulApiViewModel with Real RestfulApiModel Integration', () => {
let mockFetcher: ReturnType<typeof vi.fn>;
let realModel: RestfulApiModel<ItemArray, z.ZodArray<typeof ItemSchema>>;
let viewModel: RestfulApiViewModel<ItemArray, z.ZodArray<typeof ItemSchema>>;
const baseUrl = 'http://real-api.com';
const endpoint = 'items';
beforeEach(() => {
mockFetcher = vi.fn();
realModel = new RestfulApiModel<ItemArray, z.ZodArray<typeof ItemSchema>>({
baseUrl,
endpoint,
fetcher: mockFetcher,
schema: z.array(ItemSchema),
initialData: null,
});
viewModel = new RestfulApiViewModel(realModel);
});
afterEach(() => {
vi.clearAllMocks();
viewModel.dispose();
});
it('should fetch data and update viewModel.data$', async () => {
const expectedItems: ItemArray = [
{ id: '1', name: 'Item 1 from Real Model' },
{ id: '2', name: 'Item 2 from Real Model' },
];
mockFetcher.mockResolvedValue(expectedItems);
const dataEmissions: (ItemArray | null)[] = [];
const isLoadingEmissions: boolean[] = [];
const errorEmissions: (Error | null)[] = [];
// observe() delivers the current value immediately, then every future
// change, so these arrays capture the full transition sequence.
observe(viewModel.data$, (data) => dataEmissions.push(data));
observe(viewModel.isLoading$, (loading) => isLoadingEmissions.push(loading));
observe(viewModel.error$, (error) => errorEmissions.push(error));
await viewModel.fetchCommand.execute();
expect(mockFetcher).toHaveBeenCalledTimes(1);
expect(mockFetcher).toHaveBeenCalledWith(`${baseUrl}/${endpoint}`, { method: 'GET' });
// Verify loading state transitions
expect(isLoadingEmissions).toContain(true);
expect(isLoadingEmissions[isLoadingEmissions.length - 1]).toBe(false);
// Verify data was updated
expect(dataEmissions[0]).toBeNull(); // Initial value
expect(dataEmissions[1]).toEqual(expectedItems); // Value after fetch
// Verify no errors
const lastError = errorEmissions.pop();
expect(lastError === null || lastError === undefined).toBe(true);
});
it('should set error$ on viewModel if fetcher fails', async () => {
const apiError = new Error('API Failure');
mockFetcher.mockRejectedValue(apiError);
const errorEmissions: (Error | null)[] = [];
observe(viewModel.error$, (error) => errorEmissions.push(error));
await expect(viewModel.fetchCommand.execute()).rejects.toThrow(apiError);
expect(mockFetcher).toHaveBeenCalledTimes(1);
expect(errorEmissions.pop()).toBe(apiError);
});
});This integration test:
- Uses a real
RestfulApiModel(not a mock) - Mocks only the
fetcherfunction (network boundary) - Tests the complete flow: ViewModel → Model → Fetcher
- Verifies state transitions across both layers
- Proves ViewModel and Model integrate correctly
19.6 Testing with Vitest
All tests in the Web Loom monorepo use Vitest, a fast, modern test runner built for Vite projects.
Vitest Configuration
Here's the Vitest configuration from packages/mvvm-core/vitest.config.js:
// packages/mvvm-core/vitest.config.js
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'jsdom',
setupFiles: './vitest.setup.ts',
testTimeout: 20000,
include: ['src/**/*.{test,spec}.{js,ts}'],
},
});Key configuration:
globals: true: Makesdescribe,it,expectavailable globallyenvironment: 'jsdom': Provides DOM APIs for tests that need themtestTimeout: 20000: Sets timeout to 20 seconds for async testsinclude: Matches test files with.test.tsor.spec.tssuffix
Running Tests
# Run tests once
cd packages/mvvm-core && npm test
# Run tests in watch mode
cd packages/mvvm-core && npm run test:watch
# Run tests with coverage
cd packages/mvvm-core && npm run test:coverageVitest Features Used in MVVM Tests
1. Mocking with vi.fn():
const mockFetcher = vi.fn();
mockFetcher.mockResolvedValue({ id: '1', name: 'Test' });
await viewModel.fetchCommand.execute();
expect(mockFetcher).toHaveBeenCalledTimes(1);
expect(mockFetcher).toHaveBeenCalledWith('/api/items', { method: 'GET' });2. Spying on methods:
const disposeSpy = vi.spyOn(viewModel.cmd1 as any, 'dispose');
viewModel.dispose();
expect(disposeSpy).toHaveBeenCalled();3. Synchronous assertions on signals:
Because selectedItem$ is a computed() signal, its value updates the moment selectItem() runs—no waitFor or async wrapper needed:
viewModel.selectItem('a');
expect(viewModel.selectedItem$.get()).toEqual(items[0]);vi.waitFor is still useful for genuinely async conditions—e.g. waiting on a setTimeout-driven side effect—but reading a signal's current value is never one of them.
4. Lifecycle hooks:
describe('BaseViewModel', () => {
let viewModel: BaseViewModel<MockBaseModel>;
beforeEach(() => {
viewModel = new BaseViewModel(new MockBaseModel());
});
afterEach(() => {
viewModel.dispose(); // Clean up after each test
});
it('should test something', () => {
// Test code
});
});19.7 Testing Strategies for Each MVVM Layer
Let's summarize testing strategies for each layer of MVVM architecture.
Testing Models
What to test:
- Initial state (data, loading, error)
- State updates (
setData,setLoading,setError) - Validation with Zod schemas
- Disposal and cleanup
How to test:
- Create Model instances directly
- Call methods and verify signal changes
- Test validation with valid and invalid data
- Verify disposal stops further notifications
Example:
it('should validate data successfully', () => {
const validData = { id: 'abc', name: 'Test User', age: 42 };
expect(model.validate(validData)).toEqual(validData);
});
it('should throw ZodError for invalid data', () => {
const invalidData = { id: 'def', name: 'Another User', age: -5 };
expect(() => model.validate(invalidData)).toThrow(z.ZodError);
});Testing ViewModels
What to test:
- Signal exposure from Model
- Derived state (like
validationErrors$) - Command registration and execution
- Selection state management
- Disposal and cleanup
How to test:
- Create mock Models
- Inject mocks into ViewModels
- Verify ViewModel exposes correct signals
- Test commands execute correctly
- Verify disposal cleans up commands and subscriptions
Example:
it('should expose data$ from the model', () => {
const testData = { id: '1', name: 'Test' };
mockModel.setData(testData);
expect(viewModel.data$.get()).toEqual(testData);
});
it('should dispose all registered commands', () => {
const disposeSpy = vi.spyOn(viewModel.cmd1 as any, 'dispose');
viewModel.dispose();
expect(disposeSpy).toHaveBeenCalled();
});Testing Views
What to test:
- Correct rendering based on ViewModel state
- User interactions trigger ViewModel methods
- Subscription cleanup on unmount
How to test:
- Use framework-specific testing libraries (React Testing Library, Vue Test Utils)
- Mock ViewModels or use real ViewModels with mock Models
- Verify UI updates when ViewModel state changes
- Verify user actions call ViewModel methods
Example (React Testing Library):
import { signal } from '@web-loom/signals-core';
it('should display sensors when loaded', async () => {
const mockViewModel = {
sensors$: signal([
{ id: '1', name: 'Sensor 1' },
{ id: '2', name: 'Sensor 2' },
]).asReadonly(),
isLoading$: signal(false).asReadonly(),
error$: signal(null).asReadonly(),
};
render(<SensorDashboard viewModel={mockViewModel} />);
expect(screen.getByText('Sensor 1')).toBeInTheDocument();
expect(screen.getByText('Sensor 2')).toBeInTheDocument();
});
it('should call calibrateSensor when button clicked', async () => {
const calibrateSpy = vi.fn();
const mockViewModel = {
sensors$: signal([{ id: '1', name: 'Sensor 1' }]).asReadonly(),
isLoading$: signal(false).asReadonly(),
error$: signal(null).asReadonly(),
calibrateSensor: calibrateSpy,
};
render(<SensorDashboard viewModel={mockViewModel} />);
const calibrateButton = screen.getByText('Calibrate');
fireEvent.click(calibrateButton);
expect(calibrateSpy).toHaveBeenCalledWith('1');
});Integration Testing
What to test:
- Complete flows across Model → ViewModel → View
- Real Model with mocked network layer
- State synchronization across layers
How to test:
- Use real Models and ViewModels
- Mock only external dependencies (fetch, APIs)
- Test complete user workflows
- Verify state updates propagate correctly
Example:
it('should complete full CRUD workflow', async () => {
const mockFetcher = vi.fn();
const model = new RestfulApiModel({
baseUrl: '/api',
endpoint: 'items',
fetcher: mockFetcher,
schema: ItemSchema,
});
const viewModel = new RestfulApiViewModel(model);
// Fetch items
mockFetcher.mockResolvedValueOnce([{ id: '1', name: 'Item 1' }]);
await viewModel.fetchCommand.execute();
expect(viewModel.data$.get()).toHaveLength(1);
// Create item
mockFetcher.mockResolvedValueOnce({ id: '2', name: 'Item 2' });
await viewModel.createCommand.execute({ name: 'Item 2' });
expect(viewModel.data$.get()).toHaveLength(2);
// Update item
mockFetcher.mockResolvedValueOnce({ id: '1', name: 'Updated Item 1' });
await viewModel.updateCommand.execute({ id: '1', payload: { name: 'Updated Item 1' } });
const data = viewModel.data$.get();
expect(data[0].name).toBe('Updated Item 1');
// Delete item
mockFetcher.mockResolvedValueOnce(undefined);
await viewModel.deleteCommand.execute('1');
expect(viewModel.data$.get()).toHaveLength(1);
});19.8 Testing Benefits of MVVM Separation
Let's compare testing complexity between tightly coupled code and MVVM architecture.
Tightly Coupled Code: Testing Nightmare
// ❌ Component with embedded business logic
function SensorDashboard() {
const [sensors, setSensors] = useState<Sensor[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedSensor, setSelectedSensor] = useState<Sensor | null>(null);
useEffect(() => {
setLoading(true);
fetch('/api/sensors')
.then(res => res.json())
.then(data => {
setSensors(data);
setLoading(false);
})
.catch(err => {
setError(err.message);
setLoading(false);
});
}, []);
const handleCalibrate = async (sensorId: string) => {
try {
await fetch(`/api/sensors/${sensorId}/calibrate`, { method: 'POST' });
// Refresh sensors
const res = await fetch('/api/sensors');
const data = await res.json();
setSensors(data);
} catch (err) {
setError(err.message);
}
};
const handleSelect = (sensor: Sensor) => {
setSelectedSensor(sensor);
};
// ... rendering logic
}To test this, you need:
- Render the entire React component
- Mock
fetchglobally - Wait for async effects to complete
- Query DOM elements to verify state
- Simulate user interactions on DOM
- Mock timers if there are delays
- Clean up global mocks after each test
Test complexity: HIGH
- Slow (rendering + DOM queries)
- Brittle (UI changes break tests)
- Hard to isolate (everything coupled)
- Difficult to test edge cases
MVVM Architecture: Testing Bliss
// ✅ ViewModel with isolated business logic
export class SensorViewModel extends RestfulApiViewModel<Sensor[], typeof SensorSchema> {
constructor(model: SensorModel) {
super(model);
}
async calibrateSensor(sensorId: string): Promise<void> {
await this.model.calibrate(sensorId);
await this.fetchCommand.execute(); // Refresh data
}
}
// ✅ Dumb view component
function SensorDashboard({ viewModel }: { viewModel: SensorViewModel }) {
const sensors = useSignal(viewModel.data$);
const loading = useSignal(viewModel.isLoading$);
const error = useSignal(viewModel.error$);
const selectedSensor = useSignal(viewModel.selectedItem$);
// ... rendering logic
}To test the ViewModel:
- Create a mock Model
- Instantiate the ViewModel with the mock
- Call methods directly
- Verify signal changes with
.get()
Test complexity: LOW
- Fast (no rendering, no DOM)
- Stable (UI changes don't affect tests)
- Easy to isolate (ViewModel is independent)
- Simple to test edge cases
Example test:
it('should calibrate sensor and refresh data', async () => {
const mockModel = new MockSensorModel();
const viewModel = new SensorViewModel(mockModel);
mockModel.calibrate = vi.fn().mockResolvedValue(undefined);
mockModel.fetch = vi.fn().mockResolvedValue([
{ id: '1', name: 'Sensor 1', calibrated: true },
]);
await viewModel.calibrateSensor('1');
expect(mockModel.calibrate).toHaveBeenCalledWith('1');
expect(mockModel.fetch).toHaveBeenCalled();
const data = viewModel.data$.get();
expect(data[0].calibrated).toBe(true);
});No rendering. No DOM. No mocking fetch globally. Just pure logic testing.
19.9 Testing Patterns and Best Practices
Pattern 1: Always Dispose in afterEach
describe('SensorViewModel', () => {
let viewModel: SensorViewModel;
beforeEach(() => {
viewModel = new SensorViewModel(new MockSensorModel());
});
afterEach(() => {
viewModel.dispose(); // Prevent memory leaks
});
// Tests...
});Why: ViewModels hold subscriptions. Failing to dispose causes memory leaks and test pollution.
Pattern 2: Read Signals Directly, or observe() a Sequence
import { observe } from '@web-loom/signals-core';
// Get current value — no operator needed, just read it
const currentValue = viewModel.data$.get();
// Collect the current value plus every future change
const emissions: boolean[] = [];
observe(viewModel.isLoading$, val => emissions.push(val));
// If you only want *future* changes (skipping the value at subscribe time),
// use .subscribe() instead of observe()
const futureOnly: boolean[] = [];
viewModel.isLoading$.subscribe(val => futureOnly.push(val));Why: Signals collapse what used to require first(), skip(), take(), and firstValueFrom() into two plain methods: .get() for a synchronous read, and observe()/.subscribe() for a sequence of changes—depending on whether you want the current value included.
Pattern 3: Mock at the Right Boundary
// ❌ Don't mock internal ViewModel methods
const viewModel = new SensorViewModel(mockModel);
vi.spyOn(viewModel, 'loadSensors'); // Bad: testing implementation details
// ✅ Mock external dependencies (Model, API)
const mockModel = new MockSensorModel();
mockModel.fetch = vi.fn().mockResolvedValue([...]); // Good: mock boundary
const viewModel = new SensorViewModel(mockModel);Why: Mocking internal methods tests implementation, not behavior. Mock external dependencies instead.
Pattern 4: Test State Transitions, Not Just Final State
it('should transition through loading states', async () => {
const loadingStates: boolean[] = [];
// observe() delivers the current value immediately, then every future
// change — so the array below captures the full transition.
observe(viewModel.isLoading$, val => loadingStates.push(val));
await viewModel.fetchCommand.execute();
expect(loadingStates).toEqual([false, true, false]);
// initial → loading → complete
});Why: State transitions reveal bugs that final state alone might miss. Note that if you use plain .subscribe() instead of observe(), the initial false won't be captured (subscribe only fires on future changes)—the expected sequence becomes [true, false].
Pattern 5: Test Error Paths, Not Just Happy Paths
it('should handle fetch errors gracefully', async () => {
const fetchError = new Error('Network failure');
mockModel.fetch.mockRejectedValue(fetchError);
await expect(viewModel.fetchCommand.execute()).rejects.toThrow(fetchError);
expect(viewModel.error$.get()).toBe(fetchError);
expect(viewModel.isLoading$.get()).toBe(false);
expect(viewModel.data$.get()).toBeNull();
});Why: Error handling is critical. Test it explicitly.
Pattern 6: Use Descriptive Test Names
// ❌ Vague
it('should work', () => { ... });
// ✅ Descriptive
it('should emit null for selectedItem$ when ID is not found in data array', () => { ... });Why: Descriptive names document behavior and make failures easier to diagnose.
19.10 Real-World Testing Example: GreenWatch Sensor Monitoring
Let's put it all together with a complete testing example for the GreenWatch sensor monitoring system.
SensorViewModel Implementation
// packages/view-models/src/SensorViewModel.ts
export class SensorViewModel extends RestfulApiViewModel<Sensor[], typeof SensorSchema> {
readonly selectedSensor$ = this.selectedItem$;
constructor(model: SensorModel) {
super(model);
}
async loadSensors(greenhouseId: string): Promise<void> {
await this.fetchCommand.execute(greenhouseId);
}
async calibrateSensor(sensorId: string): Promise<void> {
await this.model.calibrate(sensorId);
await this.fetchCommand.execute(); // Refresh after calibration
}
selectSensor(sensorId: string | null): void {
this.selectItem(sensorId);
}
}Complete Test Suite
// packages/view-models/src/SensorViewModel.test.ts
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
import { SensorViewModel } from './SensorViewModel';
import { SensorModel } from '../models/SensorModel';
class MockSensorModel extends SensorModel {
fetch = vi.fn();
calibrate = vi.fn();
}
describe('SensorViewModel', () => {
let mockModel: MockSensorModel;
let viewModel: SensorViewModel;
beforeEach(() => {
mockModel = new MockSensorModel();
viewModel = new SensorViewModel(mockModel);
});
afterEach(() => {
viewModel.dispose();
});
describe('loadSensors', () => {
it('should fetch sensors for a greenhouse', async () => {
const sensors = [
{ id: 's1', name: 'Temperature Sensor', type: 'temperature' },
{ id: 's2', name: 'Humidity Sensor', type: 'humidity' },
];
mockModel.fetch.mockResolvedValue(sensors);
await viewModel.loadSensors('greenhouse-1');
expect(mockModel.fetch).toHaveBeenCalledWith('greenhouse-1');
expect(viewModel.data$.get()).toEqual(sensors);
});
it('should handle fetch errors', async () => {
const error = new Error('Failed to load sensors');
mockModel.fetch.mockRejectedValue(error);
await expect(viewModel.loadSensors('greenhouse-1')).rejects.toThrow(error);
expect(viewModel.error$.get()).toBe(error);
});
});
describe('calibrateSensor', () => {
it('should calibrate sensor and refresh data', async () => {
const initialSensors = [
{ id: 's1', name: 'Sensor 1', calibrated: false },
];
const updatedSensors = [
{ id: 's1', name: 'Sensor 1', calibrated: true },
];
mockModel.fetch.mockResolvedValueOnce(initialSensors);
await viewModel.loadSensors('greenhouse-1');
mockModel.calibrate.mockResolvedValue(undefined);
mockModel.fetch.mockResolvedValueOnce(updatedSensors);
await viewModel.calibrateSensor('s1');
expect(mockModel.calibrate).toHaveBeenCalledWith('s1');
expect(mockModel.fetch).toHaveBeenCalledTimes(2); // Initial load + refresh
const data = viewModel.data$.get();
expect(data[0].calibrated).toBe(true);
});
});
describe('selectSensor', () => {
it('should select a sensor by ID', async () => {
const sensors = [
{ id: 's1', name: 'Sensor 1' },
{ id: 's2', name: 'Sensor 2' },
];
mockModel.fetch.mockResolvedValue(sensors);
await viewModel.loadSensors('greenhouse-1');
viewModel.selectSensor('s2');
expect(viewModel.selectedSensor$.get()).toEqual(sensors[1]);
});
it('should clear selection when null is passed', async () => {
const sensors = [{ id: 's1', name: 'Sensor 1' }];
mockModel.fetch.mockResolvedValue(sensors);
await viewModel.loadSensors('greenhouse-1');
viewModel.selectSensor('s1');
expect(viewModel.selectedSensor$.get()).toEqual(sensors[0]);
viewModel.selectSensor(null);
expect(viewModel.selectedSensor$.get()).toBeNull();
});
});
});This test suite demonstrates:
- Complete ViewModel testing with mocked Model
- Testing async operations (load, calibrate)
- Testing state management (selection)
- Testing error handling
- Testing data refresh after mutations
- Proper setup and teardown
19.11 Key Takeaways
-
MVVM makes testing dramatically simpler by separating business logic from UI rendering. ViewModels can be tested as plain TypeScript classes without any framework dependencies.
-
Test Models for domain logic and validation. Use Zod schemas to validate data, and test that Models correctly manage reactive state (data, loading, error).
-
Test ViewModels in isolation by mocking Models. Verify that ViewModels correctly expose Model signals, handle commands, manage selection state, and clean up on disposal.
-
Integration tests verify layers work together. Use real Models with mocked network boundaries to test complete workflows across Model → ViewModel.
-
Vitest provides excellent testing experience with fast execution, built-in mocking, and modern async testing utilities.
-
Test state transitions, not just final state. Verify loading states transition correctly (
false → true → false) and errors are handled properly. -
Always dispose ViewModels in
afterEachto prevent memory leaks and test pollution from lingering subscriptions. -
Mock at the right boundary. Mock external dependencies (Models, APIs) rather than internal ViewModel methods. Test behavior, not implementation.
-
Signals make observable testing nearly trivial.
.get()reads the current value synchronously;observe()captures the current value plus every future change; plain.subscribe()captures only future changes. No operator library required. -
The testing pyramid applies to MVVM:
- Many unit tests for Models and ViewModels (fast, isolated)
- Some integration tests across layers (moderate speed)
- Few end-to-end tests for complete user workflows (slow, expensive)
19.12 Testing Checklist
When testing MVVM applications, ensure you cover:
Model Tests:
- [ ] Initial state (data, loading, error)
- [ ] State updates (setData, setLoading, setError)
- [ ] Validation with Zod schemas (valid and invalid data)
- [ ] Disposal completes observables
- [ ] No emissions after disposal
ViewModel Tests:
- [ ] Observable exposure from Model
- [ ] Derived state (validationErrors$, computed values)
- [ ] Command registration and execution
- [ ] Command disposal on ViewModel disposal
- [ ] Selection state management
- [ ] Disposal cleans up all subscriptions
Integration Tests:
- [ ] ViewModel + Model work together
- [ ] CRUD operations update state correctly
- [ ] Error handling propagates through layers
- [ ] State synchronization across layers
View Tests (framework-specific):
- [ ] Correct rendering based on ViewModel state
- [ ] User interactions trigger ViewModel methods
- [ ] Subscription cleanup on unmount
- [ ] Loading and error states display correctly
19.13 Next Steps
Now that you understand how to test MVVM applications, you're ready to explore more advanced topics:
- Chapter 20: Plugin Architecture and Extensibility - Learn how to build runtime-extensible applications with the plugin-core library
- Chapter 21: Design Systems and Theming - Discover how to create framework-agnostic design systems with design-core
- Chapter 22: Complete Case Studies - See complete implementations of GreenWatch and e-commerce applications across all frameworks
The testing strategies you've learned in this chapter apply to all these advanced topics. MVVM's separation of concerns makes even complex features testable, maintainable, and reliable.
Remember: The best architecture is the one you can test. MVVM gives you that architecture. Now go write some tests!