Mocks and fake timers

mock and clock stand in for something real: a function or module you want to assert about, and code that waits.

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

test("gives up after a minute", async () => {
  const send = mock.fn().mockRejectedValueOnce(new Error("nope"));
  clock.freeze();

  const gone = deliver(send);
  await clock.advanceAsync(60_000);

  expect(send).toHaveBeenCalledTimes(2);
  expect(gone).resolves.toBe(false);
  clock.release();
});
mock.fn(impl?)Records mock.calls, mock.results; mockReturnValue, mockResolvedValue, mockThrow, mockImplementation, each with a …Once
mock.spyOn(o, k)Replaces a method and still calls the original; mockRestore() puts it back
mock.global(name, v)Replaces a global for the file
mock.env(name, v)Sets an environment variable — see below
mock.when(spy)Answers by argument — see below
mock.module(spec, factory)Replaces a module — see below
mock.restoreAll()Every spy's method, replaced global and environment variable, back
clock.freeze(at?)Stops time — timers, Date, performance.now, Temporal.Now
clock.advance(ms)Moves it, running what comes due
clock.advanceToNextFrame()To the next animation frame
clock.advanceAsync(ms)…pausing after each callback, so an await resumes
clock.runAll() / runPending()Drain, or fire what is waiting once
clock.release()Real time again

clock.advanceAsync is the one to reach for when the code under test awaits. The synchronous form resolves the promise and returns before anything waiting on it has run, so the assertion after it sees the state from before.

Freezing the clock replaces globals for the whole process, which is safe under the default process-per-file runner. With --isolation=none, release it and restore mocks before the file ends: the next file shares the same globals. The runner drains on microtasks rather than timers, so a file that forgets clock.release() still reports.

Choosing what to fake

clock.freeze() replaces everything time-related the realm has:

  • the timers

  • Date

  • performance.now

  • Temporal.Now

  • Intl.DateTimeFormat's "now"

  • setImmediate, requestAnimationFrame and requestIdleCallback, where they exist

To replace only some of it, pass toFake; to keep some real, pass toNotFake:

TypeScript
clock.freeze({ now: "2024-01-01", toFake: ["Date"] }); // timers stay real
clock.freeze({ toNotFake: ["performance"] });          // measure real time

queueMicrotask is left alone unless toFake names it. When it is faked, the microtasks wait for clock.runMicrotasks(), or run after each timer the clock fires.

In a --dom or --browser run, clock.advanceToNextFrame() runs the next animation frame. Frames fall after the clock freezes:

TypeScript
test("fades in over one frame", () => {
  clock.freeze();
  const box = fadeIn(document.body);
  clock.advanceToNextFrame();
  expect(box).toHaveStyle({ opacity: "1" });
  clock.release();
});

Restoring a spy with using

A spy declared with using puts the method back when its block ends, even when an assertion in the block fails:

TypeScript
test("warns once", () => {
  using warn = mock.spyOn(console, "warn").mockImplementation(() => {});
  load({ legacy: true });
  expect(warn).toHaveBeenCalledOnce();
});

For one stretch of a test, withImplementation answers differently until its callback returns, or its promise settles:

TypeScript
await fetchUser.withImplementation(() => Promise.reject(new Error("down")), async () => {
  await expect(showProfile(1)).rejects.toThrow("down");
});

Answers by argument

mock.when gives a mock a different answer for each set of arguments:

TypeScript
const price = mock.fn((sku: string) => 0);

using answers = mock
  .when(price)
  .calledWith("apple")
  .thenReturn(1.2)
  .calledWith(expect.any(String))
  .thenThrow(new Error("unknown sku"), { times: 1 });

expect(price("apple")).toBe(1.2);
expect(() => price("pear")).toThrow("unknown sku");
expect(answers).toHaveBeenExhausted();

Arguments match the way toHaveBeenCalledWith matches them, asymmetric matchers included. The newest answer for matching arguments is used first; { times } or a …Once form limits it, and an older answer takes over once it is used up. A call no answer matches goes to what the mock did before, unless onUnmatched says "throw" or gives a function. The chain restores the mock's previous answer when it is disposed, as using does at the end of the block.

Environment variables

mock.env sets a variable in runtime:process's env. undefined removes it, and mock.restoreAll() puts every one back:

TypeScript
import { env } from "runtime:process";
import { afterEach, expect, mock, test } from "runtime:test";

afterEach(() => mock.restoreAll());

test("reads the port", () => {
  mock.env("PORT", "8080");
  expect(env.PORT).toBe("8080");
});

It needs the env capability.

Mocking a module

mock.module replaces a module for everything that imports it. Called at the top of a test file, it runs before that file's imports, so the code under test gets the mock too:

TypeScript
import { test, expect, mock } from "runtime:test";
import { signup } from "./signup.ts"; // imports ./mail.ts
import { send } from "./mail.ts";

mock.module("./mail.ts", async (importOriginal) => ({
  ...(await importOriginal()),
  send: mock.fn(() => "queued"),
}));

test("sends a welcome mail", () => {
  signup("ada@example.com");
  expect(send).toHaveBeenCalledWith("ada@example.com");
});

The specifier resolves the way an import in the same file would, so a relative path or a package name. The factory's object becomes the module's exports. importOriginal() loads the real module, for keeping the exports you don't replace. mock.importActual(specifier) does the same anywhere in the file.

Inside a test, a mock applies to modules loaded after it. Import the code under test dynamically once the mock is in place:

TypeScript
test("reports a mail service that is down", async () => {
  mock.module("./mail.ts", () => ({ send: () => { throw new Error("down"); } }));
  const { signup } = await import("./signup.ts");
  expect(() => signup("ada@example.com")).toThrow("down");
});

A few rules follow from how ES modules load:

  • A module that was already imported before the mock stays real.

  • The factory runs before the file's own imports, so it cannot use them. Use importOriginal() or mock.fn() instead.

  • A mock lasts until the file ends. Each file runs in its own process, so other files never see it. mock.restoreAll() does not undo it.

  • Call it as mock.module(…), not through a variable, so esdev knows which file the specifier is relative to.

Migrating from Vitest, rename vi.mock to mock.module and vi.importActual to mock.importActual. Jest's ESM jest.unstable_mockModule and Node's mock.module suites already import dynamically.

In browser runs

In --browser runs a file's modules are bundled before the page loads, so mock.module throws there. A page has no process environment, so mock.env throws too.

Last updated on
Edit this page