Skip to content

@stateloom/testing

Test utilities for @stateloom/* packages. Provides mock implementations, scope helpers, and async utilities for testing reactive state.

Install

bash
pnpm add -D @stateloom/testing
bash
npm install -D @stateloom/testing
bash
yarn add -D @stateloom/testing

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

CategoryUtilitiesPurpose
ScopecreateTestScope()Isolated scope with reset() for test cleanup
MocksmockSubscribable(), mockStore()Manually-controlled reactive sources
AsynccollectValues(), flushEffects(), waitForUpdate()Assertion helpers for async / reactive flows

Quick Start

typescript
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().

typescript
function createTestScope(): TestScope;

Returns: A TestScope with the following methods:

MethodDescription
scopeThe 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
typescript
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 value

mockSubscribable(initialValue)

Create a manually-controlled Subscribable<T> mock.

typescript
function mockSubscribable<T>(initialValue: T): MockSubscribable<T>;
ParameterTypeDescription
initialValueTThe initial value

Returns: A MockSubscribable<T> with:

MethodDescription
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
typescript
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(); // 0

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

typescript
function mockStore<T extends Record<string, unknown>>(initialState: T): MockStoreApi<T>;
ParameterTypeDescription
initialStateTThe initial state object

Returns: A MockStoreApi<T> with:

MethodDescription
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
typescript
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.

typescript
function collectValues<T>(
  subscribable: Subscribable<T>,
  options?: CollectOptions,
): CollectedValues<T>;
ParameterTypeDescription
subscribableSubscribable<T>The reactive source
options.includeInitialbooleanIf true, read get() and include it first. Default: false

Returns: CollectedValues<T> — a real Array<T> with an unsubscribe() method.

typescript
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 collecting

flushEffects(options?)

Flush microtask-scheduled effects.

typescript
function flushEffects(options?: FlushOptions): Promise<void>;
ParameterTypeDescription
options.roundsnumberMicrotask rounds to flush. Default: 1
typescript
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.

typescript
function waitForUpdate<T>(
  subscribable: Subscribable<T>,
  options?: WaitForUpdateOptions,
): Promise<T>;
ParameterTypeDescription
subscribableSubscribable<T>The reactive source
options.timeoutnumberMax wait in ms. Default: 5000
typescript
import { waitForUpdate, mockSubscribable } from '@stateloom/testing';

const mock = mockSubscribable(0);

setTimeout(() => mock.emit(42), 10);
const value = await waitForUpdate(mock);
// value === 42

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

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

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

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

typescript
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

typescript
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

typescript
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

typescript
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

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

ScenarioWhich Utility
Isolate state between testscreateTestScope() with reset() in beforeEach
Test framework adapter hooksmockSubscribable() for controlled reactive source
Test middleware behaviormockStore() for store-like assertions
Assert emitted valuescollectValues() to capture all emissions
Test async state changeswaitForUpdate() with timeout
Flush microtask effectsflushEffects() 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.