Browse packages

Package · v0.20.1

test

Standard library testing

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#

bash
npm install @rcompat/test
bash
pnpm add @rcompat/test
bash
yarn add @rcompat/test
bash
bun add @rcompat/test

Usage#

Writing tests#

Create a .spec.ts or .spec.js file:

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

bash
npx proby

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

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

ts
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#

js
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#

js
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#

js
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#

js
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#

ts
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#

js
import test from "@rcompat/test";

test.case("async operations", async assert => {
  const result = await Promise.resolve(42);
  assert(result).equals(42);
});

Cleanup with ended#

js
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.

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

bash
npx proby math.spec.ts addition

Mocking modules#

Use test.mock to replace a module's exports and test.import to import the mocked module.

js
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.ts pairs with foo.mock.ts
  • foo.spec.js pairs with foo.mock.js

proby loads the mock file before the spec file, so the spec's static imports see the mocked module immediately.

ts
// math.ts
export function add(a: number, b: number) {
  return a + b;
}
ts
// math.mock.ts
import test from "@rcompat/test";

test.mock("./math.ts", () => ({
  add: (a: number, b: number) => 99,
}));
ts
// 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.

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

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

ts
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.

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

js
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.

js
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.

js
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(...).

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

ts
import type {
  Asserter,
  Env,
  ExtendedTest,
  Repository,
  Result,
  Test,
  TestRun,
} from "@rcompat/test";

test.case#

ts
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#

ts
test.ended(callback: () => void | Promise<void>): void;

Register a cleanup callback to run after all tests in the file.

test.group#

ts
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#

ts
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#

ts
test.import(specifier: string): Promise<unknown>;

Import a module after mocks have been registered.

test.intercept#

ts
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#

ts
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#

ts
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#

ts
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#

ts
import test from "@rcompat/test";

test.equals(a: unknown, b: unknown): boolean;

Deep equality check supporting primitives, objects, arrays, maps, sets, dates.

includes#

ts
import test from "@rcompat/test";

test.includes(value: unknown, subset: unknown): boolean;

Deep inclusion check supporting strings, objects, arrays, maps, sets, and dates.

any#

ts
import test from "@rcompat/test";

test.any(value: unknown): never;

Cast any value to never type for testing purposes.

undef#

ts
import test from "@rcompat/test";

const value: never = test.undef;

Pre-cast undefined value typed as never.

E#

ts
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#

js
// 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#

js
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#

js
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#

js
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.