Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion docs/test/mocks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,29 @@ test("spy on a getter", () => {

This also works on plain properties, such as `spyOn(process, "platform", "get")`, and on accessors inherited from a class.

### Replacing a Property with jest.replaceProperty()

Use `jest.replaceProperty()` to give a data property another value for a while. It returns a handle with `replaceValue()` to change the value again and `restore()` to put the original back. `jest.restoreAllMocks()`, the `restoreMocks` option and the end of the test file restore it too.

```ts title="test.ts" icon="/icons/typescript.svg"
import { test, expect, jest } from "bun:test";

const config = { mode: "production", retries: 1 };

test("replace a property", () => {
const mode = jest.replaceProperty(config, "mode", "test");
expect(config.mode).toBe("test");

mode.replaceValue("staging");
expect(config.mode).toBe("staging");

mode.restore();
expect(config.mode).toBe("production");
});
```

As in Jest, the property must exist and be configurable. For a method use `spyOn(object, "method")`, and for a getter or setter use `spyOn(object, "property", "get")` or `"set"`. Unlike Jest, Bun also replaces a property that is not writable, and keeps its attributes.
Comment thread
claude[bot] marked this conversation as resolved.
Resolved
Comment thread
robobun marked this conversation as resolved.
Resolved

### Classes

Calling a mock with `new` constructs its implementation, so you can mock or spy on a class the same way as a function.
Expand Down Expand Up @@ -728,7 +751,7 @@ Call `mock.restore()` in an `afterEach` block, or in your test preload script, i

### Cleanup After Each Test File

When a test file ends, Bun restores any spies that are still active and clears every mock's recorded calls. Return values and implementations set with `mockReturnValue()`, `mockImplementation()` and similar methods stay until you reset the mock. A mock in a module that several test files import keeps them from one file to the next.
When a test file ends, Bun restores any spies and replaced properties that are still active and clears every mock's recorded calls. Return values and implementations set with `mockReturnValue()`, `mockImplementation()` and similar methods stay until you reset the mock. A mock in a module that several test files import keeps them from one file to the next.

Spies and mocks made in a `--preload` script, or in one of its `beforeAll()` hooks, aren't restored or cleared. They stay the same for every test file.

Expand Down
17 changes: 17 additions & 0 deletions packages/bun-types/test.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,23 @@ declare module "bun:test" {
obj: T,
methodOrPropertyValue: K,
): Mock<Extract<T[K], (...args: any[]) => any>>;
/**
* Replaces a data property of `object` with `value`. The property must exist, be configurable, and hold
* neither a function (use `jest.spyOn(object, key)`) nor an accessor (use `jest.spyOn(object, key, "get")`).
*
* `jest.restoreAllMocks()`, the `restoreMocks` option and the end of the test file put the original back.
*
* @example
* const config = { mode: "production" };
* const mode = jest.replaceProperty(config, "mode", "test");
* mode.replaceValue("staging");
* mode.restore();
*/
function replaceProperty<T extends object, K extends keyof T>(
object: T,
propertyKey: K,
value: T[K],
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
): Replaced<T[K]>;

/**
* Constructs the type of a mock function, such as the return type of `jest.fn()`.
Expand Down
515 changes: 422 additions & 93 deletions src/jsc/bindings/JSMockFunction.cpp

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions src/jsc/bindings/JSMockFunction.h
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ class JSMockModule final {
V(Structure, mockModuleStructure) \
V(Structure, activeSpySetStructure) \
V(JSFunction, withImplementationCleanupFunction) \
V(JSFunction, replacedPropertyRestoreFunction) \
V(JSFunction, replacedPropertyReplaceValueFunction) \
V(JSC::Structure, mockWithImplementationCleanupDataStructure) \
V(JSCell, lazyPrototype) \
V(JSString, defaultName) \
Expand Down Expand Up @@ -68,6 +70,13 @@ class JSMockModule final {
// A JSMap from the promises given to didStartDynamicImport() to their keys. Settled ones get swept.
JSC::WriteBarrier<JSC::Unknown> dynamicImports;

// The mock behind each jest.replaceProperty() that is not restored yet, and its `Replaced` handle. Nothing else roots that mock.
struct ReplacedProperty {
JSC::Strong<JSC::JSObject> mock;
JSC::Strong<JSC::JSObject> handle;
};
Vector<ReplacedProperty> replacedProperties;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

// True until the first test starts. A preload, or a beforeAll() of one, can only run before that.
bool preloadMayBeRunning { true };

Expand Down
121 changes: 121 additions & 0 deletions test/cli/test/isolation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3105,6 +3105,127 @@ describe.concurrent("mocks and spies do not outlive their test file", () => {
expect({ results: results(stderr), exitCode }).toEqual({ results: ["(pass) a", "(pass) b"], exitCode: 0 });
});

const replaced = `[[process, "platform"], [shared, "value"], [Shared.prototype, "inheritedValue"], [shared, "inheritedValue"]]`;
const replacedFiles = {
"shared.ts": `
export class Shared {}
Shared.prototype.inheritedValue = "original";
export const shared = Object.assign(new Shared(), { value: "original" });
`,
"a.test.ts": `
import { expect, jest, test } from "bun:test";
import { shared, Shared } from "./shared.ts";
test("a", () => {
for (const [object, key] of ${replaced}) jest.replaceProperty(object, key, "replaced");
expect(${replaced}.map(([object, key]) => object[key])).toEqual(["replaced", "replaced", "replaced", "replaced"]);
expect(Object.hasOwn(shared, "inheritedValue")).toBe(true);
});
`,
"b.test.ts": `
import { expect, test } from "bun:test";
import { shared, Shared } from "./shared.ts";
test("b", () => {
expect(${replaced}.map(([object, key]) => object[key])).toEqual([process.env.ISOLATION_PLATFORM, "original", "original", "original"]);
expect(Object.hasOwn(shared, "inheritedValue")).toBe(false);
});
`,
};

test.each(modes)("replaced properties are restored: bun test %s", async flags => {
using dir = tempDir("replaced-of-a-file", replacedFiles);
const { stderr, exitCode } = await runTests(String(dir), flags.split(" "), ["./a.test.ts", "./b.test.ts"], {
...bunEnv,
ISOLATION_PLATFORM: process.platform,
});
expect({ results: results(stderr), exitCode }, stderr).toEqual({ results: ["(pass) a", "(pass) b"], exitCode: 0 });
});

test("replaced properties of a file that fails to load are restored", async () => {
using dir = tempDir("replaced-of-a-file", {
...replacedFiles,
"a.test.ts": `
import { jest } from "bun:test";
import { shared, Shared } from "./shared.ts";
for (const [object, key] of ${replaced}) jest.replaceProperty(object, key, "replaced");
throw new Error("thrown while loading");
`,
});
const { stderr, exitCode } = await runTests(String(dir), [], ["./a.test.ts", "./b.test.ts"], {
...bunEnv,
ISOLATION_PLATFORM: process.platform,
});
expect(stderr).toContain("error: thrown while loading");
expect({ results: results(stderr), exitCode }, stderr).toEqual({ results: ["(pass) b"], exitCode: 1 });
});

test("a replaced property that can't be restored is reported once, under the file that made it", async () => {
using dir = tempDir("replaced-of-a-file", {
"a.test.ts": `
import { jest, test } from "bun:test";
test("a", () => {
let isOver = false;
globalThis.target = new Proxy({ value: "original" }, {
defineProperty(...args) {
if (isOver) throw new Error("the trap refuses");
return Reflect.defineProperty(...args);
},
});
jest.replaceProperty(target, "value", "replaced");
isOver = true;
});
`,
"b.test.ts": `
import { expect, jest, test } from "bun:test";
test("b", () => {
expect(target.value).toBe("replaced");
jest.restoreAllMocks();
expect(target.value).toBe("replaced");
});
`,
});
const { stderr, exitCode } = await runTests(String(dir), [], ["./a.test.ts", "./b.test.ts"]);
const lines = stderr.split("\n").filter(line => /^(error: |[ab]\.test\.ts:$|\((pass|fail)\) )/.test(line));
expect(
lines.map(line => line.replace(/ \[[\d.]+ms\]$/, "")),
stderr,
).toEqual(["a.test.ts:", "(pass) a", "error: the trap refuses", "b.test.ts:", "(pass) b"]);
expect(exitCode).toBe(1);
});

test("properties replaced by a preload or its beforeAll() are kept across files", async () => {
using dir = tempDir("replaced-of-a-preload", {
"preload.ts": `
import { beforeAll, jest } from "bun:test";
globalThis.target = { loading: "original", hook: "original", file: "original" };
jest.replaceProperty(target, "loading", "replaced");
beforeAll(() => {
jest.replaceProperty(target, "hook", "replaced");
});
`,
"a.test.ts": `
import { expect, jest, test } from "bun:test";
test("a", () => {
jest.replaceProperty(target, "file", "replaced");
expect(target).toEqual({ loading: "replaced", hook: "replaced", file: "replaced" });
});
`,
"b.test.ts": `
import { expect, jest, test } from "bun:test";
test("b", () => {
expect(target).toEqual({ loading: "replaced", hook: "replaced", file: "original" });
jest.restoreAllMocks();
expect(target).toEqual({ loading: "original", hook: "original", file: "original" });
});
`,
});
const { stderr, exitCode } = await runTests(
String(dir),
["--preload", "./preload.ts"],
["./a.test.ts", "./b.test.ts"],
);
expect({ results: results(stderr), exitCode }, stderr).toEqual({ results: ["(pass) a", "(pass) b"], exitCode: 0 });
});

test("a preload's afterAll() sees the calls made in every file", async () => {
using dir = tempDir("mocks-of-a-preload", {
"preload.ts": `
Expand Down
12 changes: 12 additions & 0 deletions test/integration/bun-types/fixture/mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,18 @@ expectType<ReturnType<typeof spyOn<DateConstructor, "now">>>().is<Mock<() => num
expectType<ReturnType<typeof jest.spyOn<DateConstructor, "now">>>().is<Mock<() => number>>();
expectType<ReturnType<typeof vi.spyOn<DateConstructor, "now">>>().is<Mock<() => number>>();

// jest.replaceProperty() types the value and the handle after the property, as in Jest.
const config = { mode: "production" as "production" | "test", retries: 1 };
const replaced = jest.replaceProperty(config, "mode", "test");
expectType(replaced).is<jest.Replaced<"production" | "test">>();
expectType(replaced.replaceValue("production")).is<jest.Replaced<"production" | "test">>();
expectType(replaced.restore()).is<void>();
expectType(jest.replaceProperty(process.env, "NODE_ENV", "test")).is<jest.Replaced<string | undefined>>();
// @ts-expect-error the value has the property's type
jest.replaceProperty(config, "retries", "many");
// @ts-expect-error the property must exist
jest.replaceProperty(config, "notAProperty", 1);

// Same for `ReturnType<typeof mock>`.
expectType<ReturnType<typeof mock>>().is<Mock<(...args: any[]) => any>>();
expectType<ReturnType<typeof jest.fn>>().is<Mock<(...args: any[]) => any>>();
Expand Down
Loading
Loading