-
-
Notifications
You must be signed in to change notification settings - Fork 1.4k
Description
Describe the bug
#4564 (released in >= 1.x.x
) made class methods' mock instances independent, but as a side-effect it left some unintuitive and/or undesired behaviors:
Behavior A: instance-level mock persistent configurations (e.g. new MyClass().myMethod.mockReturnValue()
) "branch off" their prototype-level mock (e.g. MyClass.prototype.myMethod
). The prototype-level mock:
- Observes instance-level mock invocations prior to the branching.
- Does not observe instance-level mock invocations after the branching.
This results in unintuitive and confusing expect(MyClass.prototype.myMethod).toHaveBeenCalledTimes()
results.
Behavior B: prototype-level mock once configurations (e.g. MyClass.prototype.myMethod.mockReturnValueOnce()
) are "shared" across all instance-level mocks, even those that have "branched off" as mentioned in Behavior A. As a result, consecutive once configurations can be spread across different instance-level mocks which is unintuitive and confusing.
Additionally, each invoked instance-level mock "temporarily branches off" during a "once invocation". The prototype-level mock:
- Does not observe instance-level mock invocations affected by "once invocations".
- Observes instance-level mock invocations after the "once" configurations are "depleted".
This results in unintuitive and confusing expect(MyClass.prototype.myMethod).toHaveBeenCalledTimes()
results.
Behavior C: instance-level mock once configurations (e.g. new MyClass().myMethod.mockReturnValueOnce()
) are "shared" across all instance-level mocks, which contradicts the expected behavior given Behavior A and Behavior B. The same "temporary branching" that affects call counts described in Behavior B applies here.
Reproduction
// file: my-class.ts
export class MyClass {
myMethod(): string {
return "original implementation";
}
}
Behavior A:
// file: my-class.test.ts
import { expect, test, vi } from "vitest";
import { MyClass } from "./my-class";
vi.mock("./my-class");
test("Behavior A", () => {
const inst1 = new MyClass();
const inst2 = new MyClass();
const inst3 = new MyClass();
// prototype-level mock setup
vi.mocked(MyClass.prototype).myMethod.mockReturnValue("prototype-fake");
// inst1 invocations should be impacted by prototype-level mock setup
const inst1FirstCall = inst1.myMethod(); // calls: [inst1: 1, inst2: 0, inst3: 0, total: 1]
const inst1SecondCall = inst1.myMethod(); // calls: [inst1: 2, inst2: 0, inst3: 0, total: 2]
expect(inst1FirstCall).toBe("prototype-fake");
expect(inst1SecondCall).toBe("prototype-fake");
// instance-level mock call counts should be isolated
expect(inst1.myMethod).toHaveBeenCalledTimes(2);
expect.soft(inst2.myMethod).toHaveBeenCalledTimes(0); // pre-v1: 2, post-v1: OK
expect.soft(inst3.myMethod).toHaveBeenCalledTimes(0); // pre-v1: 2, post-v1: OK
// prototype-level mock call count should keep track of all instance-level mock invocations
expect(MyClass.prototype.myMethod).toHaveBeenCalledTimes(2);
// inst2 invocations should be impacted by prototype-level mock setup
const inst2FirstCall = inst2.myMethod(); // calls: [inst1: 2, inst2: 1, inst3: 0, total: 3]
const inst2SecondCall = inst2.myMethod(); // calls: [inst1: 2, inst2: 2, inst3: 0, total: 4]
expect(inst2FirstCall).toBe("prototype-fake");
expect(inst2SecondCall).toBe("prototype-fake");
// instance-level mock call counts should be isolated
expect.soft(inst1.myMethod).toHaveBeenCalledTimes(2); // pre-v1: 4, post-v1: OK
expect.soft(inst2.myMethod).toHaveBeenCalledTimes(2); // pre-v1: 4, post-v1: OK
expect.soft(inst3.myMethod).toHaveBeenCalledTimes(0); // pre-v1: 4, post-v1: OK
// prototype-level mock call count should keep track of all instance-level mock invocations
expect(MyClass.prototype.myMethod).toHaveBeenCalledTimes(4);
// instance-level mock setup causes to "branch off" prototype-level mock
vi.mocked(inst1.myMethod).mockReturnValue("inst1-fake");
vi.mocked(inst2.myMethod).mockReturnValue("inst2-fake");
// inst1 and inst2 invocations should be impacted by their corresponding instance-level mock setup
// inst3 invocations should be impacted by prototype-level mock setup
const inst1ThirdCall = inst1.myMethod(); // calls: [inst1: 3, inst2: 2, inst3: 0, total: 5]
const inst2ThirdCall = inst2.myMethod(); // calls: [inst1: 3, inst2: 3, inst3: 0, total: 6]
const inst3FirstCall = inst3.myMethod(); // calls: [inst1: 3, inst2: 3, inst3: 1, total: 7]
expect.soft(inst1ThirdCall).toBe("inst1-fake"); // pre-v1: "inst2-fake", post-v1: OK
expect(inst2ThirdCall).toBe("inst2-fake");
expect.soft(inst3FirstCall).toBe("prototype-fake"); // pre-v1: "inst2-fake"
// instance-level mock call counts should be isolated
expect.soft(inst1.myMethod).toHaveBeenCalledTimes(3); // pre-v1: 7
expect.soft(inst2.myMethod).toHaveBeenCalledTimes(3); // pre-v1: 7
expect.soft(inst3.myMethod).toHaveBeenCalledTimes(1); // pre-v1: 7
// prototype-level mock call count should keep track of all instance-level mock invocations
expect.soft(MyClass.prototype.myMethod).toHaveBeenCalledTimes(7); // pre-v1: OK, post-v1: 5
});
Behavior B:
// file: my-class.test.ts
import { expect, test, vi } from "vitest";
import { MyClass } from "./my-class";
vi.mock("./my-class");
test("Behavior B", () => {
const inst1 = new MyClass();
const inst2 = new MyClass();
const inst3 = new MyClass();
// prototype-level mock setup
vi.mocked(MyClass.prototype).myMethod.mockReturnValue("prototype-fake");
// instance-level mock setup
vi.mocked(inst3).myMethod.mockReturnValue("inst3-fake");
// inst1 and inst2 invocations should be impacted by their corresponding prototype-level mock setup
// inst3 invocations should be impacted by its corresponding instance-level mock setup
const inst1FirstCall = inst1.myMethod(); // calls: [inst1: 1, inst2: 0, inst3: 0, total: 1]
const inst2FirstCall = inst2.myMethod(); // calls: [inst1: 1, inst2: 1, inst3: 0, total: 2]
const inst3FirstCall = inst3.myMethod(); // calls: [inst1: 1, inst2: 1, inst3: 1, total: 3]
expect.soft(inst1FirstCall).toBe("prototype-fake"); // pre-v1: "inst3-fake", post-v1: OK
expect.soft(inst2FirstCall).toBe("prototype-fake"); // pre-v1: "inst3-fake", post-v1: OK
expect(inst3FirstCall).toBe("inst3-fake");
// instance-level mock call counts should be isolated
expect.soft(inst1.myMethod).toHaveBeenCalledTimes(1); // pre-v1: 3, post-v1: OK
expect.soft(inst2.myMethod).toHaveBeenCalledTimes(1); // pre-v1: 3, post-v1: OK
expect.soft(inst3.myMethod).toHaveBeenCalledTimes(1); // pre-v1: 3, post-v1: OK
// prototype-level mock call count should keep track of all instance-level mock invocations
expect.soft(MyClass.prototype.myMethod).toHaveBeenCalledTimes(3); // pre-v1: OK, post-v1: 2
// prototype-level mock once setup: causes instances to "temporarily branch off" prototype-level mock
vi.mocked(MyClass.prototype).myMethod
.mockReturnValueOnce("prototype-once-1")
.mockReturnValueOnce("prototype-once-2");
// inst1 and inst2 invocations should be impacted by their corresponding prototype-level mock setup
// inst3 invocations should be impacted by its corresponding instance-level mock setup
const inst3SecondCall = inst3.myMethod(); // calls: [inst1: 1, inst2: 1, inst3: 2, total: 4]
const inst1SecondCall = inst1.myMethod(); // calls: [inst1: 2, inst2: 1, inst3: 2, total: 5]
const inst2SecondCall = inst2.myMethod(); // calls: [inst1: 2, inst2: 2, inst3: 2, total: 6]
expect.soft(inst3SecondCall).toBe("inst3-fake"); // (pre/post)-v1: "prototype-once-1"
expect.soft(inst1SecondCall).toBe("prototype-once-1"); // (pre/post)-v1: "prototype-once-2"
expect.soft(inst2SecondCall).toBe("prototype-once-1"); // pre-v1: "inst3-fake", post-v1: "prototype-fake"
// instance-level mock call counts should be isolated
expect.soft(inst1.myMethod).toHaveBeenCalledTimes(2); // pre-v1: 6, post-v1: OK
expect.soft(inst2.myMethod).toHaveBeenCalledTimes(2); // pre-v1: 6, post-v1: OK
expect.soft(inst3.myMethod).toHaveBeenCalledTimes(2); // pre-v1: 6, post-v1: OK
// prototype-level mock call count should keep track of all instance-level mock invocations
expect.soft(MyClass.prototype.myMethod).toHaveBeenCalledTimes(6); // pre-v1: OK, post-v1: 3
});
Behavior C:
// file: my-class.test.ts
import { expect, test, vi } from "vitest";
import { MyClass } from "./my-class";
vi.mock("./my-class");
test("Behavior C", () => {
const inst1 = new MyClass();
const inst2 = new MyClass();
// prototype-level mock setup
vi.mocked(MyClass.prototype).myMethod.mockReturnValue("prototype-fake");
// instance-level mock setup
vi.mocked(inst2).myMethod.mockReturnValue("inst2-fake");
vi.mocked(inst2).myMethod.mockReturnValueOnce("inst2-once-fake");
// inst1 invocations should be impacted by their corresponding prototype-level mock setup
// inst2 invocations should be impacted by its corresponding instance-level mock setup
const inst1FirstCall = inst1.myMethod(); // calls: [inst1: 1, inst2: 0, total: 1]
const inst2FirstCall = inst2.myMethod(); // calls: [inst1: 1, inst2: 1, total: 2]
const inst1SecondCall = inst1.myMethod(); // calls: [inst1: 2, inst2: 1, total: 3]
const inst2SecondCall = inst2.myMethod(); // calls: [inst1: 2, inst2: 2, total: 4]
expect.soft(inst1FirstCall).toBe("prototype-fake"); // (pre/post)-v1: "inst2-once-fake"
expect.soft(inst2FirstCall).toBe("inst2-once-fake"); // (pre/post)-v1: "inst2-fake"
expect.soft(inst1SecondCall).toBe("prototype-fake"); // pre-v1: "inst2-fake", post-v1: OK
expect.soft(inst2SecondCall).toBe("inst2-fake");
// instance-level mock call counts should be isolated
expect.soft(inst1.myMethod).toHaveBeenCalledTimes(2); // pre-v1: 4, post-v1: OK
expect.soft(inst2.myMethod).toHaveBeenCalledTimes(2); // pre-v1: 4, post-v1: OK
// prototype-level mock call count should keep track of all instance-level mock invocations
expect.soft(MyClass.prototype.myMethod).toHaveBeenCalledTimes(4); // pre-v1: OK, post-v1: 1
});
System Info
pre-v1:
System:
OS: macOS 15.4.1
CPU: (12) arm64 Apple M3 Pro
Memory: 153.89 MB / 36.00 GB
Shell: 5.9 - /bin/zsh
Binaries:
Node: 22.14.0 - ~/.nvm/versions/node/v22.14.0/bin/node
Yarn: 4.6.0 - ~/.nvm/versions/node/v22.14.0/bin/yarn
npm: 10.9.2 - ~/.nvm/versions/node/v22.14.0/bin/npm
Browsers:
Chrome: 138.0.7204.101
Safari: 18.4
npmPackages:
vitest: ^0.34.6 => 0.34.6
---
post-v1:
System:
OS: macOS 15.4.1
CPU: (12) arm64 Apple M3 Pro
Memory: 416.81 MB / 36.00 GB
Shell: 5.9 - /bin/zsh
Binaries:
Node: 22.14.0 - ~/.nvm/versions/node/v22.14.0/bin/node
Yarn: 1.22.22 - ~/.nvm/versions/node/v22.14.0/bin/yarn
npm: 10.9.2 - ~/.nvm/versions/node/v22.14.0/bin/npm
pnpm: 9.14.2 - ~/.nvm/versions/node/v22.14.0/bin/pnpm
Browsers:
Chrome: 138.0.7204.101
Safari: 18.4
npmPackages:
vitest: ^3.2.4 => 3.2.4
Used Package Manager
npm
Validations
- Follow our Code of Conduct
- Read the Contributing Guidelines.
- Read the docs.
- Check that there isn't already an issue that reports the same bug to avoid creating a duplicate.
- Check that this is a concrete bug. For Q&A open a GitHub Discussion or join our Discord Chat Server.
- The provided reproduction is a minimal reproducible example of the bug.