Testing

Tests are executable specifications. Evolu apps and libraries should follow the same organization as the Evolu repository: collocated unit tests, targeted integration and browser tests, and type contracts tested beside runtime behavior.

Test organization

Keep unit tests beside their source. Put cross-module and platform tests in a root test directory, adding only the categories the project needs:

src/
  Foo.ts
  Foo.test.ts
test/
  integration/
    nodejs/
    browsers/
    shared/
  bundle/
  jsdoc/

Use node:test for unit and Node.js integration tests that Node.js can execute directly. Use test for standalone cases; use describe and it when several cases describe one subject. Use Vitest for real-browser and shared cross-runtime suites, or when framework tooling requires it.

Do not repeat the complete unit suite in browsers. Target browser APIs, engine differences, polyfills, workers, and framework behavior. Use integration tests for real platform implementations such as SQLite drivers and WebSockets, and bundle tests when a published library's output, tree-shaking, execution, or size is part of its contract.

Use Node.js's built-in coverage for unit tests. Cover the changed behavior, including relevant branches and failure paths. Report pre-existing coverage gaps without adding unrelated tests solely to reach 100% coverage of an entire source file. Integration and bundle tests verify compositions and artifacts; browser suites can also collect coverage where the engine supports it. Documentation examples are executable tests and belong in the test pipeline. Use consistent script roles such as test:unit, test:integration, test:bundle, and test:jsdoc; test runs every category the project has.

A published library should ship collocated unit tests with its TypeScript source while excluding them from compiled output. A published test is self-contained: it may import Node.js built-ins, adjacent source, declared package dependencies, and shipped runner-independent helpers, but not Vitest, another separate test framework, or unpublished monorepo files.

Tests specify observable behavior. A published library versions changes to that behavior like other API changes; test filenames, organization, and assertion implementation remain internal. Avoid asserting irrelevant details such as an exact stack trace, private helper interaction, or unobservable scheduling choice.

Assertions and type tests

Prefer Evolu assertions for predictable equality semantics and domain-specific narrowing such as assertOk and assertErr. Use node:assert/strict only when a test needs exact Node.js assertion semantics or an assertion Evolu does not provide.

The Assert module documents the available helpers, their equality semantics, and how to use assertions in production code, examples, and tests.

Runtime tests do not replace type-checking. Keep TypeScript contracts beside the runtime tests. Use assertType<Actual, Expected>() for exact inference, satisfies for one-way assignability, and a precise @ts-expect-error for a rejected program. Include type-checking in the test pipeline.

Documentation examples must explicitly import their assertions from Evolu so they remain standalone and portable. They do not use assertions injected by a test harness.

Test through dependencies

Test Evolu code by making dependencies explicit and passing deterministic test implementations. You do not need a dependency injection container or module mocks: dependencies are ordinary values, so a test can construct exactly what the unit needs.

Define the smallest dependency interface that production code needs. In a test, provide a small implementation of that interface and assert the result and any observable calls.

import {
  assertEqual,
  assertErr,
  assertOk,
  err,
  ok,
  type Result,
} from "@evolu/common";
import { test } from "node:test";

interface UserNotFoundError {
  readonly type: "UserNotFoundError";
  readonly id: string;
}

interface Users {
  readonly findName: (id: string) => Result<string, UserNotFoundError>;
}

interface UsersDep {
  readonly users: Users;
}

const greetUser =
  (deps: UsersDep) =>
  (id: string): Result<string, UserNotFoundError> => {
    const name = deps.users.findName(id);
    if (!name.ok) return name;

    return ok(`Hello, ${name.value}`);
  };

test("greets an existing user", () => {
  const requestedIds: Array<string> = [];
  const deps: UsersDep = {
    users: {
      findName: (id) => {
        requestedIds.push(id);
        return ok("Ada");
      },
    },
  };

  const result = greetUser(deps)("user-1");

  assertOk(result, "Hello, Ada");
  assertEqual(requestedIds, ["user-1"]);
});

test("preserves a domain error", () => {
  const error: UserNotFoundError = {
    type: "UserNotFoundError",
    id: "missing",
  };
  const deps: UsersDep = {
    users: { findName: () => err(error) },
  };

  assertErr(greetUser(deps)("missing"), error);
});

This tests the unit's contract without replacing imported modules. If several tests need the same arrangement, extract a local setupFoo helper that returns fresh dependencies and any state the test needs to inspect.

A caller may pass more dependencies than a function requires, but the function should declare only the dependencies it actually uses. A broad test deps object is not a reason to broaden a production function's dependency type.

See Dependency injection for the production conventions behind this pattern.

Test Tasks

Use testCreateRun instead of createRun in Task tests. It creates a root Run with deterministic, controllable default dependencies and merges in the custom dependencies passed by the test.

import {
  assertErr,
  assertOk,
  err,
  ok,
  testCreateRun,
  type Result,
  type Task,
} from "@evolu/common";
import { test } from "node:test";

interface UserNotFoundError {
  readonly type: "UserNotFoundError";
}

interface Users {
  readonly findName: () => Result<string, UserNotFoundError>;
}

interface UsersDep {
  readonly users: Users;
}

const greetUser: Task<string, UserNotFoundError, UsersDep> = (run) => {
  const name = run.deps.users.findName();
  if (!name.ok) return name;

  return ok(`Hello, ${name.value}`);
};

test("runs a Task with test dependencies", async () => {
  await using run = testCreateRun({
    users: { findName: () => ok("Ada") },
  });

  assertOk(await run(greetUser), "Hello, Ada");
});

test("asserts a Task error", async () => {
  const error: UserNotFoundError = { type: "UserNotFoundError" };
  await using run = testCreateRun({
    users: { findName: () => err(error) },
  });

  assertErr(await run(greetUser), error);
});

Create the Run inside each test and dispose it with await using. Run Tasks with run(task), never task(run). Use run.ok(task) for test fixtures or internal setup Tasks whose error type is never; when an error is behavior under test, assert the returned Result instead.

Deterministic default dependencies

Use testCreateDeps for synchronous code that needs Evolu's default dependencies. Use testCreateRun for Tasks. Each call creates independent state.

DependencyTest behavior and controls
timeStarts at zero and advances only when time.advance(duration) is called.
random, randomBytes, randomLibProduce deterministic values from the default "evolu" seed or a seed passed to testCreateDeps.
consoleCaptures entries for console.getEntriesSnapshot() instead of writing them.
leakDetectorExposes collect() and getTrackedCount() so collection is explicit.
reportDefectRecords defects and exposes next(), getDefects(), and getDefectsSnapshot().
nativeFetchThrows until the test provides a fetch implementation, so unexpected network access fails immediately.

Controllable time makes timing tests instant and independent of the wall clock:

import { assertOk, sleep, testCreateRun } from "@evolu/common";
import { test } from "node:test";

test("sleeps without waiting for real time", async () => {
  await using run = testCreateRun();

  const fiber = run(sleep("1s"));
  run.deps.time.advance("1s");

  assertOk(await fiber, undefined);
});

Override only the boundary relevant to the test. For example, testCreateNativeFetch provides a queued, inspectable fetch implementation:

import {
  assertLength,
  assertOk,
  ok,
  testCreateNativeFetch,
  testCreateRun,
  type Task,
} from "@evolu/common";
import { test } from "node:test";

const loadGreeting: Task<string> = async (run) => {
  const response = await run.deps.nativeFetch("https://example.com/greeting");
  return ok(await response.text());
};

test("loads a greeting", async () => {
  const nativeFetch = testCreateNativeFetch(() => new Response("Hello, Ada"));
  await using run = testCreateRun({ nativeFetch });

  assertOk(await run(loadGreeting), "Hello, Ada");
  assertLength(nativeFetch.calls, 1);
});

Stable test data

Use testCreateId when fixtures need valid, deterministic IDs. It returns a test-local ID factory whose successive calls produce distinct IDs. Recreating the factory replays the same sequence.

import {
  assertEqual,
  assertNotEqual,
  testCreateId,
  type Brand,
  type Id,
} from "@evolu/common";
import { test } from "node:test";

test("creates stable branded IDs", () => {
  const createId = testCreateId();
  const firstTodoId = createId<"Todo">();
  const secondTodoId = createId<"Todo">();

  const replayCreateId = testCreateId();
  const todoId: Id & Brand<"Todo"> = firstTodoId;

  assertEqual(replayCreateId<"Todo">(), todoId);
  assertNotEqual(secondTodoId, firstTodoId);
});

Create the ID factory per test or per setupFoo helper. Do not share one across an entire test file: adding an ID in one test would shift the sequence used by later tests.

Find the right test helper

Test helpers are normally colocated with the API they replace, which makes them easy to discover next to the production implementation:

The Test module is reserved for general helpers shared by unrelated modules and helpers whose natural colocation would create dependency cycles. Test helpers exported by library modules use the testX prefix. Reusable helpers local to a test suite use setupX.

Whether a test uses a test double or a real platform implementation, keep its resources local to the test and release them with using or await using. See Resource management for ownership and cleanup patterns.