Skip to content

fix(spy): keep prototype methods on instances created from mocked classes#10584

Closed
naveentehrpariya wants to merge 1 commit into
vitest-dev:mainfrom
naveentehrpariya:fix-spy-class-prototype-methods
Closed

fix(spy): keep prototype methods on instances created from mocked classes#10584
naveentehrpariya wants to merge 1 commit into
vitest-dev:mainfrom
naveentehrpariya:fix-spy-class-prototype-methods

Conversation

@naveentehrpariya

Copy link
Copy Markdown

Description

Resolves #10553

When a mock is constructed, the instance is created with Reflect.construct(implementation, args, new.target), where new.target is the mock itself — so the instance's prototype is mock.prototype, and any methods defined on the implementation's prototype are lost. This breaks the documented mockImplementation(class { ... }) pattern:

vi.spyOn(myObj, "TestClass").mockImplementation(
  class MockClass extends OriginalClass {
    method() { return "mocked" }
  },
)

const instance = new myObj.TestClass()
typeof instance.method // "undefined" ❌

The same root cause also affects:

  • plain vi.spyOn(obj, 'Class') with no mock implementation — constructed instances lose the original class's methods,
  • vi.fn(class { ... }) — instances lose the class's prototype methods.

(Class fields work because the constructor installs them as own properties; only prototype members are lost.)

Fix

Before constructing, link the resolved implementation's prototype into the chain: Object.setPrototypeOf(mock.prototype, implementation.prototype). The instance's chain becomes instance → mock.prototype → implementation.prototype → ..., which preserves the existing pinned semantics:

  • instance instanceof mock stays true (docs promise, mocks constructors test),
  • methods assigned directly on mock.prototype (e.g. Dog.prototype.speak = vi.fn()) keep priority,
  • the automocker path is unaffected — its member mocks live as own properties of mock.prototype,
  • mockImplementationOnce with different classes resolves each class's prototype per construction.

⚠️ One deliberate semantic change that maintainers should weigh in on: the existing spies on classes test asserted expect(new Spy()).not.toBeInstanceOf(MockExample). Prototype methods can only resolve through the prototype chain, so making instance.method work necessarily puts MockExample.prototype in the chain, and instanceof MockExample becomes true. I updated that assertion — it also matches how a real class MockClass extends Original instance would behave. If hiding the implementation class is intentional, an alternative is copying prototype descriptors instead of linking, but that breaks mockImplementationOnce sequencing and user-assigned method priority in subtle ways.

Tests

  • New tests covering: the issue repro (extends + override), plain vi.spyOn on a class, vi.fn(class), mock.prototype-assigned method priority, and mockImplementationOnce with different classes.
  • test/unit full suite: 2425 passed, type checks clean. (Browser-dependent e2e specs were skipped locally — no Playwright browsers installed.)

Please don't delete this checklist! Before submitting the PR, please make sure you do the following:

Tests

  • Run the tests with pnpm test:ci. (test/unit projects run locally; browser e2e requires Playwright binaries not available in my environment)

Documentation

  • No new functionality — existing documented behavior is fixed.

Changesets

  • Changes in changelog are generated from PR name.

@sheremet-va

Copy link
Copy Markdown
Member

there is already open PR #10561

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

vi.spyOn(...).mockImplementation(class {...}) produces instances with no prototype methods

2 participants