Skip to content

Fix getters being imitated as undefined, and td.replace on getter properties - #550

Merged
rosston merged 3 commits into
mainfrom
fix-526-getters
Oct 12, 2026
Merged

rosston merged 3 commits into
mainfrom
fix-526-getters

Conversation

@rosston

@rosston rosston commented Oct 12, 2026

Copy link
Copy Markdown
Member

Fixes #526

The problem

Any property defined with a getter came out of imitation as undefined. The property copier read only descriptor.value, and accessor descriptors have get instead. That affected every entry point that imitates: td.replace(path) (including testdouble-jest's td.mock), td.object, td.constructor and td.imitate.

The most visible case is compiled CommonJS:

  • SWC compiles every export to a non-configurable getter. Projects built with SWC (Next.js, @swc/jest) couldn't stub their own modules at all: td.when(dep.doTheThing()) threw dep.doTheThing is not a function.
  • Babel and tsc compile re-exports (export { x } from, export *) to getters, so barrel files broke the same way.
  • Native ESM through td.replaceEsm was unaffected, because namespace objects use data properties.

A related bug: td.replace(object, 'prop') replaced and restored by assignment. That throws Cannot set property ... which has only a getter when prop is a getter with no setter.

What changed

Imitating getters (copy-props.js, overwrite-children/index.js)

The original object is now passed down to copyProps, which calls each getter on it and imitates the result. Functions become test doubles and objects are imitated recursively, as for data properties. It falls back to undefined, which was the old behavior, when:

  • the getter, or imitating its result, throws
  • the accessor has only a setter
  • the object being walked is a prototype. There's no instance to call the getter on, so td.constructor(Foo) instances still read prototype getters as undefined
  • getters are nested more than 3 deep. A getter can return a fresh object with getters of its own forever (get kid () { return new A() }), so the depth is capped to keep imitation finite

Replacing getter properties (replace/property.js)

For a getter with no setter, td.replace(object, 'prop') now defines the fake instead of assigning it:

Getter Replace td.reset()
Own, configurable (tsc re-exports) Redefined as a data property Original descriptor restored exactly
Inherited (class getter, via an instance) Shadowed by an own property Own property deleted
Own, non-configurable (SWC exports) Clear error pointing to td.replace('path/to/module') n/a

Data properties and getters with setters are still replaced by assignment, as before.

Behavior changes

  • Getters now run during imitation, so their side effects run too. For example, Node deprecation warnings from getters on fs or crypto, or lazy loading. Getters that throw still give undefined.
  • td.replace(moduleObject, 'export') can't work on SWC output, because JavaScript won't let a non-configurable getter be redefined. It now fails with an error explaining that and suggesting td.replace(path), which does work for these modules after this change.

Review notes

  • The depth cap uses a module-level counter. That's safe because imitation is fully synchronous, and a finally restores the counter even when something throws. Its one quirk: a getter that calls td.imitate itself starts that inner imitation partway up the cap, so it gets fewer levels of expansion.
  • A 2017 commit (adea88c) switched to descriptor.value specifically to avoid running getters. The hazards it named are handled elsewhere now:
    • gatherProps stops at Function.prototype and Object.prototype.
    • Current V8 makes arguments/caller data properties.
    • The try/catch covers anything that still throws.

copyProps built every fake property from `descriptor.value`, so any
accessor property, which has `get` instead, came out as `undefined`.
That broke td.replace(path), td.object, td.constructor and td.imitate
for objects with getters. Most visibly, SWC compiles every export to a
getter, and Babel and tsc compile re-exports to getters, so those
modules' exports couldn't be stubbed.

Pass the original object down to copyProps and call each getter on it,
imitating the result. Fall back to `undefined`, as before, when:

- the getter (or imitating its result) throws
- there's only a setter
- the object is a prototype, which has no instance to call it on
- getters are nested more than 3 deep, since a getter can return a
  fresh object with getters of its own, forever
td.replace(object, property) swapped the property by assignment, both
when replacing and when restoring on reset. Assigning to a getter with
no setter throws in strict mode, so it failed with "Cannot set property
... which has only a getter".

For a getter with no setter, define the fake as a data property
instead:

- An own, configurable getter (as tsc defines re-exports) is redefined,
  and reset puts the original descriptor back exactly.
- An inherited getter (a class getter, replaced through an instance) is
  shadowed by an own property, which reset deletes.
- An own, non-configurable getter (as SWC defines exports) can't be
  redefined, so it gets an error that points to replacing the whole
  module with td.replace(path) instead.

Data properties and getters with setters are still replaced by
assignment, as before.
Babel compiles `export { square } from './squarer'` to a getter, which
td.replace(path) used to imitate as `undefined`. This runs that path
through a real compiler in CI.
@rosston rosston self-assigned this Oct 12, 2026
@rosston
rosston marked this pull request as ready for review October 12, 2026 16:54
@rosston
rosston merged commit 04063f1 into main Oct 12, 2026
12 checks passed
@rosston
rosston deleted the fix-526-getters branch October 12, 2026 16:54
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.

td.imitate produces undefined for object keys of type Accessor

1 participant