@stateloom/testing
Test utilities for @stateloom/* packages. Provides mock implementations, scope helpers, and async utilities for testing reactive state.
Install
pnpm add -D @stateloom/testingnpm install -D @stateloom/testingyarn add -D @stateloom/testingPeer Dependency
@stateloom/testing peer-depends on @stateloom/core. It does not depend on any paradigm package (store, atom, proxy) or framework adapter.
Overview
The package provides six utilities grouped into three categories:
| Category | Utilities | Purpose |
|---|---|---|
| Scope | createTestScope() | Isolated scope with reset() for test cleanup |
| Mocks | mockSubscribable(), mockStore() | Manually-controlled reactive sources |
| Async | collectValues(), flushEffects(), waitForUpdate() | Assertion helpers for async / reactive flows |
Quick Start
import { describe, it, expect, beforeEach } from 'vitest';
import { signal } from '@stateloom/core';
import { createTestScope, mockSubscribable, collectValues } from '@stateloom/testing';
describe('my feature', () => {
const testScope = createTestScope();
beforeEach(() => {
testScope.reset();
});
it('tracks scoped state', () => {
const count = signal(0);
testScope.set(count, 42);
expect(testScope.get(count)).toBe(42);
});
it('collects emitted values', () => {
const mock = mockSubscribable(0);
const values = collectValues(mock);
mock.emit(1);
mock.emit(2);
expect([...values]).toEqual([1, 2]);
values.unsubscribe();
});
});API Reference
createTestScope()
Create an isolated test scope wrapping the core createScope().
function createTestScope(): TestScope;Returns: A TestScope with the following methods:
| Method | Description |
|---|---|
scope | The underlying core Scope (getter) |
get(subscribable) | Read a subscribable's scoped value |
set(signal, value) | Set a signal's value within the scope |
fork() | Create a child TestScope inheriting parent values |
run(fn) | Execute fn with this scope as the active scope (via runInScope) |
reset() | Replace the internal scope with a fresh createScope() |
serialize() | Serialize scoped state to a plain object |
import { createTestScope } from '@stateloom/testing';
import { signal } from '@stateloom/core';
const testScope = createTestScope();
const count = signal(0);
testScope.set(count, 10);
testScope.get(count); // 10
// Fork creates an isolated child
const child = testScope.fork();
child.set(count, 99);
testScope.get(count); // 10 — parent unchanged
// Reset discards all scoped state
testScope.reset();
testScope.get(count); // 0 — reads global valuemockSubscribable(initialValue)
Create a manually-controlled Subscribable<T> mock.
function mockSubscribable<T>(initialValue: T): MockSubscribable<T>;| Parameter | Type | Description |
|---|---|---|
initialValue | T | The initial value |
Returns: A MockSubscribable<T> with:
| Method | Description |
|---|---|
get() | Read the current value |
subscribe(callback) | Register a subscriber, returns unsubscribe function |
emit(value) | Set value and notify all subscribers synchronously |
getSubscriberCount() | Number of active subscribers |
reset() | Restore initial value and clear all subscribers |
import { mockSubscribable } from '@stateloom/testing';
const mock = mockSubscribable('initial');
const values: string[] = [];
mock.subscribe((v) => values.push(v));
mock.emit('hello');
mock.emit('world');
// values === ['hello', 'world']
mock.getSubscriberCount(); // 1
mock.reset();
mock.get(); // 'initial'
mock.getSubscriberCount(); // 0Type Compatibility
MockSubscribable<T> structurally satisfies Subscribable<T> from @stateloom/core. You can pass it anywhere a Subscribable<T> is expected.
mockStore(initialState)
Create a mock store structurally compatible with StoreApi<T>.
function mockStore<T extends Record<string, unknown>>(initialState: T): MockStoreApi<T>;| Parameter | Type | Description |
|---|---|---|
initialState | T | The initial state object |
Returns: A MockStoreApi<T> with:
| Method | Description |
|---|---|
get() / getState() | Read current state |
setState(partial | updater) | Shallow-merge partial state |
subscribe(listener) | Listen to (state, prevState) changes |
getInitialState() | Read the frozen initial state |
destroy() | Make setState a no-op |
getStateHistory() | All state snapshots for assertions |
reset() | Restore initial state, clear subscribers/history, un-destroy |
import { mockStore } from '@stateloom/testing';
const store = mockStore({ count: 0, name: 'test' });
store.setState({ count: 1 });
store.setState((s) => ({ count: s.count + 1 }));
store.getState(); // { count: 2, name: 'test' }
// Assert state transitions
store.getStateHistory();
// [{ count: 0, ... }, { count: 1, ... }, { count: 2, ... }]
// Frozen initial state never changes
store.getInitialState(); // { count: 0, name: 'test' }Structural Typing
MockStoreApi<T> does not import StoreApi<T> from @stateloom/store. It declares the same methods independently, so TypeScript's structural type system makes it assignable at call sites.
collectValues(subscribable, options?)
Subscribe to a source and collect emitted values into an array.
function collectValues<T>(
subscribable: Subscribable<T>,
options?: CollectOptions,
): CollectedValues<T>;| Parameter | Type | Description |
|---|---|---|
subscribable | Subscribable<T> | The reactive source |
options.includeInitial | boolean | If true, read get() and include it first. Default: false |
Returns: CollectedValues<T> — a real Array<T> with an unsubscribe() method.
import { collectValues, mockSubscribable } from '@stateloom/testing';
const mock = mockSubscribable(0);
const values = collectValues(mock, { includeInitial: true });
// [...values] === [0]
mock.emit(1);
mock.emit(2);
// [...values] === [0, 1, 2]
values.unsubscribe();
mock.emit(3);
// [...values] === [0, 1, 2] — no longer collectingflushEffects(options?)
Flush microtask-scheduled effects.
function flushEffects(options?: FlushOptions): Promise<void>;| Parameter | Type | Description |
|---|---|---|
options.rounds | number | Microtask rounds to flush. Default: 1 |
import { flushEffects } from '@stateloom/testing';
// Flush one round
await flushEffects();
// Flush cascading effects
await flushEffects({ rounds: 3 });When to Use
Signal set() effects in @stateloom/core run synchronously within a batch. flushEffects() is mainly useful for edge cases involving microtask-scheduled work or future async scheduling.
waitForUpdate(subscribable, options?)
Wait for the next emission from a subscribable.
function waitForUpdate<T>(
subscribable: Subscribable<T>,
options?: WaitForUpdateOptions,
): Promise<T>;| Parameter | Type | Description |
|---|---|---|
subscribable | Subscribable<T> | The reactive source |
options.timeout | number | Max wait in ms. Default: 5000 |
import { waitForUpdate, mockSubscribable } from '@stateloom/testing';
const mock = mockSubscribable(0);
setTimeout(() => mock.emit(42), 10);
const value = await waitForUpdate(mock);
// value === 42Auto-unsubscribes after resolution or timeout rejection.
Guide
Setting Up Test Isolation
Use createTestScope with beforeEach/afterEach to ensure each test starts with a clean state:
import { describe, it, expect, beforeEach } from 'vitest';
import { signal, computed, effect } from '@stateloom/core';
import { createTestScope } from '@stateloom/testing';
describe('counter feature', () => {
const testScope = createTestScope();
const count = signal(0);
const doubled = computed(() => count.get() * 2);
beforeEach(() => {
testScope.reset();
});
it('isolates scoped values', () => {
testScope.set(count, 10);
expect(testScope.get(count)).toBe(10);
expect(testScope.get(doubled)).toBe(20);
});
it('starts fresh after reset', () => {
expect(testScope.get(count)).toBe(0);
});
});Testing with Mock Subscribables
Use mockSubscribable when you need a manually-controlled reactive source. This is useful for testing framework adapter hooks or middleware:
import { mockSubscribable, collectValues } from '@stateloom/testing';
const mock = mockSubscribable(0);
const values = collectValues(mock, { includeInitial: true });
mock.emit(1);
mock.emit(2);
mock.emit(3);
expect([...values]).toEqual([0, 1, 2, 3]);
values.unsubscribe();Testing Store Behavior
Use mockStore to test components or middleware that depend on a store without creating a real one:
import { mockStore } from '@stateloom/testing';
const store = mockStore({ count: 0, name: 'test' });
// Simulate state changes
store.setState({ count: 1 });
store.setState((s) => ({ count: s.count + 1 }));
// Assert state history
expect(store.getStateHistory()).toEqual([
{ count: 0, name: 'test' },
{ count: 1, name: 'test' },
{ count: 2, name: 'test' },
]);Waiting for Async Updates
Use waitForUpdate for testing async effects or delayed state changes:
import { waitForUpdate, mockSubscribable } from '@stateloom/testing';
const mock = mockSubscribable(0);
// Schedule an update
setTimeout(() => mock.emit(42), 10);
const value = await waitForUpdate(mock);
expect(value).toBe(42);Patterns
Component Testing with Scopes
import { describe, it, expect, beforeEach } from 'vitest';
import { signal } from '@stateloom/core';
import { createTestScope } from '@stateloom/testing';
const userSignal = signal<string | null>(null);
describe('user component', () => {
const testScope = createTestScope();
beforeEach(() => {
testScope.reset();
});
it('shows user name when logged in', () => {
testScope.set(userSignal, 'Alice');
testScope.run(() => {
expect(testScope.get(userSignal)).toBe('Alice');
});
});
it('shows nothing when logged out', () => {
testScope.run(() => {
expect(testScope.get(userSignal)).toBeNull();
});
});
});Middleware Testing
import { mockStore } from '@stateloom/testing';
it('middleware transforms state', () => {
const store = mockStore({ count: 0 });
const listener = vi.fn();
store.subscribe(listener);
store.setState({ count: 5 });
expect(listener).toHaveBeenCalledWith({ count: 5 }, { count: 0 });
});Collecting All Emissions
import { signal, effect } from '@stateloom/core';
import { collectValues } from '@stateloom/testing';
it('effect fires on every change', () => {
const count = signal(0);
const values = collectValues(count);
count.set(1);
count.set(2);
count.set(3);
expect([...values]).toEqual([1, 2, 3]);
values.unsubscribe();
});How It Works
Test Scope Reset
createTestScope wraps a mutable reference to a core Scope. Calling reset() replaces the inner scope with a fresh createScope(), discarding all scoped state. This is O(1) -- no cleanup of individual signals required.
Mock Subscribable
mockSubscribable manages a Set<Callback> and a current value. emit(value) updates the value and iterates the set synchronously. reset() clears all subscribers and restores the initial value.
Mock Store
mockStore implements the StoreApi<T> interface structurally (no runtime import). It tracks state history as an array for assertion. setState handles both partial objects and updater functions, performing a shallow merge like the real store.
Collected Values
collectValues returns an object that extends Array<T> and adds an unsubscribe() method. Values are pushed on each emission. This avoids callback boilerplate in tests.
TypeScript
import { createTestScope, mockSubscribable, mockStore, collectValues } from '@stateloom/testing';
import type {
TestScope,
MockSubscribable as MockSubType,
MockStoreApi,
CollectedValues,
} from '@stateloom/testing';
import { expectTypeOf } from 'vitest';
// createTestScope returns TestScope
const scope = createTestScope();
expectTypeOf(scope).toMatchTypeOf<TestScope>();
// mockSubscribable infers from initial value
const mock = mockSubscribable(42);
expectTypeOf(mock.get()).toEqualTypeOf<number>();
// mockStore infers from initial state
const store = mockStore({ count: 0, name: 'test' });
expectTypeOf(store.getState()).toEqualTypeOf<{ count: number; name: string }>();
// collectValues returns CollectedValues<T>
const values = collectValues(mock);
expectTypeOf(values).toMatchTypeOf<CollectedValues<number>>();When to Use
| Scenario | Which Utility |
|---|---|
| Isolate state between tests | createTestScope() with reset() in beforeEach |
| Test framework adapter hooks | mockSubscribable() for controlled reactive source |
| Test middleware behavior | mockStore() for store-like assertions |
| Assert emitted values | collectValues() to capture all emissions |
| Test async state changes | waitForUpdate() with timeout |
| Flush microtask effects | flushEffects() for edge cases |
@stateloom/testing is a dev dependency. It has zero runtime overhead and does not import @stateloom/store -- all types use structural compatibility.