Expressive MVC
Guides

Testing

Test state on its own terms - no renderer, no DOM, no act()

The best thing about moving state into classes is what it does to your tests. There's no component to render, so there's nothing to mock - create an instance, call methods, assert properties. Done.

import { test, expect } from 'vitest';
import { Counter } from './counter';

test('increments count', () => {
  const counter = Counter.new();
  expect(counter.count).toBe(0);
  counter.increment();
  expect(counter.count).toBe(1);
});

Samples here use vitest, but nothing is runner-specific - bun test and jest read the same.


Create with State.new()

Always use State.new() in tests, never new State(). Only .new() activates the instance - properties become reactive and lifecycle hooks run.

const user = UserForm.new(); // empty state
const user = UserForm.new({ name: 'A' }); // with initial values
const user = UserForm.new({ name: 'A' }, (self) => {
  self.touch(); // lifecycle callback
});

Testing actions

Method tests are the cleanest form - arrange, act, assert:

test('adds an item to the cart', () => {
  const cart = Cart.new();
  cart.add({ id: '1', price: 10, qty: 2 });
  expect(cart.items).toHaveLength(1);
  expect(cart.total).toBe(20);
});

Here total is a getter, read for the first time - lazy getters always compute fresh on first access, so this passes as-is. Re-reading a getter across multiple writes wants a flush; more on that under computed values below.


Testing async methods

Async methods are just async methods:

test('saves the form', async () => {
  const form = UserForm.new({ name: 'Alice', email: '[email protected]' });
  await form.save();
  expect(form.saving).toBe(false);
  expect(form.error).toBeNull();
});

Mock fetch or the service the method calls - the class has no React-specific entry points to stub out.


The flush idiom

Commit this one to memory: await state.set() with no arguments waits for pending updates to settle and resolves an array of the keys that changed.

test('batches writes', async () => {
  const form = Form.new();
  form.name = 'Alice';
  form.email = '[email protected]';

  expect(await form.set()).toEqual(['name', 'email']);
});

One idiom, two jobs - it asserts the shape of a batched update, and it doubles as "wait for the dust to settle" before any assertion that depends on dispatch having run. You'll use it in nearly every section below.

Good to know:
If nothing is pending, await state.set() resolves immediately with an empty array. Safe to sprinkle liberally.


Testing async factories

Properties defined with set(async () => ...) have a wrinkle: reading one while the promise is pending doesn't return a promise - it throws one. That's the suspense mechanism, and it works in plain tests too. Catch the throw and await it:

class Profile extends State {
  user = set(async () => fetchUser(this.userId));
}

test('loads user data', async () => {
  const profile = Profile.new({ userId: 'u1' });

  try { void profile.user; } catch (pending) { await pending; }

  expect(profile.user.name).toBe('Alice');
});

The first read kicks off the factory and throws the in-flight promise; awaiting it resumes once the value has settled. After that, reads are synchronous.

If suspense is more ceremony than the test deserves, define the property with set(factory, false) instead - it stays undefined while pending rather than throwing:

class Profile extends State {
  user = set(async () => fetchUser(this.userId), false);
}

test('starts empty, fills in', async () => {
  const profile = Profile.new({ userId: 'u1' });
  expect(profile.user).toBeUndefined();
});

Controllable promises

For real control over timing, hand the factory a promise you resolve yourself. The helper is ten lines and worth keeping around:

function mockPromise<T = void>() {
  let resolve!: (value: T) => void;
  let reject!: (reason?: unknown) => void;
  const promise = new Promise<T>((res, rej) => {
    resolve = res;
    reject = rej;
  });
  return Object.assign(promise, { resolve, reject });
}

Now the test decides when the "network" answers:

test('resolves when the request does', async () => {
  const request = mockPromise<{ name: string }>();

  class Profile extends State {
    user = set(() => request, false);
  }

  const profile = Profile.new();
  expect(profile.user).toBeUndefined();

  request.resolve({ name: 'Moneypenny' });
  await request;
  await profile.set(); // flush

  expect(profile.user!.name).toBe('Moneypenny');
});

No fake timers, no polling. The test reads top to bottom like the scenario it describes. ✨


Testing subscriptions and updates

To assert an update fires, attach a listener with state.set('key', callback). Listeners fire synchronously, once per assignment that changes the value:

test('notifies on count change', () => {
  const counter = Counter.new();
  const seen: number[] = [];

  counter.set('count', () => {
    seen.push(counter.count);
  });

  counter.increment();
  counter.increment();
  expect(seen).toEqual([1, 2]);
});

set(key, callback) returns an unsubscribe function, should the test need to detach early.

Watch the arrow body. The callback's return value is meaningful - return a function and the event system schedules it to run when the update settles. A concise arrow like () => seen.push(...) returns a number, which gets "called" on the next microtask and throws a TypeError long after your assertion passed. Use a block body, always.


Testing effects

Effects registered via state.get(effect) run immediately, then once per batch of changes - two synchronous writes produce one re-run, not two. Flush with await state.set() before asserting:

test('effect re-runs on change', async () => {
  const state = App.new();
  const snapshots: string[] = [];

  const stop = state.get((current) => {
    snapshots.push(current.title);
  });

  state.title = 'A';
  state.title = 'B';
  await state.set();

  expect(snapshots).toEqual(['', 'B']);
  stop();
});

The 'A' write never reaches the effect - both assignments land in the same microtask batch, and the effect sees only where things ended up. If your test expects an intermediate value, the test is wrong, not the batching.


Snapshot and restore

state.get() (no arguments) returns a frozen snapshot of all enumerable fields. state.set(obj) merges values back:

test('round-trips a form', () => {
  const form = Form.new({ name: 'Alice', email: '[email protected]' });
  const snapshot = form.get();

  const restored = Form.new();
  restored.set(snapshot);
  expect(restored.name).toBe('Alice');
  expect(restored.email).toBe('[email protected]');
});

Useful for fixtures, serialization tests, and undo/redo logic.


Testing computed values

Computed values are pure functions of their inputs - but remember that getters are cached, and the cache refreshes with the update batch, not on the spot. Flush between write and assertion:

test('total recomputes from items', async () => {
  const cart = Cart.new();
  expect(cart.total).toBe(0);

  cart.items = [{ price: 10, qty: 2 }];
  await cart.set();
  expect(cart.total).toBe(20);

  cart.items = [];
  await cart.set();
  expect(cart.total).toBe(0);
});

Skip the flush and a previously-read getter may hand you a stale cache, depending on access order. The flush idiom makes it deterministic - cheap insurance.


Testing context dependencies

For classes that declare get(OtherState), compose the dependency as an owned child - the child finds it through the parent automatically:

class Theme extends State {
  color = 'blue';
}

class Panel extends State {
  theme = get(Theme);
}

test('panel reads theme from parent', () => {
  class App extends State {
    theme = new Theme();
    panel = new Panel();
  }

  const app = App.new();
  expect(app.panel.theme.color).toBe('blue');
});

Cleaning up globals

Module-scope State.new() registers the instance into the global root context. Without cleanup, a global created in one test file (or at module load) leaks into every test that follows. Reset the root between tests:

import { afterEach } from 'vitest';
import { Context } from '@expressive/mvc';

afterEach(() => Context.root.pop());

pop() removes every State registered in the root context and runs their cleanup callbacks. This library's own test suite does exactly this, globally. If your tests never touch globals you won't miss it - the first time one does, you will.

Context is also re-exported from @expressive/react, so no extra dependency either way.


Testing destruction

test('cleans up on destroy', () => {
  let cleaned = false;
  class Timer extends State {
    new() {
      return () => {
        cleaned = true;
      };
    }
  }

  const t = Timer.new();
  t.set(null);
  expect(cleaned).toBe(true);
  expect(t.get(null)).toBe(true); // isDestroyed
});

Rendering a Component

When you do want to mount a Component subclass, your normal React setup applies - Expressive adds nothing special to the harness. The one trick worth knowing: pass is to capture the instance, then drive it directly.

import { render, act, screen } from '@testing-library/react';
import { Component } from '@expressive/react';

test('refreshes on update', async () => {
  class Control extends Component {
    value = 'bar';

    render() {
      return <span>{this.value}</span>;
    }
  }

  let control!: Control;
  render(<Control is={(x) => (control = x)} />);

  await act(async () => {
    control.value = 'foo';
    await control.set();
  });

  expect(screen.getByText('foo')).toBeDefined();
});

is runs once, on creation - the instance survives rerenders, so the captured reference stays good for the whole test. Same flush idiom as everywhere else, just wrapped in act.


What you don't need

  • act() - unless a component is actually mounted, there's no renderer to flush.
  • @testing-library/react - you're not rendering anything.
  • jsdom - same deal; state classes never touch the DOM on their own.
  • Mocks for hooks - State classes don't call hooks. A use() method only runs on the State.use() hook path, which .new() sidesteps entirely.

Most of your logic tests end up as plain synchronous functions. That was the point of the classes all along.


Next

On this page