Skip to content

Undesired and/or unintuitive behaviors with mocked class methods #8307

@marcogrcr

Description

@marcogrcr

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

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions