vitest-dev/vitest · error · TypeError

Cannot spy on export

Error message

Cannot spy on export "${String(key)}". Module namespace is not configurable in ESM. See: https://vitest.dev/guide/mocking/modules#mocking-a-module

What it means

vi.spyOn attempts to redefine a property on the target object. ESM module namespace objects have non-configurable exports by spec, so redefinition throws a TypeError that Vitest catches and re-wraps into this actionable message. The library cannot patch live ESM namespace bindings; mocking must go through vi.mock at the module level instead.

Solutions

  1. Use vi.mock('module-name', () => ({ myFunc: vi.fn() })) to replace the module before it is imported.
  2. Refactor the code under test to accept the function as a parameter (dependency injection) so you can pass a spy directly.
  3. If the module has a default export that is an object, spy on that object's method instead of the namespace binding.

Example fix

// before — fails because ESM namespace is non-configurable:
import * as mod from './mod'
vi.spyOn(mod, 'doThing')

// after — mock at the module level:
vi.mock('./mod', () => ({ doThing: vi.fn() }))
import { doThing } from './mod'
Defensive patterns

Strategy: type-guard

Validate before calling

import { isModuleNamespaceObject } from 'util/types'

// before spying, check if the target is an ESM namespace:
if (isModuleNamespaceObject(target)) {
  // use vi.mock instead of vi.spyOn
}

Type guard

function isESMNamespace(obj: unknown): boolean {
  return typeof obj === 'object' && obj !== null
    && Object.prototype.toString.call(obj) === '[object Module]'
}

Try / catch

try {
  vi.spyOn(mod, 'fn')
} catch (e) {
  if (e instanceof TypeError && e.message.includes('Module namespace is not configurable')) {
    // fall back to vi.mock at module level
    vi.mock('mod', () => ({ fn: vi.fn() }))
  } else { throw e }
}

Prevention

When it happens

Trigger: Calling vi.spyOn on an imported ESM module's named export directly, e.g. `vi.spyOn(myModule, 'myFunc')` where myModule is a namespace object from a real ESM import (not a CJS interop).

Common situations: Migrating from Jest where spyOn on module exports worked via CJS; importing a pure-ESM dependency and trying to spy on its functions at runtime; testing code that wasn't designed with dependency injection.

Related errors


AI-assisted analysis of vitest-dev/vitest@1fa9837ec2 (2026-08-11). Data as JSON: /api/errors/e714ca69d975a0d6. Report an issue: GitHub.

Appendix: source

Thrown at packages/spy/src/index.ts:436

  })

  try {
    reassign(
      ssr
        ? () => mock
        : mock,
    )
  }
  catch (error) {
    if (
      error instanceof TypeError
      && Symbol.toStringTag
      && (object as any)[Symbol.toStringTag] === 'Module'
      && (error.message.includes('Cannot redefine property')
        || error.message.includes('Cannot replace module namespace')
        || error.message.includes('can\'t redefine non-configurable property'))
    ) {
      throw new TypeError(
        `Cannot spy on export "${String(key)}". Module namespace is not configurable in ESM. See: https://vitest.dev/guide/mocking/modules#mocking-a-module`,
        { cause: error },
      )
    }

    throw error
  }

  return mock
}

function getDescriptor(obj: any, method: string | symbol | number): [any, PropertyDescriptor] | undefined {
  const objDescriptor = Object.getOwnPropertyDescriptor(obj, method)
  if (objDescriptor) {
    return [obj, objDescriptor]
  }
  let currentProto = Object.getPrototypeOf(obj)
  while (currentProto !== null) {

View on GitHub (pinned to 1fa9837ec2)