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.
| Dependency | Test behavior and controls |
|---|---|
time | Starts at zero and advances only when time.advance(duration) is called. |
random, randomBytes, randomLib | Produce deterministic values from the default "evolu" seed or a seed passed to testCreateDeps. |
console | Captures entries for console.getEntriesSnapshot() instead of writing them. |
leakDetector | Exposes collect() and getTrackedCount() so collection is explicit. |
reportDefect | Records defects and exposes next(), getDefects(), and getDefectsSnapshot(). |
nativeFetch | Throws 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:
testCreateTimeprovides a controllable clock.testCreateConsolecaptures structured console entries.testCreateNativeFetchqueues handlers and records calls.testCreateWebSocketcreates inspectable in-memory sockets.testCreateWorkerand related helpers connect both sides of an in-memory worker.testSetupSqlitecreates a disposable in-memory SQLite setup from a platform driver.
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.