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 ≥ 22 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 ≥ 22 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:
npm ciRunning Tests
Full test suite (Node environments)
npm testRuns vitest run across all 30 package-level projects defined in the root vitest.config.ts. Uses the forks pool (up to 4 workers) with process-level isolation between test files.
Watch mode (development)
npm run test:watchRuns vitest in interactive watch mode. Re-runs affected tests on file change.
Browser tests (Playwright / Chromium)
npm 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
npm run test:coverageProduces coverage output in text, html, and json-summary formats.
npm run coverage:report # HTML reportnpm run coverage:summary # JSON summary onlyVerify TypeScript test graph compiles
npm run test:buildRuns tsc --build tsconfig.test.json. 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.
Running tests for a single package
From the monorepo root:
npm test -w packages/playnpm run test:coverage -w packages/play-reactOr from within a package directory, run npx 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 npm 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 ≥ 22 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.
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/**"],},}); -
Register the new config in the root
vitest.config.tsprojectsarray. -
Add the package’s
tsconfig.test.jsontotsconfig.test.jsonreferences in the root.
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:
function createMockActor(initialView: PlaySpec | null = null) { return { 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({} as unknown), currentRoute: new Signal.Computed(() => null), } as unknown as AbstractActor<AnyActorLogic> & Viewable;}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<MyCtx>({ root: "root", // @ts-expect-error "typo" is not a key of MyCtx — typedSpec enforces this contextProps: ["typo"], elements: {},});// At runtime the object exists; only the compile-time error is being testedexpect(bad.contextProps).toEqual(["typo"]);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 npm run test:build (tsc --build tsconfig.test.json) — never executed by Vitest.
Error testing
// Synchronous throwsexpect(() => actor.send(null as unknown as PlayEvent)).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 package exports a shared behavioral contract suite that all router bridge adapters must satisfy:
export function runBridgeContractTests(opts: ContractSuiteOptions): void;
// Each adapter's test file calls it:import { runBridgeContractTests } from "../../play-router/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/test/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):
npm 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 npm run format:check and npm 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 |