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:
| File | When injected | Purpose |
|---|---|---|
packages/shared/config/vitest.node.setup.ts | All non-browser projects | Validates Node.js ≥ 24 runtime; throws if wrong runtime |
packages/shared/config/vitest.setup.ts | All projects | Imports @testing-library/jest-dom/vitest matchers |
Before running any tests, ensure all dependencies are installed:
pnpm install --frozen-lockfileRunning Tests
Full test suite (Node environments)
pnpm testRuns 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)
pnpm run test:watchRuns vitest in interactive watch mode. Re-runs affected tests on file change.
Browser tests (Playwright / Chromium)
pnpm run test:browserRuns 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
pnpm run test:coverageProduces coverage output in text, html, and json-summary formats.
pnpm run test:browser:coverage # browser suite, with coverageRoot-level coverage thresholds (regression gate):
| Metric | Threshold |
|---|---|
| Lines | 80% |
| Functions | 80% |
| Statements | 80% |
| Branches | 75% |
Individual packages may enforce higher per-package thresholds in their own vitest.config.ts.
Verify TypeScript test graph compiles
pnpm run test:buildRuns 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:
pnpm --filter @xmachines/play testpnpm --filter @xmachines/play-react run test:coverageOr 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
| Extension | Purpose |
|---|---|
.spec.ts | Protocol enforcement tests; compile-time type safety tests; used in core packages (play, play-actor, play-signals, play-xstate) |
.test.ts | Unit, integration, and behavioral tests; used across all packages |
.browser.test.ts / .browser.test.tsx | Playwright 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.ts | Shared contract test runner; not a test file itself; imported by adapter test files |
Test environments
| Environment | Packages |
|---|---|
node | Core logic: @xmachines/play, play-actor, play-signals, play-xstate, play-router, router adapters |
jsdom | UI 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-dommatchers. Applied automatically bydefineXmVitestConfig.@xmachines/shared/vitest-node-setup— Enforces Node ≥ 24 at runtime. Auto-injected for non-browser configs.@xmachines/shared/vitest-urlpattern-setup— PolyfillsURLPatternfor packages that need it (e.g.@xmachines/play-router). Must be declared explicitly insetupFiles.packages/play-react/test/test-utils.ts— React-specific test utilities for theplay-reactpackage.packages/play-router/examples/shared/andpackages/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:
| Suite | Import | What it holds |
|---|---|---|
runBridgeContractTests | @xmachines/play-router-shared/test/router-bridge-contract.js | The bidirectional sync contract, with optional sections |
runBasePathContractTests | @xmachines/play-router-shared/test/base-path-contract.js | The 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
-
Place test files under
packages/<pkg>/test/. -
Name them following the convention above.
-
Import
defineXmVitestConfigin the package’svitest.config.ts:import { defineXmVitestConfig } from "@xmachines/shared/vitest";export default defineXmVitestConfig(import.meta.url, {test: {environment: "node",include: ["test/**/*.test.ts"],exclude: ["node_modules/**"],},}); -
Keep the
includeinsidetest/, and excludeexamples/. The demo config of the package owns every test underexamples/, and the root run joins both configs. -
Add the package’s
tsconfig.test.jsontotsconfig.test.jsonreferences 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.errorwhen 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 thexmAliasesVite 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 upCompile-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 testedexpect(bad.root).toBe("root");For purely structural type assertions with no runtime test needed, use .typecheck.ts files in src/:
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 patternexpect(() => extractMachineRoutes(dupMachine)).toThrow(/Duplicate route paths detected/);
// Inspect error propertiestry { 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:
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 costCoverage Requirements
Coverage is collected using the v8 provider. The root vitest.config.ts defines monorepo-wide regression thresholds:
| Type | Monorepo threshold |
|---|---|
| Lines | 80% |
| Functions | 80% |
| Branches | 75% |
| Statements | 80% |
Individual packages enforce their own (typically stricter) thresholds inside their vitest.config.ts:
| Package tier | Lines | Functions | Branches | Statements |
|---|---|---|---|---|
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):
pnpm run test:coverage -- \ --coverage.reporter=text \ --coverage.reporter=cobertura \ --coverage.reportsDirectory=reports/coverage \ --reporter=default \ --reporter=junit \ --outputFile.junit=reports/junit.xmlArtifacts: 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 theAll filesline)
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 type | Extension | Environment | Scope |
|---|---|---|---|
| Unit | .test.ts | node or jsdom | Single module in isolation |
| Protocol/type safety | .spec.ts | node | Compile-time type correctness + RFC invariants |
| Integration | .test.ts | node | Multiple modules working together |
| Performance/regression | .spec.ts | node | Timing budgets for critical paths |
| Browser unit | .browser.test.ts(x) | Chromium (Playwright) | Browser-specific APIs, real microtask timing |
| E2E demo | .browser.test.tsx | Chromium (Playwright) | Full application flows in demo packages |
| Type-only | .typecheck.ts (in src/) | tsc only | Structural type assertions; no Vitest runner |