Web Loom logo
Chapter 19Advanced Topics

Testing MVVM Applications

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?

  1. You need a DOM: Can't test business logic without rendering React components
  2. You need to mock fetch: Every test requires mocking network calls
  3. You can't test in isolation: Business logic is tangled with UI rendering
  4. Tests are slow: Rendering components and mocking DOM APIs takes time
  5. 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:

  1. No DOM needed: Test ViewModels as plain TypeScript classes
  2. Mock at the Model layer: Inject mock Models into ViewModels
  3. Test in isolation: ViewModels have no UI dependencies
  4. Tests are fast: No rendering, no DOM, just pure logic
  5. 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:

  1. Test reactive state: Read signals synchronously with .get()—no async wrapper needed
  2. Test validation: Verify Zod schema validation works correctly
  3. Test state transitions: Verify setData, setLoading, setError update the underlying signals
  4. 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:

  1. Mock the Model: Create a simple mock Model for testing
  2. Test signal exposure: Verify ViewModel exposes Model signals correctly, read synchronously via .get()
  3. Test derived state: Verify validationErrors$ derives from error$ via computed()
  4. Test teardown registration: Verify addSubscription-registered teardowns run on dispose()
  5. Clean up after tests: Always call dispose() in afterEach

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 a computed(), 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 fetcher function (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: Makes describe, it, expect available globally
  • environment: 'jsdom': Provides DOM APIs for tests that need them
  • testTimeout: 20000: Sets timeout to 20 seconds for async tests
  • include: Matches test files with .test.ts or .spec.ts suffix

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:coverage

Vitest 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:

  1. Render the entire React component
  2. Mock fetch globally
  3. Wait for async effects to complete
  4. Query DOM elements to verify state
  5. Simulate user interactions on DOM
  6. Mock timers if there are delays
  7. 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:

  1. Create a mock Model
  2. Instantiate the ViewModel with the mock
  3. Call methods directly
  4. 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

  1. MVVM makes testing dramatically simpler by separating business logic from UI rendering. ViewModels can be tested as plain TypeScript classes without any framework dependencies.

  2. Test Models for domain logic and validation. Use Zod schemas to validate data, and test that Models correctly manage reactive state (data, loading, error).

  3. Test ViewModels in isolation by mocking Models. Verify that ViewModels correctly expose Model signals, handle commands, manage selection state, and clean up on disposal.

  4. Integration tests verify layers work together. Use real Models with mocked network boundaries to test complete workflows across Model → ViewModel.

  5. Vitest provides excellent testing experience with fast execution, built-in mocking, and modern async testing utilities.

  6. Test state transitions, not just final state. Verify loading states transition correctly (false → true → false) and errors are handled properly.

  7. Always dispose ViewModels in afterEach to prevent memory leaks and test pollution from lingering subscriptions.

  8. Mock at the right boundary. Mock external dependencies (Models, APIs) rather than internal ViewModel methods. Test behavior, not implementation.

  9. 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.

  10. 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!

Web Loom logo
Copyright © Web Loom. All rights reserved.