Testing library for JavaScript runtimes.
What is @rcompat/test?#
A cross-runtime testing library with a fluent assertion API. Provides deep
equality checks, type assertions, exception testing, fetch interception, and
module mocking. Designed to work with the proby test runner. Works
consistently across Node, Deno, and Bun.
Installation#
npm install @rcompat/testpnpm add @rcompat/testyarn add @rcompat/testbun add @rcompat/testUsage#
Writing tests#
Create a .spec.ts or .spec.js file:
import test from "@rcompat/test";
test.case("addition works", assert => {
assert(1 + 1).equals(2);
});
test.case("string concatenation", assert => {
assert("hello" + " " + "world").equals("hello world");
});Running tests#
Use the proby test runner:
npx probyConfiguring test discovery#
Test tools share the neutral spec.config.ts and spec.config.js contract.
Define a configuration with the identity helper on the default test aggregate:
import test from "@rcompat/test";
export default test.config({
monorepo: true,
packages: "packages",
include: ["src", "test"],
loaders: ["loader.ts"],
});The helper only provides typing and returns the same object; it does not register or execute tests. A type-only plain-object form is equivalent:
import type { TestConfig } from "@rcompat/test";
export default {
monorepo: true,
} satisfies TestConfig;monorepo defaults to false, packages to "packages", include to
["src", "test"], and loaders to []. Package and loader paths are relative
to the project root. Include paths are relative to the project root, or to each
selected package directory in monorepo mode.
Proby also accepts legacy proby.config.ts and proby.config.js files and its
existing config(...) helper. Keep exactly one supported configuration file;
coexisting new or legacy names are rejected as ambiguous.
Equality assertions#
import test from "@rcompat/test";
test.case("equals", assert => {
// primitives
assert(42).equals(42);
assert("hello").equals("hello");
assert(true).equals(true);
// objects (deep equality)
assert({ a: 1, b: 2 }).equals({ a: 1, b: 2 });
assert([1, 2, 3]).equals([1, 2, 3]);
// nested structures
assert({ users: [{ name: "Alice" }] }).equals({
users: [{ name: "Alice" }],
});
});
test.case("nequals", assert => {
assert(1).nequals(2);
assert("foo").nequals("bar");
assert({ a: 1 }).nequals({ a: 2 });
});Boolean assertions#
import test from "@rcompat/test";
test.case("boolean checks", assert => {
assert(1 === 1).true();
assert(1 === 2).false();
assert(null).null();
assert(undefined).undefined();
});Instance assertions#
import test from "@rcompat/test";
test.case("instanceof", assert => {
assert(new Date()).instance(Date);
assert(new Map()).instance(Map);
assert([1, 2, 3]).instance(Array);
});Exception assertions#
import test from "@rcompat/test";
test.case("throws", assert => {
// check that any error is thrown
assert(() => {
throw new Error("oops");
}).throws();
// check a specific error code
assert(() => {
throw Object.assign(new Error("oops"), { code: "oops" });
}).throws("oops");
// Check specific error type
assert(() => {
throw new TypeError("invalid input");
}).throws(TypeError);
// check a specific error message
assert(() => {
throw new Error("invalid input");
}).throws("invalid input");
});
test.case("throws asynchronously", async assert => {
await assert(async () => {
throw new TypeError("invalid input");
}).throws(TypeError);
});
test.case("tries (does not throw)", assert => {
assert(() => {
return 42;
}).tries();
});
test.case("tries asynchronously", async assert => {
await assert(async () => {
await save();
}).tries();
});Asynchronous throws() and tries() assertions must be awaited.
Type assertions#
import test from "@rcompat/test";
test.case("type checking", assert => {
// compile-time type checks
assert<string>().type<string>();
assert("hello").type<string>();
// check types don't match
assert<string>().nottype<number>();
assert(42).nottype<string>();
// literal types
assert<"foo">().type<"foo">();
assert<"foo">().nottype<"bar">();
});Async tests#
import test from "@rcompat/test";
test.case("async operations", async assert => {
const result = await Promise.resolve(42);
assert(result).equals(42);
});Cleanup with ended#
import test from "@rcompat/test";
test.case("database test", async assert => {
const db = await openDatabase();
const user = await db.createUser({ name: "Alice" });
assert(user.name).equals("Alice");
});
test.ended(async () => {
// runs after all tests in this file
await closeDatabase();
});Grouping tests#
Use test.group to cluster related test cases together. Groups can be run
individually via proby.
import test from "@rcompat/test";
test.group("addition", () => {
test.case("integers", assert => {
assert(1 + 1).equals(2);
});
test.case("floats", assert => {
assert(0.1 + 0.2).equals(0.3);
});
});
test.group("subtraction", () => {
test.case("integers", assert => {
assert(3 - 1).equals(2);
});
});Run a specific group:
npx proby math.spec.ts additionMocking modules#
Use test.mock to replace a module's exports and test.import to import the
mocked module.
import test from "@rcompat/test";
using math = test.mock("./math.ts", () => ({
add: (a, b) => 99,
}));
const { add } = await test.import("./math.ts");
test.case("returns mocked value", assert => {
assert(add(1, 2)).equals(99);
});
test.case("tracks calls", assert => {
add(1, 2);
assert(math.add.calls.length).equals(1);
assert(math.add.calls[0]).equals([1, 2]);
assert(math.add.called).true();
});Tracked mock functions record each call as a tuple of arguments in calls.
Call tracking resets between test cases.
Static mocks with proby#
When running tests with proby, you can preload mocks before a spec file is
evaluated by adding a sibling mock file.
foo.spec.tspairs withfoo.mock.tsfoo.spec.jspairs withfoo.mock.js
proby loads the mock file before the spec file, so the spec's static imports
see the mocked module immediately.
// math.ts
export function add(a: number, b: number) {
return a + b;
}// math.mock.ts
import test from "@rcompat/test";
test.mock("./math.ts", () => ({
add: (a: number, b: number) => 99,
}));// math.spec.ts
import test from "@rcompat/test";
import { add } from "./math.ts";
test.case("static mock is loaded before the spec", assert => {
assert(add(1, 2)).equals(99);
});Static mocks are file-scoped when run through proby; they do not leak into
later spec files. Module mocking currently requires Node.js
module.registerHooks; other runtimes throw a descriptive unsupported error.
Spying on functions#
Use spy to wrap a function and record every call made to it. The wrapped
function behaves identically to the original but tracks its arguments.
import test from "@rcompat/test";
const add = (a: number, b: number) => a + b;
const tracked = test.spy(add);
tracked(1, 2);
tracked(3, 4);
tracked.called; // true
tracked.calls; // [[1, 2], [3, 4]]
tracked.calls[0]; // [1, 2]Use spy inside a test case to make call-tracking assertions:
import test from "@rcompat/test";
test.case("tracks calls", assert => {
const tracked = test.spy((a: number, b: number) => a + b);
assert(tracked.called).false();
assert(tracked.calls).equals([]);
tracked(1, 2);
assert(tracked.called).true();
assert(tracked.calls).equals([[1, 2]]);
assert(tracked(3, 4)).equals(7);
});Pass a second argument to replace the implementation while still tracking calls:
import test from "@rcompat/test";
test.case("mocks implementation", assert => {
const tracked = test.spy(
(a: number, b: number) => a + b,
(a: number, b: number) => a * b,
);
assert(tracked(2, 3)).equals(6); // mocker runs, not original
assert(tracked.calls).equals([[2, 3]]);
});Intercepting fetch#
Use test.intercept to block outbound fetch calls to a specific origin and
replace them with fake responses. Calls to other origins pass through
untouched. The intercept records every request so you can assert on what was
called and how.
import test from "@rcompat/test";
using telegram = test.intercept("https://api.telegram.org", setup => {
setup.post("/sendMessage", () => ({
ok: true,
result: { message_id: 42 },
}));
});
test.case("notifies user via telegram on signup", async assert => {
await fetch("http://localhost:6161/signup", {
method: "POST",
body: JSON.stringify({ email: "foo@bar.com" }),
});
// assert the path was hit the right number of times
assert(telegram.calls("/sendMessage")).equals(1);
// assert on the actual request that came in
assert(telegram.requests("/sendMessage")[0].method).equals("POST");
});using restores the original fetch automatically when the file scope exits.
For long-lived intercepts that need manual control, use restore() with
test.ended:
const telegram = test.intercept("https://api.telegram.org", setup => {
setup.post("/sendMessage", () => ({ ok: true, result: { message_id: 42 } }));
});
test.case("first case", async assert => {
// ...
});
test.case("second case", async assert => {
// ...
});
test.ended(() => telegram.restore());Hitting a path on an intercepted origin that has no registered handler throws immediately, catching accidental unhandled calls early.
Extending the asserter#
Use test.extend to attach custom assertion methods to the asserter. Useful
for domain-specific assertions shared across many test cases.
import test from "@rcompat/test";
// create an extended test with custom assertions
const myTest = test.extend((assert, subject) => ({
even() {
const passed = subject % 2 === 0;
// use the base asserter to report the result
assert(passed).true();
return this;
},
}));
myTest.case("even numbers", assert => {
assert(2).even();
assert(4).even();
});The factory receives the base assert function and the current subject
(the value passed to assert()). Return an object whose methods will be
mixed into every Assert instance for that test.
The extended test keeps the full base surface — group, mock, spy,
import, and intercept all keep working on it, so you can group, mock, and
intercept exactly as you would on test itself.
import test from "@rcompat/test";
const myTest = test.extend((assert, subject) => ({
even() {
assert(subject % 2 === 0).true();
return this;
},
}));
myTest.group("arithmetic", () => {
myTest.case("even numbers", assert => {
assert(2).even();
assert(4).even();
});
});An extended test is itself extendable: chain test.extend(...) calls to
compose assertions. Later extend calls win on overlapping keys, just as if
you had merged the factories into a single test.extend(...).
import test from "@rcompat/test";
const chained = test.extend((assert, subject) => ({
even() {
assert(subject % 2 === 0).true();
return this;
},
})).extend((assert, subject) => ({
odd() {
assert(subject % 2 === 1).true();
return this;
},
}));
chained.case("parity", assert => {
assert(2).even();
assert(3).odd();
});API Reference#
The assertion and runner contracts are available as type-only exports:
import type {
Asserter,
Env,
ExtendedTest,
Repository,
Result,
Test,
TestRun,
} from "@rcompat/test";test.case#
test.case(name: string, body: (assert: Asserter) => void | Promise<void>): void;Define a test case.
| Parameter | Type | Description |
|---|---|---|
name |
string |
Test case name |
body |
function |
Test function receiving assert helper |
test.ended#
test.ended(callback: () => void | Promise<void>): void;Register a cleanup callback to run after all tests in the file.
test.group#
test.group(name: string, fn: () => void): void;Group test cases under a named scope. Groups can be targeted individually when running proby.
| Parameter | Type | Description |
|---|---|---|
name |
string |
Group name, used by proby to filter |
fn |
function |
Function containing test.case calls |
test.mock#
test.mock<T extends object>(
specifier: string,
factory: (original: unknown) => T,
): MockHandle<T>;Register a module mock and return a handle to the tracked mocked exports.
Function exports are wrapped so you can inspect calls and called.
| Parameter | Type | Description |
|---|---|---|
specifier |
string |
Module specifier to mock |
factory |
function |
Returns the mocked exports for that module |
test.import#
test.import(specifier: string): Promise<unknown>;Import a module after mocks have been registered.
test.intercept#
test.intercept(
base_url: string,
setup: (setup: Setup) => void
): Intercept;Intercept outbound fetch calls to base_url. Returns an Intercept object
for asserting on recorded requests.
| Parameter | Type | Description |
|---|---|---|
base_url |
string |
Origin to intercept, e.g. "https://api.example.com" |
setup |
function |
Register route handlers on the setup object |
test.extend#
test.extend<Subject, Extensions>(
factory: (assert: Asserter, subject: Subject) => Extensions
): ExtendedTest<Extensions>;Create a new test object with custom assertion methods mixed into the
asserter. The returned ExtendedTest keeps the full base surface
(group, mock, spy, import, intercept) and is itself extendable, so
test.extend(...) calls can be chained to compose assertions.
| Parameter | Type | Description |
|---|---|---|
factory |
function |
Returns extra methods to attach to each Assert instance |
test.spy#
test.spy<F extends (...args: any[]) => any>(fn: F, mocker?: F): Tracked<F>;Wrap a function to track calls. Returns the wrapped function with two extra properties.
| Parameter | Type | Description |
|---|---|---|
fn |
F |
The function to wrap |
mocker |
F |
Optional replacement implementation |
Tracked<F>#
| Property | Type | Description |
|---|---|---|
calls |
Parameters<F>[] |
Array of argument tuples, one per call |
called |
boolean |
true if the function has been called |
test.repository#
The shared repository used by proby to register and execute suites. It is
exposed for test-runner integration; ordinary tests should use test.case,
test.group, and test.ended instead.
Setup#
| Method | Description |
|---|---|
get(path, handler) |
Register a GET handler |
post(path, handler) |
Register a POST handler |
put(path, handler) |
Register a PUT handler |
patch(path, handler) |
Register a PATCH handler |
delete(path, handler) |
Register a DELETE handler |
Each handler receives the incoming Request and returns a plain object,
which is serialized into a Response automatically.
Intercept#
| Method | Description |
|---|---|
calls(path) |
Number of times path was hit |
requests(path) |
Array of Request objects recorded for path |
restore() |
Reinstate the original globalThis.fetch |
[Symbol.dispose] |
Called automatically by using |
Asserter#
type Asserter = <T>(actual?: T) => Assert<T>;The assert function passed to test cases.
Assert<T>#
| Method | Description |
|---|---|
equals(expected) |
Deep equality check |
nequals(expected) |
Deep inequality check |
includes(expected) |
String substring or array member check |
true() |
Assert value is true |
false() |
Assert value is false |
null() |
Assert value is null |
undefined() |
Assert value is undefined |
defined() |
Assert value is not undefined |
instance(constructor) |
Assert value is instance of class |
throws(expected?) |
Assert function throws; optionally match its type, code, or message |
tries() |
Assert function does not throw |
not |
Negate the next assertion |
type<T>() |
Compile-time type assertion |
nottype<T>() |
Compile-time negative type assertion |
pass() |
Manually pass the assertion |
fail(reason?) |
Manually fail the assertion |
Utilities#
equals#
import test from "@rcompat/test";
test.equals(a: unknown, b: unknown): boolean;Deep equality check supporting primitives, objects, arrays, maps, sets, dates.
includes#
import test from "@rcompat/test";
test.includes(value: unknown, subset: unknown): boolean;Deep inclusion check supporting strings, objects, arrays, maps, sets, and dates.
any#
import test from "@rcompat/test";
test.any(value: unknown): never;Cast any value to never type for testing purposes.
undef#
import test from "@rcompat/test";
const value: never = test.undef;Pre-cast undefined value typed as never.
E#
import test from "@rcompat/test";
test.E(error: unknown): { code?: string; message: string };Casts an unknown caught value to the error shape used in tests. This helper does not perform runtime validation.
Examples#
Testing a utility function#
// sum.ts
export default (a, b) => a + b;
// sum.spec.ts
import test from "@rcompat/test";
import sum from "./sum.js";
test.case("sum adds two numbers", assert => {
assert(sum(1, 2)).equals(3);
assert(sum(-1, 1)).equals(0);
assert(sum(0, 0)).equals(0);
});Testing a class#
import test from "@rcompat/test";
class Calculator {
#value = 0;
add(n) { this.#value += n; return this; }
subtract(n) { this.#value -= n; return this; }
get value() { return this.#value; }
}
test.case("calculator operations", assert => {
const calc = new Calculator();
calc.add(10).subtract(3);
assert(calc.value).equals(7);
});
test.case("calculator is instance", assert => {
assert(new Calculator()).instance(Calculator);
});Testing error handling#
import test from "@rcompat/test";
function divide(a, b) {
if (b === 0) throw new Error("Division by zero");
return a / b;
}
test.case("divide throws on zero", assert => {
assert(() => divide(10, 0)).throws("Division by zero");
});
test.case("divide works normally", assert => {
assert(() => divide(10, 2)).tries();
assert(divide(10, 2)).equals(5);
});Testing code that calls external APIs#
import test from "@rcompat/test";
using openai = test.intercept("https://api.openai.com", setup => {
setup.post("/v1/chat/completions", () => ({
choices: [{ message: { content: "Hello!" } }],
}));
});
test.case("generates a reply", async assert => {
const reply = await myService.generateReply("hi");
assert(reply).equals("Hello!");
assert(openai.calls("/v1/chat/completions")).equals(1);
});Cross-Runtime Compatibility#
| Runtime | Supported |
|---|---|
| Node.js | ✓ |
| Deno | ✓ |
| Bun | ✓ |
Module mocking is currently available only on Node.js. Test registration, assertions, spies, fetch interception, and dynamic imports work on all listed runtimes.
No configuration required — just import and use.
License#
MIT
Contributing#
See CONTRIBUTING.md in the repository root.