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";Creating a spy
Section titled “Creating a spy”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:
| Property | Description |
|---|---|
mock.calls | Array of argument arrays, in invocation order |
mock.results | { type: "return" | "throw", value } per call |
mock.lastCall | Shortcut for the most recent argument list |
mock.contexts | The this binding for each call |
mock.instances | Instances produced when the spy was invoked with new |
Configuring return values
Section titled “Configuring return values”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);Wrapping a method with spyOn
Section titled “Wrapping a method with spyOn”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" againSpying on getters and setters
Section titled “Spying on getters and setters”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 getterconst getSpy = spyOn(obj, "value", "get").mockReturnValue(99);expect(obj.value).toBe(99);
// Spy the setter - assigned values become spy call argumentsconst 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; // → 99Auto-mocking whole objects and classes
Section titled “Auto-mocking whole objects and classes”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 sharedexpect(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.
Stubbing arbitrary properties
Section titled “Stubbing arbitrary properties”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().
Mock matchers
Section titled “Mock matchers”The full matcher set, all of which require received to be a spy/mock:
| Matcher | Pass 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.
Lifecycle: clear, reset, restore
Section titled “Lifecycle: clear, reset, restore”| Call | Effect |
|---|---|
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();});When to use which
Section titled “When to use which”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 likeprocess.env.NODE_ENVorglobalThis.fetch.