@rcompat/test
v0.14.0
Published
Standard library testing
Readme
@rcompat/test
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 probyEquality 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 function throws
assert(() => {
throw new Error("oops");
}).throws("oops");
// Check specific error type
assert(() => {
throw new TypeError("invalid input");
}).throws(TypeError);
});
test.case("tries (does not throw)", assert => {
assert(() => {
return 42;
}).tries();
});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.
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: "[email protected]" }),
});
// 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.
API Reference
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.
| Parameter | Type | Description |
| --------- | ---------- | ---------------------------------------------------------- |
| factory | function | Returns extra methods to attach to each Assert instance |
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) | Inclusion check (string, array, object) |
| 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 |
| 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 equals from "@rcompat/test/equals";
equals(a: unknown, b: unknown): boolean;Deep equality check supporting primitives, objects, arrays, maps, sets, dates.
any
import any from "@rcompat/test/any";
any(value: unknown): never;Cast any value to never type for testing purposes.
undef
import undef from "@rcompat/test/undef";
const value: never = undef;Pre-cast undefined value typed as never.
E
import E from "@rcompat/test/E";
E(error: unknown): { message: string };Extract error data from unknown error types.
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 | ✓ |
No configuration required — just import and use.
License
MIT
Contributing
See CONTRIBUTING.md in the repository root.
