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 ≥ 22 on non-browser projects

Auto-injected setup files:

FileWhen injectedPurpose
packages/shared/config/vitest.node.setup.tsAll non-browser projectsValidates Node.js ≥ 22 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
npm ci

Running Tests

Full test suite (Node environments)

Terminal window
npm test

Runs 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)

Terminal window
npm run test:watch

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

Browser tests (Playwright / Chromium)

Terminal window
npm 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
npm run test:coverage

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

Terminal window
npm run coverage:report # HTML report
npm run coverage:summary # JSON summary only

Verify TypeScript test graph compiles

Terminal window
npm run test:build

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

Terminal window
npm test -w packages/play
npm run test:coverage -w packages/play-react

Or 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

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 npm 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 ≥ 22 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.

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. Register the new config in the root vitest.config.ts projects array.

  5. Add the package’s tsconfig.test.json to tsconfig.test.json references 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.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:

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 up

Compile-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 tested
expect(bad.contextProps).toEqual(["typo"]);

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 npm run test:build (tsc --build tsconfig.test.json) — never executed by Vitest.

Error testing

// Synchronous throws
expect(() => actor.send(null as unknown as PlayEvent)).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 package exports a shared behavioral contract suite that all router bridge adapters must satisfy:

packages/play-router/test/router-bridge-contract.ts
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 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
npm 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 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 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