Skip to content

fix(spy): preserve class mock prototype methods#10561

Draft
hi-ogawa wants to merge 3 commits into
vitest-dev:mainfrom
hi-ogawa:codex/spyon-class-mockimplementation-prototype
Draft

fix(spy): preserve class mock prototype methods#10561
hi-ogawa wants to merge 3 commits into
vitest-dev:mainfrom
hi-ogawa:codex/spyon-class-mockimplementation-prototype

Conversation

@hi-ogawa

@hi-ogawa hi-ogawa commented Jun 10, 2026

Copy link
Copy Markdown
Collaborator

Description

Root cause

Vitest constructs class mocks by running the original/mock implementation constructor, but it uses the mock wrapper as newTarget. That means the constructor body and class fields run, but the allocated object inherits from MockClass.prototype instead of ActualClass.prototype. The gap is easiest to see by splitting construction into two conceptual phases: object allocation with a prototype, then constructor/class-field initialization.

class ActualClass {
  constructor(...ctorArgs) {
    // In normal construction, `this` already inherits from ActualClass.prototype.
    // So `this.method1` is visible here.
  }

  method1() {} // lives on ActualClass.prototype
  method2 = () => {} // assigned as an own property during construction
}

const actualInstance = new ActualClass(...ctorArgs)
//
// This is equivalent to:
//
//   Reflect.construct(ActualClass, ctorArgs, ActualClass)
//
// which then semantically almost same as:
//
//   const actualInstance = Object.create(ActualClass.prototype)
//   // Creates this prototype chain:
//   //   actualInstance -> ActualClass.prototype -> Object.prototype
//   // This is why:
//   //   actualInstance.method1                 // found via ActualClass.prototype
//   //   actualInstance instanceof ActualClass  // true
//
//   ActualClass[[constructorBody]].apply(actualInstance, ctorArgs)
//   // Runs constructor/class-field initialization on `actualInstance`.
//   // This is why:
//   //   actualInstance.method2                 // own property assigned during construction

const MockClass = vi.fn(ActualClass)
const mockInstance = new MockClass(...ctorArgs)
//
// Vitest currently implements this as:
//
//   Reflect.construct(ActualClass, ctorArgs, MockClass)
//
// which mostly means:
//
//   const mockInstance = Object.create(MockClass.prototype)
//   // Creates this prototype chain:
//   //   mockInstance -> MockClass.prototype -> Object.prototype
//   //
//   // This is why:
//   //   mockInstance.method1                 // missing: ActualClass.prototype is not in the chain
//   //   mockInstance instanceof MockClass    // true
//   //   mockInstance instanceof ActualClass  // false
//
//   ActualClass[[constructorBody]].apply(mockInstance, ctorArgs)
//   // Runs constructor/class-field initialization on `mockInstance`.
//   // This is why:
//   //   mockInstance.method2                 // own property assigned during construction

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

  • It's really useful if your PR references an issue where it is discussed ahead of time. If the feature is substantial or introduces breaking changes without a discussion, PR might be closed.
  • Ideally, include a test that fails without this PR but passes with it.
  • Please, don't make changes to pnpm-lock.yaml unless you introduce a new test example.
  • Please check Allow edits by maintainers to make review process faster. Note that this option is not available for repositories that are owned by Github organizations.

Tests

  • Run the tests with pnpm test:ci.

Documentation

  • If you introduce new functionality, document it. You can run documentation with pnpm run docs command.

Changesets

  • Changes in changelog are generated from PR name. Please, make sure that it explains your changes in an understandable manner. Please, prefix changeset messages with feat:, fix:, perf:, docs:, or chore:.

hi-ogawa and others added 2 commits June 10, 2026 18:45
Co-authored-by: Codex <noreply@openai.com>
@netlify

netlify Bot commented Jun 10, 2026

Copy link
Copy Markdown

Deploy Preview for vitest-dev ready!

Built without sensitive environment variables

Name Link
🔨 Latest commit 9edb0cf
🔍 Latest deploy log https://app.netlify.com/projects/vitest-dev/deploys/6a2932d14953840007f60c83
😎 Deploy Preview https://deploy-preview-10561--vitest-dev.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

Comment thread packages/spy/src/index.ts
try {
if (new.target) {
returnValue = Reflect.construct(implementation, args, new.target)
const implementationPrototype = (implementation as any).prototype

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any explanation why Reflect.construct just doesn't work? Does it behave the same outside of Vitest?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's is quite mind-bending, but I got the gist. Added root cause in the PR description. I'm not settled with the fix. There is still some divergence and probably need to pick some trade off.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, I remember this now. I don't think there is a good solution for this, all of them break something, it just needs to be documented

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