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.
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
Dateperformance.nowTemporal.NowIntl.DateTimeFormat's "now"setImmediate,requestAnimationFrameandrequestIdleCallback, where they exist
To replace only some of it, pass toFake; to keep some real, pass toNotFake:
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:
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:
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:
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:
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:
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:
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:
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()ormock.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 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.