Skip to content

Spies and Mocks

Unrift ships a built-in spy/mock primitive plus convenience helpers for auto-mocking objects and classes, stubbing arbitrary properties, and asserting calls with expect.

Everything lives under a single mock namespace, with named exports for the most-used pieces:

import { mock, spy, spyOn, expect } from "unrift";

spy() (or mock.fn()) returns a callable that records every invocation. Without an implementation it returns undefined; pass one to control the return value.

import { spy } from "unrift";
const fn = spy();
fn("a", "b");
expect(fn).toHaveBeenCalledWith("a", "b");
const greet = spy((name: string) => `hi ${name}`);
expect(greet("alice")).toBe("hi alice");

The spy records every call in fn.mock:

PropertyDescription
mock.callsArray of argument arrays, in invocation order
mock.results{ type: "return" | "throw", value } per call
mock.lastCallShortcut for the most recent argument list
mock.contextsThe this binding for each call
mock.instancesInstances produced when the spy was invoked with new

The implementation can be controlled three ways. Permanent overrides win until cleared; “once” variants form a FIFO queue and are consumed first.

const fn = spy<() => string>();
fn.mockReturnValue("default");
fn.mockReturnValueOnce("first");
fn.mockReturnValueOnce("second");
fn(); // → "first"
fn(); // → "second"
fn(); // → "default"

For async code:

const fetchUser = spy<() => Promise<{ id: number }>>();
fetchUser.mockResolvedValue({ id: 1 });
fetchUser.mockRejectedValueOnce(new Error("transient"));

For full control:

const fn = spy<(x: number) => number>();
fn.mockImplementation((x) => x * 10);
fn.mockImplementationOnce(() => 0);

spyOn(obj, method) replaces obj.method with a spy that records calls and passes through to the original by default. Override via .mockReturnValue / .mockImplementation; restore via .mockRestore() or mock.restoreAll().

import { spyOn } from "unrift";
const service = {
greet(name: string) {
return `hello ${name}`;
},
};
const greetSpy = spyOn(service, "greet");
service.greet("world");
expect(greetSpy).toHaveBeenCalledWith("world");
greetSpy.mockRestore();
service.greet("world"); // → "hello world" again

The 3-arg form wraps an accessor instead of a method:

const obj = {
_value: 1,
get value() {
return this._value;
},
set value(v: number) {
this._value = v;
},
};
// Spy the getter
const getSpy = spyOn(obj, "value", "get").mockReturnValue(99);
expect(obj.value).toBe(99);
// Spy the setter - assigned values become spy call arguments
const setSpy = spyOn(obj, "value", "set");
obj.value = 42;
expect(setSpy.mock.calls).toEqual([[42]]);

spyOn walks the prototype chain to find the descriptor, so spying on a class’s getter at the prototype level applies to every instance:

class Holder {
get x() { return 1; }
}
spyOn(Holder.prototype, "x", "get").mockReturnValue(99);
new Holder().x; // → 99

For test doubles that replace every method at once:

import { mock } from "unrift";
const realApi = {
getUser: () => ({ id: 1 }),
deleteUser: () => true,
};
mock.object(realApi);
realApi.getUser();
expect(realApi.getUser).toHaveBeenCalled();
expect(realApi.deleteUser).not.toHaveBeenCalled();

mock.object() walks own and prototype methods (stopping at Object.prototype / Function.prototype), so it works on class instances too. Non-function properties are left untouched. Constructor wrapping for classes:

class Service {
static factory() {
return new Service();
}
run() {
return "real";
}
}
const MockedService = mock.class(Service);
const a = new MockedService();
const b = new MockedService();
a.run();
expect(a.run).toHaveBeenCalledTimes(1);
expect(b.run).toHaveBeenCalledTimes(0); // per-instance spies - not shared
expect(a instanceof Service).toBe(true);

Instance methods are spied as own properties on each instance, so call state is per-instance and the class prototype is never modified - un-mocked instances of the same class are unaffected, and there’s nothing to restore. Repeated spyOn on an already-spied method returns the existing spy rather than double-wrapping it, and re-stubbing a stubbed property keeps the original value for restore.

mock.stub(target, key, value) replaces any property - function or non-function - and returns a restore function. mock.global(key, value) is shorthand for mock.stub(globalThis, key, value).

const restore = mock.stub(process, "platform", "linux");
expect(process.platform).toBe("linux");
restore();
mock.global("fetch", spy().mockResolvedValue(new Response("mocked")));

Both forms participate in mock.restoreAll().

The full matcher set, all of which require received to be a spy/mock:

MatcherPass condition
.toHaveBeenCalled()Spy was called at least once
.toHaveBeenCalledTimes(n)Spy was called exactly n times
.toHaveBeenCalledWith(...args)Some call’s args deeply equal args
.toHaveBeenLastCalledWith(...args)The most recent call deeply equals args
.toHaveBeenNthCalledWith(n, ...args)The nth call (1-indexed) deeply equals args
.toHaveReturned()At least one call returned (didn’t throw)
.toHaveReturnedTimes(n)Exactly n calls returned (excluding throws)
.toHaveReturnedWith(value)A returned value deeply equals value
.toHaveLastReturnedWith(value)The most recent call returned a value deeply equal to value
.toHaveNthReturnedWith(n, value)The nth call (1-indexed) returned a value deeply equal to value

For mock.fetch specifically there are two additional matchers - see Request Mocking.

All matchers support .not:

expect(fn).not.toHaveBeenCalledWith("forbidden");

Passing a non-spy throws a clear error explaining the matcher needs a spy or mock.

CallEffect
spy.mockClear()Wipe call state, keep the implementation
spy.mockReset()Wipe state and remove all configured implementations
spy.mockRestore()For spyOn only - restore the original method on the host object
mock.clearAll()mockClear every active spy
mock.resetAll()mockReset every active spy
mock.restoreAll()Restore every spyOn/stub/fetch/useFakeTimers/doMock registration, then reset every spy

The idiomatic cleanup pattern is afterEach:

import { afterEach, mock } from "unrift";
afterEach(() => {
mock.restoreAll();
});
  • spy() - standalone callable for tests that don’t have an object to spy on (e.g. callback invocation count).
  • spyOn(obj, method) - assert that real code called a specific method, optionally pass through to the real implementation.
  • mock.object / mock.class - replace an entire dependency at once, then customize individual methods with .mockReturnValue.
  • mock.stub / mock.global - swap non-function values or temporarily set globals like process.env.NODE_ENV or globalThis.fetch.