#11306·vitest

vi.spyOn on both `get` and `set` of the same property: restore reinstalls the sibling's mock

Author: yusuke-oba1Created Sep 18, 2026Updated Sep 18, 2026

Describe the bug

When vi.spyOn(obj, key, 'get') and vi.spyOn(obj, key, 'set') are both installed on the same property, restoring does not return the property to its original state.

typescript
const target = { _value: 'original' }
Object.defineProperty(target, 'prop', {
  get() { return target._value },
  set(value) { target._value = value },
  configurable: true,
})

vi.spyOn(target, 'prop', 'get').mockReturnValue('mocked')
vi.spyOn(target, 'prop', 'set').mockImplementation(() => {})
vi.restoreAllMocks()

target.prop // 'mocked' - expected 'original'

This is not an ordering mistake on the user's side. Restoring a single spy also silently removes the sibling spy that is still supposed to be active:

typescript
const getSpy = vi.spyOn(target, 'prop', 'get').mockReturnValue('mocked')
const setSpy = vi.spyOn(target, 'prop', 'set').mockImplementation(() => {})
getSpy.mockRestore()

target.prop = 'written'
setSpy.mock.calls // [] - the setter spy was wiped

Spying set first and get second leaves the setter mocked instead, and for an accessor inherited from a prototype a spurious own property is left on the instance, shadowing the prototype. The linked reproduction covers all four cases.

The failure mode is silent: a getter that stays mocked produces no error, so it keeps returning the mocked value for the rest of the environment's lifetime. With isolate: false (or any pool configuration that reuses an environment across files) it leaks into every subsequent test file. In our Angular suite a spy written in one spec file made an unrelated spec file fail with a wrong value; because the victim moved around with file ordering and worker count it looked like a random flake, and it took a bisect over 725 files to trace it back.

Root cause

In packages/spy/src/index.ts, reassign() rebuilds the whole descriptor from originalDescriptor — a snapshot taken when that spy was created — and only overwrites its own accessType:

typescript
const reassign = (cb: any) => {
  const { value, ...desc } = originalDescriptor || {
    configurable: true,
    writable: true,
  }
  if (accessType !== 'value') {
    delete desc.writable
  }
  ;(desc as PropertyDescriptor)[accessType] = cb
  Object.defineProperty(object, key, desc)
}

const restore = () => {
  if (originalDescriptorObject !== object) {
    Reflect.deleteProperty(object, key)
  }
  else if (originalDescriptor && !original) {
    Object.defineProperty(object, key, originalDescriptor)
  }
  else {
    reassign(original)
  }
}

By the time the second spyOn runs, the first spy's mock is already installed, so the second spy's snapshot contains that mock in the sibling slot, and its restore() writes it back. restoreAllMocks() iterates MOCK_RESTORE (a Set, i.e. creation order), so the polluted snapshot is applied last and wins:

step actual descriptor that spy's snapshot
spyOn(get) { get: mockG, set: S0 } { get: G0, set: S0 }
spyOn(set) { get: mockG, set: mockS } { get: mockG, set: S0 }
restore #1 { get: G0, set: S0 }
restore #2 { get: mockG, set: S0 } mock reinstalled

The early-return guard if (isMockFunction(originalImplementation)) return originalImplementation only inspects the accessor being spied on, so an already-mocked sibling neither prevents nor deduplicates the second spyOn.

Suggested fix

Have restore() read the current descriptor rather than the snapshot, and replace only its own accessor. That composes correctly regardless of order:

typescript
const restore = () => {
  const current = Object.getOwnPropertyDescriptor(object, key)
  Object.defineProperty(object, key, { ...current, [accessType]: original })
}

For the prototype branch, the own copy should only be deleted once no sibling spy is still installed on it — otherwise restoring the first spy deletes the own property and the second spy's restore() immediately re-creates it.

Iterating MOCK_RESTORE in reverse would fix the restoreAllMocks() cases but not the individual mockRestore() one. (For reference, jasmine uses the same snapshot approach but restores LIFO, which is why equivalent test code is safe there.)

Reproduction

https://github.com/yusuke-oba1/vitest-accessor-spy-restore-repro

bash
npm install
npm test   # 4 failing tests

Also reproduced on [email protected].

System Info

  System:
    OS: macOS 15.7.9
    CPU: (10) arm64 Apple M2 Pro
    Memory: 263.11 MB / 32.00 GB
    Shell: 5.9 - /opt/homebrew/bin/zsh
  Binaries:
    Node: 24.18.0 - /Users/yusuke_oba/.nodebrew/current/bin/node
    Yarn: 1.22.22 - /opt/homebrew/bin/yarn
    npm: 11.16.0 - /Users/yusuke_oba/.nodebrew/current/bin/npm
    pnpm: 10.29.3 - /opt/homebrew/bin/pnpm
  Browsers:
    Safari: 26.6.1
  npmPackages:
    vitest: 5.0.1 => 5.0.1

Used Package Manager

npm

Validations