runtime:test

The test API, as a module you import.

esdev only

A test file is never a production artifact, so esrun does not serve this module: importing it there fails at load with unknown built-in module. Capability: none — an assertion computes and reaches nothing.

JavaScript
import { test, expect } from "runtime:test";

test("adds", () => expect(add(2, 3)).toBe(5));
test("fetches", async () => await expect(get("/")).resolves.toMatchObject({ status: 200 }));

Everything is imported, including expect, mock and clock. There is no global test and no global expect: this runtime hands out no ambient names anywhere, and a test file is not the place to make an exception — a suite written elsewhere gets an import line, which is a line, and keeps the guarantee that a program is handed only what it asked for.

The assertions were five globals until D71, prepended to every test file's own source. Importing them fixes three things at once:

  • the runtime hands out no ambient names anywhere else — everything is a runtime: module;

  • a helper module beside the test file can use the assertions, where before only the entry was wrapped;

  • they have types, so a .ts test file no longer references five undeclared names and tsc --noEmit no longer fails on a suite that runs perfectly.

Run a file with esdev test, or directly — esdev app.test.ts prints the same report, because what makes a run a test run is the module it imported.

test(name, fn)

Registers a test. fn may be async. Cases run one at a time, in the order the file wrote them, unless marked concurrent, and a test that awaits holds up the next; esdev prints the tally once the program is done and exits non-zero if anything failed.

That is deliberate, and it is a change. Tests used to start where they were written, so every async case in a file ran at once — two tests sharing a database, a temp directory, a port or a module global then interleave, and the failure is a flake nobody can reproduce. A beforeEach could not exist at all, because there was no "before": the next test had already started.

A test that never settles is reported as a failure — "the test never finished" — rather than being left out of a green run, and the cases queued behind it are reported as "never started", so the report names the one that is stuck.

test.skip and test.only

test.skip(name, fn?) registers the case and reports it as skipped without running it; the body may be left out. test.only(name, fn) runs that case and skips the rest. Both are counted, and only gets a line of its own:

  only: 27 other tests did not run
  1 passed, 0 failed, 27 skipped

Which is why they are counted at all. A skipped case that vanished from the report is the failure this runner already refuses for a case that never finished — a green run that quietly ran fewer tests than it printed — and a .only left in a commit otherwise looks exactly like a suite that got faster.

Whether a case is the exception is decided once the file has finished registering, not where it is written: a .only further down speaks for the cases above it.

Test options and test.fails

A test takes options after its body or before it: a number of milliseconds, or { timeout, retry }.

JavaScript
test("loads", async () => { … }, 5000);
test("flaky upstream", { retry: 2 }, async () => { … });
Option
timeoutFails the test if its body has not settled within this many milliseconds. The body is not stopped.
retryRuns the test again, up to this many more times, until it passes. Each attempt runs the beforeEach and afterEach hooks. Only the last attempt is reported.
repeatsRuns the test this many more times after the first, each run with its hooks. It fails if any run fails, naming the run.

test.fails(name, fn, options?) registers a test that is expected to fail. It passes while it fails, and fails once it passes. test.skip and test.only take the same options.

Concurrent tests

test.concurrent, describe.concurrent and { concurrent: true } run tests alongside each other, at most maxConcurrency (5) at once; test.sequential, describe.sequential and { concurrent: false } opt out. See Running concurrently.

expectTypeOf and assertType

Type assertions that TypeScript checks and that do nothing at run time. They fail under esdev typecheck or esdev test --typecheck. See Type tests.

Benchmarks

In *.bench.* files run by esdev bench, the context's bench(name, options?, fn) registers a benchmark; .run() measures it and bench.compare(...) measures several, resolving to results by name. b.start()/b.end() time part of a call, writeResult saves a result and bench.from(name, source) reads one back. toBeFasterThan(other, { delta }) and toBeSlowerThan compare results. See Benchmarks.

Tags

{ tags } on a test or a describe labels it; @module-tag in a JSDoc comment labels the file. test.tags in esdev.json defines them and the options each gives, --tags-filter selects by expression, and matchesTags(tags) asks the same at run time. See Tags.

The test context and test.extend

A test's body, and its beforeEach and afterEach, get a context: task, expect, skip(), onTestFinished and onTestFailed.

test.extend(name, options?, fixture) and test.extend({ ... }) return a test API whose tests also get fixtures, set up for the tests that destructure them and torn down after. scope is "test", "file" or "worker" (the same as "file"), and auto: true sets one up for every test. See Fixtures.

describe(name, body)

A group: a name that composes into its tests' — "db > constraints > rejects a null" — and, the half that matters, a scope.

JavaScript
import { test, describe, beforeAll, afterAll, beforeEach } from "runtime:test";

describe("db", () => {
  beforeAll(() => open());        // once, before this group's first test
  afterAll(() => close());        // once, after this group's last test
  beforeEach(() => reset());      // around this group's tests, and no others

  test("inserts", async () => {});

  describe("constraints", () => {
    test("rejects a null", async () => {});
  });
});

A hook written inside the body belongs to the tests inside it. Without that a group is a naming convention — which a template string already is — and a file that sets up a database for six of its twenty cases is still setting it up for the other fourteen.

describe.skip skips every test in the group; describe.only runs the group and skips everything outside it.

The body registers and returns

It is not where awaiting belongs. An async body is refused rather than half-run: only the part before its first await would register in time, and the rest would land after the queue had already drained. TypeScript will not catch it — every function is assignable where void is expected — so the refusal is at runtime, and says where to put the await instead.

Lifecycle

Function
beforeAll(fn)Once, before the first test of its scope — the file, or the describe it is written in. One that throws fails every test in that scope.
afterAll(fn)Once, after the last test of its scope — when that scope has no cases left. An inner group's runs before the outer one that set up what it is tearing down.
beforeEach(fn)Before every test in scope, outermost group first. One that throws fails that test.
afterEach(fn)After every test in scope, innermost group first, including one that failed — it is cleanup.

Each may be registered more than once and all of them run, in registration order: a helper module and the test file both have a right to a beforeEach.

JavaScript
import { test, beforeEach, afterEach, assertEquals } from "runtime:test";

let db;
beforeEach(async () => { db = await open(":memory:"); });
afterEach(() => db.close());

test("stores a row", async () => {
  await db.exec("insert into t values (1)");
  assertEquals(await db.count("t"), 1);
});

Assertions

Function
assert(condition, message?)Fails unless condition is truthy.
assertEquals(actual, expected, message?)Fails unless the two are structurally equal.
assertThrows(fn, want?, message?)Fails unless fn throws, and unless the error matches want.
assertRejects(fn, want?, message?)The async form. Returns a promise — await it.

assertEquals compares values, not their JSON

It walks them: BigInt and NaN through Object.is, typed arrays and ArrayBuffer byte by byte, Map and Set by contents, Date/RegExp/Error by what identifies them, objects by their key set rather than key order, and cycles terminate.

Stringifying was the original implementation, and on this runtime it could not express the assertion an int64 test most needs — JSON.stringify throws on a BigInt, and rendered a Uint8Array as {"0":1,"1":2}.

JavaScript
assertEquals(reader.int64(), -9223372036854775808n);
assertEquals(bytes, new Uint8Array([1, 2, 3]));
assertEquals({ a: 1, b: 2 }, { b: 2, a: 1 });

The second argument is the expectation

Not a label. An error name or a substring of its message, a RegExp over the message, or a constructor for an instanceof check. The failure label is the third argument.

JavaScript
assertThrows(() => parse(bad), TypeError);
assertThrows(() => parse(bad), "field number 0");
assertThrows(() => parse(bad), /number 0 is not/, "the parser accepted it");
await assertRejects(() => fetchIt(), "TimeoutError");

expect(value)

The ecosystem's spelling of the same assertions. assertEquals(a, b) and expect(a).toEqual(b) share one comparison — this is a second spelling, not a second implementation, and the reason to have it is that a suite written for another runner should need an import rather than a rewrite.

.not inverts any matcher. .resolves and .rejects settle a promise first and match on what came out, so a rejection is reported as one rather than as a mismatched Promise — await them.

toBeObject.is — identity, and NaN equals NaN
toEqualStructural, the assertEquals walk
toStrictEqual…also telling an undefined key from a missing one, a hole from an undefined, and a class instance from a plain object
toMatchSnapshot(name?)Matches a versioned snapshot for this test; unnamed calls count from one
toMatchFileSnapshot(name)Matches exact text or bytes in this test file's snapshot directory
await toMatchScreenshot(name?, options?)In a browser run, the element's pixels match a reference image. See Screenshots
toMatchInlineSnapshot(matchers?, snapshot?) / toThrowErrorMatchingInlineSnapshot(snapshot?)Matches the snapshot written in the call; written into the source when missing, except under --ci
toBeTruthy / toBeFalsy / toBeNull / toBeUndefined / toBeDefined / toBeNaN / toBeNullable
toBeInstanceOf / toBeTypeOf
toContain / toContainEqualA member, a substring, a Set/Map key
toHaveLength / toHavePropertytoHaveProperty("a.b", 1)
toMatch / toMatchObjectString or RegExp; a subset of keys
toBeGreaterThan / …OrEqual / toBeLessThan / …OrEqual / toBeCloseTo
toSatisfy / toBeOneOfA predicate holds; equal to one of an array
toThrow / toThrowErrorCalls it; takes the same expectation assertThrows does
toHaveBeenCalled / …Times / …With / …LastCalledWith / …NthCalledWithNeeds a mock.fn()
toHaveBeenCalledExactlyOnceWithCalled once, with these arguments
toHaveBeenCalledBefore(other) / toHaveBeenCalledAfter(other)By each mock's first call
toHaveBeenExhausted()Needs a mock.when chain: every answer used
toHaveReturned / …Times / …With / toHaveLastReturnedWith / toHaveNthReturnedWith
toHaveResolved / …Times / …With / toHaveLastResolvedWith / toHaveNthResolvedWithWhat returned promises resolved to, once settled

The shorter jest spellings — toBeCalled, toBeCalledWith, lastCalledWith, toReturnWith — are aliases of the same matchers, not variants of them.

Snapshots use deterministic printed values: primitives (including undefined, bigint, NaN, and -0), arrays and plain objects, Date, RegExp, Map, Set, byte buffers/views, Error, shared references, and cycles. Functions, symbols, promises, weak collections, getters, class instances, and host objects throw rather than creating an ambiguous snapshot, unless a serializer prints them. The matcher is not negatable and must run inside a test.

Pass an object of property matchers to mask volatile fields in stored output: expect(user).toMatchSnapshot({ id: expect.any(Number) }). assertSnapshot is the assertion spelling, and toThrowErrorMatchingSnapshot snapshots a thrown error as an [error] entry. Built-in error messages are V8 text, so an engine upgrade can legitimately change that snapshot; own code and cause properties are printed too, including Error's normally non-enumerable cause.

JavaScript
expect(list).toHaveLength(3);
expect(list).not.toContain(9);
await expect(load()).resolves.toMatchObject({ ok: true });
await expect(load()).rejects.toThrow(/timed out/);

Asymmetric matchers

A value that says what it will accept, usable wherever a value goes — including several levels inside an expected object, which is the case that cannot be written as an assertion of its own.

JavaScript
expect(row).toEqual({
  id: expect.any(Number),
  name: expect.stringContaining("ada"),
  at: expect.anything(),
});

expect.anything(), expect.any(Ctor), expect.stringContaining, expect.stringMatching, expect.arrayContaining, expect.objectContaining, expect.closeTo(n, digits?), expect.arrayOf(item), expect.schemaMatching(schema) (any Standard Schema that validates synchronously). expect.not holds the inverted stringContaining, stringMatching, arrayContaining, objectContaining, arrayOf and schemaMatching.

DOM matchers

For DOM nodes, under --dom or --browser. Each fails with a TypeError naming the matcher when given something that is not a node.

MatcherPasses when
toBeInTheDocument()The node is connected. Accepts null, for .not after a query that found nothing.
toBeVisible()Connected, and neither it nor an ancestor has hidden, display: none or opacity: 0; itself not visibility: hidden.
toBeEmptyDOMElement()No child nodes other than comments.
toContainElement(el)el is the node or a descendant of it.
toContainHTML(html)Its outerHTML contains html, as the parser writes it.
toHaveTextContent(text, { normalizeWhitespace? })textContent contains the string, or matches the RegExp. Whitespace is collapsed unless normalizeWhitespace: false.
toHaveAttribute(name, value?)The attribute is present, and equals value when given.
toHaveClass(...names, { exact? })Every class is present; exact forbids others. With no names, it has any class.
toHaveValue(value)The control's value: a number for number and range inputs (null when empty), an array for <select multiple>. Checkboxes and radios use toBeChecked.
toBeChecked()A checked checkbox or radio, or a checkable role with aria-checked="true".
toBeDisabled() / toBeEnabled()Disabled itself, by a disabled <optgroup>, or by a disabled <fieldset> outside its first <legend>.
toBeRequired()required, or aria-required="true".
toHaveFocus()It is the active element of its document or shadow root.
toHaveStyle(css)Each declaration, from a CSS string or an object of properties, equals the node's computed value. Colours compute to rgb().

expect.extend

Adds matchers. Each receives the value and the matcher's arguments and returns { pass, message }, or a promise of one. this.isNot says whether it was called through .not, and this.equals is the structural equality toEqual uses.

JavaScript
expect.extend({
  toBeWithin(received, lo, hi) {
    return {
      pass: received >= lo && received <= hi,
      message: () => `expected ${received} to be within ${lo}..${hi}`,
    };
  },
});

expect(5).toBeWithin(1, 10);
expect({ n: 3 }).toEqual({ n: expect.toBeWithin(1, 5) });

An added matcher works with .not, .resolves, .rejects, expect.soft and expect.poll, and as an asymmetric matcher: expect.name(...) and expect.not.name(...). An async matcher returns a promise to await, and cannot be used asymmetrically. A matcher with a built-in name replaces it for the file.

In TypeScript, declare added matchers by augmenting Matchers (and AsymmetricMatchers for the asymmetric form) in "runtime:test".

Equality testers and snapshot serializers

expect.addEqualityTesters(testers) decides the pairs a tester recognises, in every deep comparison, for the rest of the file. A tester returns true, false, or undefined to pass the pair on, and this.equals(a, b) compares what the pair holds.

expect.addSnapshotSerializer({ test, serialize }) prints the values test accepts in every snapshot for the rest of the file, newest first. It takes pretty-format's serialize(value, config, indentation, depth, refs, printer) or the older print(value, serialize, indent). See Custom serializers.

Counting and soft assertions

expect.assertions(n)The running test fails unless exactly n assertions ran in it.
expect.hasAssertions()The running test fails unless at least one ran.
expect.soft(value)A failed matcher is recorded and the test continues. The test fails at the end, listing every soft failure.
expect.unreachable(message?)Fails where it is reached.
expect.fail(message?)Fails the test here.

Every matcher call counts as one assertion, as do assert, assertEquals, assertThrows, assertRejects and assertSnapshot.

expect.poll and waitFor

For state that settles on its own schedule.

JavaScript
await expect.poll(() => list.children.length).toBe(3);
const row = await waitFor(() => {
  const loaded = table.querySelector("tr.loaded");
  if (!loaded) throw new Error("no loaded row yet");
  return loaded;
});

expect.poll(fn, options?) calls fn until the matcher holds for what it returns. waitFor(fn, options?) calls fn until it returns without throwing, or its promise resolves, and returns the result. Both take { timeout, interval } (1000ms and 50ms by default), throw the last failure when the time runs out, and wait on real time, so a frozen clock does not stop them.

onTestFinished and onTestFailed

Callbacks that belong to the running test. Call them in the test or in its beforeEach.

onTestFinished(fn)Runs after the test's afterEach hooks, newest first. An error it throws fails the test.
onTestFailed(fn)Runs only if the test failed, and receives the failure.

inject

inject(key) returns what a global setup module passed to provide(key, value), or undefined. Values arrive as JSON. Declare the keys on ProvidedContext to type both calls.

mock

Functions that stand in for real ones. mock.fn() records; mock.spyOn() installs a recorder over a real method and still calls the original, because a spy is usually installed to watch something work.

JavaScript
import { test, expect, mock } from "runtime:test";

test("posts once per item", async () => {
  const post = mock.spyOn(client, "post");
  await sync([1, 2]);
  expect(post).toHaveBeenCalledTimes(2);
  expect(post).toHaveBeenLastCalledWith("/items", 2);
  post.mockRestore();
});
mock.fn(impl?)A recording function
mock.spyOn(object, key)…installed over a method; mockRestore() puts it back
mock.is(value)Whether it is one
mock.global(name, value)Replaces a global for the file
mock.env(name, value)Sets an environment variable; undefined removes it
mock.when(spy, options?)Answers by argument: calledWith(…).thenReturn(…)
mock.module(specifier, factory)Replaces a module for imports that load afterwards; a promise when factory is async
mock.importActual(specifier)The real module, as a promise
mock.clearAll() / resetAll() / restoreAll()Forget the calls / the answers / everything

factory(importOriginal) returns the module's exports. Called at the top of a test file, mock.module runs before that file's imports. It must be called by name, lasts the rest of the file, and is refused in browser runs. See Mocking a module.

On the mock itself: mock.calls, mock.results, mock.instances, mock.lastCall; mockImplementation, mockReturnValue, mockReturnThis, mockResolvedValue, mockRejectedValue, mockThrow — each with a …Once — withImplementation, getMockImplementation, and mockClear, mockReset, mockRestore, mockName. using spy = mock.spyOn(…) restores it when the block ends.

A mock.when chain takes calledWith(...args) and then thenReturn, thenThrow, thenResolve or thenReject, each with { times } and a …Once. onUnmatched is "passthrough" (default), "throw" or a function. expect(chain).toHaveBeenExhausted() checks every answer was used. See Answers by argument.

Those method names are the ecosystem's, deliberately: they are the vocabulary the matchers read.

clock

Time, stopped. clock.freeze() replaces the timers, Date, performance.now, Temporal.Now and Intl.DateTimeFormat's "now" — and, where the realm has them, setImmediate, requestAnimationFrame and requestIdleCallback — and everything scheduled through them then moves only when the test says so.

JavaScript
import { test, expect, mock, clock } from "runtime:test";

test("polls every second until it answers", async () => {
  clock.freeze();
  const ask = mock.fn().mockResolvedValueOnce(null).mockResolvedValue("ok");

  const answer = poll(ask, 1000);
  await clock.advanceAsync(2000);

  expect(ask).toHaveBeenCalledTimes(2);
  await expect(answer).resolves.toBe("ok");
  clock.release();
});
clock.freeze(at?)Stops time, optionally at a moment
clock.freeze({ now, toFake, toNotFake, loopLimit })…replacing only some of it
clock.release()The real ones back
clock.advance(ms) / advanceAsync(ms)Move it
clock.advanceToNextFrame()To the next animation frame (every 16ms)
clock.next() / nextAsync()Jump to the next timer and run it
clock.runAll() / runAllAsync()Drain the queue
clock.runPending() / runPendingAsync()Fire what is waiting once — an interval does not repeat
clock.runMicrotasks()Run microtasks, when queueMicrotask is faked
clock.pending() / clock.clear()How many are waiting; drop them
clock.setSystemTime(t) / clock.realNow()

advanceAsync is the one to reach for when the code under test awaits. The synchronous form fires every callback with nothing in between, so a sleep(10).then(…) has been resolved but its continuation has not run — and the assertion after it sees the state from before.

Date moves with the clock rather than being frozen separately, because the two are one question: a stopped setTimeout beside a running Date.now() describes a machine that does not exist.

By default freeze replaces everything the realm has except queueMicrotask. toFake names the only things to replace; toNotFake names the ones to leave real. See Choosing what to fake.

It replaces globals for the whole process

Not for one test. That is safe here because a test file is a process — the swap cannot reach the next file — and because the runner drains on microtasks rather than timers, so a file that forgets clock.release() still reports.

These are standards-defined names being replaced at the test's own explicit request, which is the opposite of the runtime handing out a vocabulary.

Sharing test code

Because the assertions are imported, a helper module can use them:

JavaScript
// helpers.js
import { assertEquals } from "runtime:test";
export const assertSorted = (xs) => assertEquals(xs, [...xs].sort());

That was impossible while they were globals — the harness reached only the file being run.

Last updated on
Edit this page