Skip to content

Testing

This document describes the test framework, conventions, and CI integration for the XMachines JS monorepo.

Test Framework and Setup

The monorepo uses Vitest ^4.1.5 as its test framework, with @vitest/coverage-v8 for coverage reporting and @vitest/browser-playwright (Playwright/Chromium) for browser-mode tests.

All packages extend the shared Vitest configuration helper defineXmVitestConfig (from @xmachines/shared/vitest) which automatically applies:

  • @xmachines/* source aliases so imports resolve to source during test runs
  • @xmachines/shared/vitest-setup — extends Vitest matchers with @testing-library/jest-dom
  • @xmachines/shared/vitest-node-setup — enforces Node.js ≥ 24 on non-browser projects

Auto-injected setup files:

FileWhen injectedPurpose
packages/shared/config/vitest.node.setup.tsAll non-browser projectsValidates Node.js ≥ 24 runtime; throws if wrong runtime
packages/shared/config/vitest.setup.tsAll projectsImports @testing-library/jest-dom/vitest matchers

Before running any tests, ensure all dependencies are installed:

Terminal window
pnpm install --frozen-lockfile

Running Tests

Full test suite (Node environments)

Terminal window
pnpm test

Runs vitest run across every project that the root vitest.config.ts collects — 36 of them today: one for each package, one for each demo that holds node tests, and infrastructure for the repository tests in tests/. Uses the forks pool (up to 4 workers) with process-level isolation between test files.

The root config finds those projects by glob, so a new package or demo joins the run with no edit. Each test file must belong to one project: a package config that reaches into its own examples/ collects the demo tests that the demo config collects already, and the run then executes them two times, under two different environments. tests/project-collection-overlap.test.ts asks Vitest for the whole collection and fails on any file that two projects claim.

Watch mode (development)

Terminal window
pnpm run test:watch

Runs vitest in interactive watch mode. Re-runs affected tests on file change.

Browser tests (Playwright / Chromium)

Terminal window
pnpm run test:browser

Runs vitest run --config vitest.browser.config.ts. Launches all browser-mode projects using headless Chromium via Playwright. Includes both package-level browser unit tests and full demo-app integration flows. The browser global setup (vitest.browser.global-setup.ts) raises process.setMaxListeners to 32 before workers spawn to prevent false-positive MaxListenersExceededWarning with multiple parallel browser projects.

With coverage

Terminal window
pnpm run test:coverage

Produces coverage output in text, html, and json-summary formats.

Terminal window
pnpm run test:browser:coverage # browser suite, with coverage

Root-level coverage thresholds (regression gate):

MetricThreshold
Lines80%
Functions80%
Statements80%
Branches75%

Individual packages may enforce higher per-package thresholds in their own vitest.config.ts.

Verify TypeScript test graph compiles

Terminal window
pnpm run test:build

Runs tsc --build tsconfig.test.json --force. This validates that all test TypeScript files across the monorepo type-check correctly without running the tests themselves. Also compiles .typecheck.ts files in src/ directories.

This is the command that answers “does this tree type-check?”. Use it rather than pnpm run build for that question. tsc --build skips a project whose dependencies have unchanged .d.ts files, so an error inside the source of a library that changes no exported declaration can leave every consumer of that library up to date and never be reported — and consumers here compile library source, through the source export condition. --force costs about a second and takes that decision away from tsc; tests/typecheck-staleness.test.ts holds it, and the two other properties the complete check rests on, in place: no project of the gate is composite, and the CI job starts with no build state on disk. The job gets that second property by DELETING every *.tsbuildinfo before it runs the gate. It does not inherit it: most of the caching of the pipeline arrives through the included node component, and no test in this repository can read that component’s cache paths, so a claim about them would be prose that nothing verifies.

Running tests for a single package

From the monorepo root:

Terminal window
pnpm --filter @xmachines/play test
pnpm --filter @xmachines/play-react run test:coverage

Or from within a package directory, run pnpm exec vitest run directly after building the workspace.

Test File Organization

Tests live in a separate test/ directory within each package — never co-located with source files.

packages/<name>/
├── src/
│ ├── *.ts
│ └── *.typecheck.ts # Compile-time-only type assertions (not Vitest tests)
└── test/
├── *.spec.ts # Protocol enforcement and type-safety tests
├── *.test.ts # Unit, integration, and behavioral tests
├── browser/
│ └── *.browser.test.ts(x) # Playwright browser environment tests
├── fixtures/ # Shared test actors, helpers, mock data
└── tsconfig.json # Test-specific tsconfig (extends shared/tsconfig-test)

File naming conventions

ExtensionPurpose
.spec.tsProtocol enforcement tests; compile-time type safety tests; used in core packages (play, play-actor, play-signals, play-xstate)
.test.tsUnit, integration, and behavioral tests; used across all packages
.browser.test.ts / .browser.test.tsxPlaywright browser environment tests; excluded from standard vitest.config.ts, only run via vitest.browser.config.ts
.typecheck.ts (in src/)Compile-time-only type assertion files; not Vitest test files; validated by pnpm run test:build
router-bridge-contract.tsShared contract test runner; not a test file itself; imported by adapter test files

Test environments

EnvironmentPackages
nodeCore logic: @xmachines/play, play-actor, play-signals, play-xstate, play-router, router adapters
jsdomUI renderers: @xmachines/play-react, play-vue, play-solid, play-svelte, play-dom
Browser (Playwright/Chromium)Browser-specific and E2E demo tests

Test helpers and shared setup

  • @xmachines/shared/vitest-setup — Injects @testing-library/jest-dom matchers. Applied automatically by defineXmVitestConfig.
  • @xmachines/shared/vitest-node-setup — Enforces Node ≥ 24 at runtime. Auto-injected for non-browser configs.
  • @xmachines/shared/vitest-urlpattern-setup — Polyfills URLPattern for packages that need it (e.g. @xmachines/play-router). Must be declared explicitly in setupFiles.
  • packages/play-react/test/test-utils.ts — React-specific test utilities for the play-react package.
  • packages/play-router/examples/shared/ and packages/play-actor/examples/shared/ — Shared test fixtures for router and actor integration tests.

Portable contract suites

Two suites live in @xmachines/play-router-shared and every router adapter runs them, so a behaviour of RouterBridgeBase is asserted one time and covered eight times:

SuiteImportWhat it holds
runBridgeContractTests@xmachines/play-router-shared/test/router-bridge-contract.jsThe bidirectional sync contract, with optional sections
runBasePathContractTests@xmachines/play-router-shared/test/base-path-contract.jsThe basePath mount contract, and several machines

An adapter supplies a harness rather than tests. For the basePath contract the harness builds ONE router whose write reaches every bridge on it, and the suite mounts one machine or two on that router. A browser behaves that way, and two bridges that never see each other’s writes never contend for the address bar — which is the case the suite exists to cover.

Add a new adapter to both suites. A copy of the assertions in the adapter is how the coverage drifted apart before: five adapters asserted the four behaviours of a mount, two asserted two of them, and the vanilla DOM adapter asserted only that the option reached the bridge.

Writing New Tests

Adding a new package test config

  1. Place test files under packages/<pkg>/test/.

  2. Name them following the convention above.

  3. Import defineXmVitestConfig in the package’s vitest.config.ts:

    import { defineXmVitestConfig } from "@xmachines/shared/vitest";
    export default defineXmVitestConfig(import.meta.url, {
    test: {
    environment: "node",
    include: ["test/**/*.test.ts"],
    exclude: ["node_modules/**"],
    },
    });
  4. Keep the include inside test/, and exclude examples/. The demo config of the package owns every test under examples/, and the root run joins both configs.

  5. Add the package’s tsconfig.test.json to tsconfig.test.json references in the root.

The root vitest.config.ts needs no edit: it collects packages/*/vitest.config.ts and packages/*/examples/*/vitest.config.ts by glob.

Basic test structure

import { describe, it, test, expect, vi, beforeEach, afterEach } from "vitest";
describe("ClassName or functionName()", () => {
describe("feature group or scenario", () => {
test("specific behavior being verified", () => {
// arrange
const actor = createMockActor("/");
// act
actor.send({ type: "play.route", to: "#about" });
// assert
expect(actor.currentRoute.get()).toBe("/about");
});
});
});

Import style: Always use .js extensions in imports (ESM requirement):

import { AbstractActor } from "../src/abstract-actor.js";
import { Signal } from "@xmachines/play-signals";

Lifecycle hooks

beforeEach(() => {
vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks(); // Always restore spies
cleanup(); // For @testing-library/react or equivalent
});

What to mock / what not to mock

Mock these:

  • Framework router objects (TanStack Router, Vue Router, React Router, SolidJS Router) — mock with typed vi.fn() interfaces because they are external framework dependencies and carry significant setup complexity:

    const mocks = vi.hoisted(() => ({
    machineToGraph: vi.fn(),
    buildRouteTree: vi.fn(),
    }));
    vi.mock("../src/machine-to-graph.js", () => ({
    machineToGraph: mocks.machineToGraph,
    }));
  • console.warn / console.error when testing code that legitimately emits warnings — mock to suppress noise and assert call counts:

    const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
    // ... test ...
    expect(warnSpy).toHaveBeenCalled();
    vi.restoreAllMocks(); // in afterEach
  • External module collaborators when testing a unit in isolation.

Never mock these:

  • TC39 Signals (Signal.State, Signal.Computed, Signal.subtle.Watcher) — always use real implementations from @xmachines/play-signals. Signals have precise reactive semantics (synchronous propagation, lazy computation) that mocks cannot replicate accurately.
  • Internal @xmachines/* packages — these are resolved directly to source via the xmAliases Vite plugin in the Vitest config. Mocking them would hide integration bugs and defeat the purpose of cross-package testing.
  • XState — always use real setup() / createMachine() / createActor(). XState’s actor lifecycle (start, stop, snapshot, subscription) is a behavioral contract that mocks cannot reliably emulate.

Test actor patterns

Preferred: extend AbstractActor for full type safety:

import { AbstractActor } from "@xmachines/play-actor";
import type { Routable } from "@xmachines/play-actor";
import { Signal } from "@xmachines/play-signals";
import type { AnyActorLogic } from "xstate";
class MockActor extends AbstractActor<AnyActorLogic> implements Routable {
override state = new Signal.State({} as unknown);
private _routeState: Signal.State<string | null>;
readonly currentRoute: Signal.Computed<string | null>;
readonly initialRoute: string | null;
constructor(startRoute: string | null = "/") {
super({} as AnyActorLogic, {}); // {} as AnyActorLogic is the standard stub
this._routeState = new Signal.State<string | null>(startRoute);
this.currentRoute = new Signal.Computed(() => this._routeState.get());
this.initialRoute = startRoute;
}
override send(_event: { readonly type: string } & Record<string, unknown>): void {
// no-op or capture events for assertion
}
}

Factory functions for lightweight inline mocks — use stubOf, never a double-cast:

import { stubOf } from "@xmachines/shared/test-support";
function createMockActor(initialView: PlaySpec | null = null) {
return stubOf<AbstractActor<AnyActorLogic> & Viewable>({
currentView: new Signal.State<PlaySpec | null>(initialView),
send: vi.fn(),
start: vi.fn(),
stop: vi.fn(),
getSnapshot: vi.fn(),
subscribe: vi.fn(),
state: new Signal.State<unknown>({}),
currentRoute: new Signal.Computed(() => null),
});
}

stubOf keeps every declared member type-checked against the target type (a send with the wrong signature is a compile error) while allowing the rest to be absent — the one unsound assertion lives inside the helper, not at call sites. X as unknown as T is banned in this repo; if stubOf cannot express the case, unsafeCast<T>(value) from the same module is the deliberate, greppable escape hatch.

Async and signal patterns

Flushing the microtask queue (required for Signal propagation):

function waitForMicrotask(): Promise<void> {
return new Promise<void>((resolve) => queueMicrotask(resolve));
}
await waitForMicrotask();
// Or use Vitest's helper:
await vi.waitFor(() => expect(result).toBe(expected), { timeout: 100 });

Signal watcher pattern:

let notified = false;
const watcher = new Signal.subtle.Watcher(() => {
notified = true;
});
watcher.watch(actor.currentRoute);
actor.currentRoute.get(); // Initial read required to prime computed signals
actor.state.set({ path: "/dashboard" });
watcher.getPending(); // Process notifications synchronously
expect(notified).toBe(true);
watcher.unwatch(actor.currentRoute); // Always clean up

Compile-time type tests

For type-only assertions, use @ts-expect-error in .spec.ts files:

const bad: PlaySpec = typedSpec({
root: "root",
elements: {},
// @ts-expect-error "stat" is not a PlaySpec field — typedSpec flags it on an
// inline literal (excess-property checking does not reach aliased or spread
// objects; use `satisfies PlaySpec` at the literal for those)
stat: {},
});
// At runtime the object exists; only the compile-time error is being tested
expect(bad.root).toBe("root");

For purely structural type assertions with no runtime test needed, use .typecheck.ts files in src/:

packages/play-xstate/src/define-player.typecheck.ts
type IsAny<T> = 0 extends 1 & T ? true : false;
type AssertFalse<T extends false> = T;
const actorNotAny: AssertFalse<IsAny<typeof actor>> = false;
void actorNotAny;

These files are validated by pnpm run test:build (tsc --build tsconfig.test.json --force) — never executed by Vitest.

Error testing

import { unsafeCast } from "@xmachines/shared/test-support";
// Synchronous throws — feed a runtime guard an invalid value through
// unsafeCast, the named escape hatch (never `null as unknown as PlayEvent`).
expect(() => actor.send(unsafeCast<PlayEvent>(null))).toThrow(InvalidEventError);
// Error with message pattern
expect(() => extractMachineRoutes(dupMachine)).toThrow(/Duplicate route paths detected/);
// Inspect error properties
try {
extractMachineRoutes(dupMachine);
expect.fail("Should have thrown");
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
expect(message.toLowerCase()).toContain("duplicate");
}

Contract Tests

The @xmachines/play-router-shared package exports a shared behavioral contract suite that all router bridge adapters must satisfy. It lives here (rather than in @xmachines/play-router) because the suite drives a real actor via @xmachines/play-xstate and uses the shared authMachine fixture from @xmachines/play-actor-shared, so it sits one layer above the router package and keeps @xmachines/play-router free of any dependency on the actor runtime:

packages/play-router/examples/shared/test/router-bridge-contract.ts
export function runBridgeContractTests(opts: ContractSuiteOptions): void;
// Each adapter's test file calls it:
import { runBridgeContractTests } from "@xmachines/play-router-shared/test/router-bridge-contract.js";
runBridgeContractTests({
name: "TanStackReactRouterBridge",
createHarness(initialPath) {
/* ... */
},
createRestoredHarness(routedPath) {
/* ... */
},
});

The contract covers: deep-link sync on connect, router→actor navigation sync, actor→router sync, guard redirect flows, duplicate event deduplication, and restore-from-snapshot behavior.

Used by: @xmachines/play-router, play-tanstack-react-router, play-vue-router, play-solid-router, play-react-router, and other adapter packages.

Compile-time bridge contract verification:

import { assertImplementsRouterBridge } from "@xmachines/play-router-shared/test/router-bridge-contract.js";
import { MyRouterBridge } from "../src/my-router-bridge.js";
assertImplementsRouterBridge<MyRouterBridge>(); // Zero runtime cost

Coverage Requirements

Coverage is collected using the v8 provider. The root vitest.config.ts defines monorepo-wide regression thresholds:

TypeMonorepo threshold
Lines80%
Functions80%
Branches75%
Statements80%

Individual packages enforce their own (typically stricter) thresholds inside their vitest.config.ts:

Package tierLinesFunctionsBranchesStatements
Core packages (@xmachines/play, @xmachines/play-actor)90%90%85%90%
Complex logic (@xmachines/play-xstate, @xmachines/play-router)85%85%80%85%
Integration packages (e.g. @xmachines/play-react, @xmachines/play-dom)80%80%80%80%

Coverage includes: src/**/*.ts, src/**/*.tsx, src/**/*.vue, src/**/*.svelte

Coverage is excluded for: test/**/*, **/*.test.ts, **/*.test.tsx, **/*.d.ts, dist/**/*.

CI Integration

Tests run in GitLab CI using the to-be-continuous/node pipeline component (version 5.1.2).

Workflow: The pipeline runs on:

  • Tag pushes ($CI_COMMIT_TAG)
  • Pushes to the default branch ($CI_DEFAULT_BRANCH)
  • Merge requests ($CI_MERGE_REQUEST_IID)

CI test command (from .gitlab-ci.yml):

Terminal window
pnpm run test:coverage -- \
--coverage.reporter=text \
--coverage.reporter=cobertura \
--coverage.reportsDirectory=reports/coverage \
--reporter=default \
--reporter=junit \
--outputFile.junit=reports/junit.xml

Artifacts: The CI job (node-build) publishes:

  • JUnit XML report at reports/junit.xml (surfaced in GitLab’s test results panel)
  • Cobertura coverage XML at reports/coverage/cobertura-coverage.xml (used for GitLab’s coverage percentage badge — extracted from the All files line)

Coverage and test results are always uploaded (when: always) so failures are visible even when the job itself fails.

The pnpm run format:check and pnpm run lint commands enforce code style and are run separately from tests (via the lint-enabled: true input to the pipeline component).

Test Types Reference

Test typeExtensionEnvironmentScope
Unit.test.tsnode or jsdomSingle module in isolation
Protocol/type safety.spec.tsnodeCompile-time type correctness + RFC invariants
Integration.test.tsnodeMultiple modules working together
Performance/regression.spec.tsnodeTiming budgets for critical paths
Browser unit.browser.test.ts(x)Chromium (Playwright)Browser-specific APIs, real microtask timing
E2E demo.browser.test.tsxChromium (Playwright)Full application flows in demo packages
Type-only.typecheck.ts (in src/)tsc onlyStructural type assertions; no Vitest runner