Testing Utilities Design

February 13, 2026 · View on GitHub

Low-level design for @stateloom/testing — the test utility package for consumers testing against StateLoom reactive primitives and stores.

Design Goals

  1. Core-only dependency — peer-depends on @stateloom/core only. No framework or paradigm imports.
  2. Structural typing — mock stores match StoreApi<T> without importing @stateloom/store.
  3. Test ergonomicsreset() on every mock for clean beforeEach teardown.
  4. Zero magic — all mocks are manually controlled. No auto-tracking, no reactive graph integration.

Architecture

graph TD
    subgraph "@stateloom/testing"
        TS[createTestScope]
        MS[mockSubscribable]
        MSt[mockStore]
        CV[collectValues]
        FE[flushEffects]
        WU[waitForUpdate]
    end

    subgraph "@stateloom/core"
        CS[createScope]
        RIS[runInScope]
        Sub["Subscribable&lt;T&gt;"]
    end

    TS --> CS
    TS --> RIS
    CV --> Sub
    WU --> Sub

    style TS fill:#4a9eff,color:#fff
    style MS fill:#7c3aed,color:#fff
    style MSt fill:#7c3aed,color:#fff
    style CV fill:#059669,color:#fff
    style FE fill:#059669,color:#fff
    style WU fill:#059669,color:#fff

Dependency Boundary

The package imports from @stateloom/core for:

  • createScope / runInScope — used by createTestScope()
  • Subscribable<T> / Signal<T> / Scope — type-only imports for interfaces

It does not import from @stateloom/store, @stateloom/atom, or any framework adapter. The MockStoreApi<T> interface declares its own method signatures that structurally match StoreApi<T>.

Type Strategy

Structural Compatibility

MockStoreApi<T> declares the same surface as StoreApi<T>:

interface MockStoreApi<T extends Record<string, unknown>> {
  get(): T;
  getState(): T;
  setState(partial: Partial<T> | ((state: T) => Partial<T>)): void;
  subscribe(listener: (state: T, prevState: T) => void): () => void;
  getInitialState(): T;
  destroy(): void;
  // Test helpers (not on StoreApi)
  getStateHistory(): readonly T[];
  reset(): void;
}

TypeScript's structural typing makes MockStoreApi<T> assignable to StoreApi<T> at call sites without any explicit extends relationship. The extra methods (getStateHistory, reset) don't break assignability.

MockSubscribable vs Subscribable

MockSubscribable<T> declares get() and subscribe() matching Subscribable<T>, plus emit(), getSubscriberCount(), and reset(). It is structurally assignable to Subscribable<T>.

Implementation Details

createTestScope — Scope Wrapping

ScopeImpl uses private #fields, so external code cannot clear its internal Map. Instead of trying to reset the existing scope, reset() replaces the entire scope reference:

function wrapScope(coreScope: Scope): TestScope {
  let scope = coreScope;
  return {
    get scope() {
      return scope;
    },
    reset() {
      scope = createScope();
    },
    // ... delegates to scope
  };
}

mockStore — Shallow Merge Semantics

setState follows the same semantics as @stateloom/store:

const resolved = typeof partial === 'function' ? partial(state) : partial;
state = Object.assign({}, state, resolved);

This creates a new reference on every update, matching real store behavior.

collectValues — Extended Array

Returns a real Array extended via Object.assign with an unsubscribe property. This preserves all array methods (.length, .filter, .map, indexing) while adding cleanup capability.

waitForUpdate — Timeout + Auto-Cleanup

Uses setTimeout for timeout and auto-unsubscribes in all code paths (resolution and rejection). A settled flag prevents double-resolution race conditions.

Deferred: TestScopeProvider

A React <TestScopeProvider> component was considered but deferred because it would require a React peer dependency, violating the core-only constraint. Options for the future:

  1. @stateloom/react-testing — dedicated React testing package
  2. Part of @stateloom/react — bundled with the React adapter

Test Utility Decision Tree

Use this flowchart to choose the right testing utility:

flowchart TB
    Start["What are you testing?"] --> Q1{Component with stores?}
    Start --> Q2{Reactive state logic?}
    Start --> Q3{Async state transitions?}

    Q1 -->|Yes| MS["mockStore(initialState)<br/>Structural match for StoreApi"]
    Q2 -->|Yes| Q4{Need isolation?}
    Q3 -->|Yes| WU["waitForUpdate(subscribable, timeout)<br/>Promise-based with auto-cleanup"]

    Q4 -->|Per-test scope| TS["createTestScope()<br/>reset() per test"]
    Q4 -->|Track emitted values| CV["collectValues(subscribable)<br/>Array + unsubscribe"]
    Q4 -->|Mock a subscribable| MSub["mockSubscribable(initial)<br/>emit() + getSubscriberCount()"]

    Q1 --> Q5{Need to flush effects?}
    Q2 --> Q5
    Q5 -->|Yes| FE["flushEffects()<br/>Process pending microtasks"]

Mock Hierarchy

This diagram shows how the mock utilities relate to the real implementations they replace:

classDiagram
    class Subscribable~T~ {
        <<interface>>
        +get() T
        +subscribe(cb) () => void
    }

    class StoreApi~T~ {
        <<interface>>
        +get() T
        +getState() T
        +setState(partial) void
        +subscribe(listener) () => void
        +destroy() void
    }

    class MockSubscribable~T~ {
        +get() T
        +subscribe(cb) () => void
        +emit(value) void
        +getSubscriberCount() number
        +reset() void
    }

    class MockStoreApi~T~ {
        +get() T
        +getState() T
        +setState(partial) void
        +subscribe(listener) () => void
        +destroy() void
        +getStateHistory() T[]
        +reset() void
    }

    class TestScope {
        +scope Scope
        +fork() Scope
        +get(sub) T
        +set(signal, value) void
        +reset() void
    }

    Subscribable~T~ <|.. MockSubscribable~T~ : structurally matches
    StoreApi~T~ <|.. MockStoreApi~T~ : structurally matches
    MockSubscribable~T~ : "Test control: emit(), reset()"
    MockStoreApi~T~ : "Test control: getStateHistory(), reset()"
    TestScope : "Test control: reset() replaces scope"

Coverage Strategy

Test CategoryTargetUtilities UsedExample
Unit: signal/computed/effect95%+ branchescollectValues, flushEffectsVerify computed recomputes on signal change
Unit: store setState95%+ branchesmockStore or real storeVerify shallow merge semantics
Unit: middleware hooks95%+ branchesReal store + middlewareVerify onSet chain ordering
Integration: framework adapter90%+mockSubscribable, framework test utilsVerify re-render on state change
Integration: SSRKey pathscreateTestScope, framework SSR utilsVerify scope isolation per request
Type: structural compatibilityAll exported typesexpectTypeOf assertionsVerify MockStoreApi assignable to StoreApi

File Structure

packages/testing/
├── src/
│   ├── types.ts              # All exported interfaces
│   ├── test-scope.ts         # createTestScope()
│   ├── mock-subscribable.ts  # mockSubscribable()
│   ├── mock-store.ts         # mockStore()
│   ├── collect-values.ts     # collectValues()
│   ├── flush-effects.ts      # flushEffects()
│   ├── wait-for-update.ts    # waitForUpdate()
│   ├── global.d.ts           # Runtime globals (queueMicrotask, setTimeout)
│   └── index.ts              # Barrel export
├── __tests__/
│   ├── test-scope.test.ts
│   ├── mock-subscribable.test.ts
│   ├── mock-store.test.ts
│   ├── collect-values.test.ts
│   ├── flush-effects.test.ts
│   ├── wait-for-update.test.ts
│   └── types.test.ts
├── package.json
├── tsconfig.json
├── tsup.config.ts
└── vitest.config.ts

Cross-References