Skip to content

test_runner: support object property mocking #58438

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Jun 9, 2025
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions doc/api/test.md
Original file line number Diff line number Diff line change
Expand Up @@ -2036,6 +2036,50 @@ added:

Resets the implementation of the mock module.

## Class: `MockPropertyContext`

<!-- YAML
added: REPLACEME
-->

The `MockPropertyContext` class is used to inspect or manipulate the behavior
of property mocks created via the [`MockTracker`][] APIs.

### `ctx.accesses`

* {Array}

A getter that returns a copy of the internal array used to track accesses (get/set) to
the mocked property. Each entry in the array is an object with the following properties:

* `type` {string} Either `'get'` or `'set'`, indicating the type of access.
* `value` {any} The value that was read (for `'get'`) or written (for `'set'`).
* `stack` {Error} An `Error` object whose stack can be used to determine the
callsite of the mocked function invocation.

### `ctx.accessCount()`

* Returns: {integer} The number of times that the property was accessed (read or written).

This function returns the number of times that the property was accessed.
This function is more efficient than checking `ctx.accesses.length` because
`ctx.accesses` is a getter that creates a copy of the internal access tracking array.

### `ctx.mockImplementation(value)`

* `value` {any} The new value to be set as the mocked property value.

This function is used to change the value returned by the mocked property getter.

### `ctx.resetAccesses()`

Resets the access history of the mocked property.

### `ctx.restore()`

Resets the implementation of the mock property to its original behavior. The
mock can still be used after calling this function.

## Class: `MockTracker`

<!-- YAML
Expand Down Expand Up @@ -2240,6 +2284,43 @@ test('mocks a builtin module in both module systems', async (t) => {
});
```

### `mock.property(object, propertyName[, value])`

<!-- YAML
added: REPLACEME
-->

* `object` {Object} The object whose value is being mocked.
* `propertyName` {string|symbol} The identifier of the property on `object` to mock.
* `value` {any} An optional value used as the mock value
for `object[valueName]`. **Default:** The original property value.
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
for `object[valueName]`. **Default:** The original property value.
for `object[propertyName]`. **Default:** The original property value.

* Returns: {Proxy} A proxy to the mocked object. The mocked object contains a
special `mock` property, which is an instance of [`MockPropertyContext`][], and
can be used for inspecting and changing the behavior of the mocked property.

Creates a mock for a property value on an object. This allows you to track and control access to a specific property,
including how many times it is read (getter) or written (setter), and to restore the original value after mocking.

```js
test('mocks a property value', (t) => {
const obj = { foo: 42 };
const prop = t.mock.property(obj, 'foo', 100);

assert.strictEqual(obj.foo, 100);
assert.strictEqual(prop.mock.accessCount(), 1);
assert.strictEqual(prop.mock.accesses[0].type, 'get');
assert.strictEqual(prop.mock.accesses[0].value, 100);

obj.foo = 200;
assert.strictEqual(prop.mock.accessCount(), 2);
assert.strictEqual(prop.mock.accesses[1].type, 'set');
assert.strictEqual(prop.mock.accesses[1].value, 200);

prop.mock.restore();
assert.strictEqual(obj.foo, 42);
});
```

### `mock.reset()`

<!-- YAML
Expand Down Expand Up @@ -3774,6 +3855,7 @@ Can be used to abort test subtasks when the test has been aborted.
[`--test-update-snapshots`]: cli.md#--test-update-snapshots
[`--test`]: cli.md#--test
[`MockFunctionContext`]: #class-mockfunctioncontext
[`MockPropertyContext`]: #class-mockpropertycontext
[`MockTimers`]: #class-mocktimers
[`MockTracker.method`]: #mockmethodobject-methodname-implementation-options
[`MockTracker`]: #class-mocktracker
Expand Down
133 changes: 133 additions & 0 deletions lib/internal/test_runner/mock/mock.js
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,106 @@ class MockModuleContext {

const { restore: restoreModule } = MockModuleContext.prototype;

class MockPropertyContext {
#object;
#propertyName;
#value;
#originalValue;
#descriptor;
#accesses;

constructor(object, propertyName, value) {
this.#accesses = [];
this.#object = object;
this.#propertyName = propertyName;
this.#originalValue = object[propertyName];
this.#value = value ?? this.#originalValue;
this.#descriptor = ObjectGetOwnPropertyDescriptor(object, propertyName);
if (!this.#descriptor) {
throw new ERR_INVALID_ARG_VALUE(
'propertyName', propertyName, 'is not a property of the object',
);
}

const { configurable, enumerable } = this.#descriptor;
ObjectDefineProperty(object, propertyName, {
__proto__: null,
configurable,
enumerable,
get: () => {
const access = {
__proto__: null,
type: 'get',
value: this.#value,
// eslint-disable-next-line no-restricted-syntax
stack: new Error(),
};
ArrayPrototypePush(this.#accesses, access);
return this.#value;
},
set: this.mockImplementation.bind(this),
});
}

/**
* Gets an array of recorded accesses (get/set) to the property.
* @returns {Array} An array of access records.
*/
get accesses() {
return ArrayPrototypeSlice(this.#accesses, 0);
}

/**
* Retrieves the number of times the property was accessed (get or set).
* @returns {number} The total number of accesses.
*/
accessCount() {
return this.#accesses.length;
}

/**
* Sets a new value for the property.
* @param {any} value - The new value to be set.
* @throws {Error} If the property is not writable.
*/
mockImplementation(value) {
if (!this.#descriptor.writable) {
throw new ERR_INVALID_ARG_VALUE(
'propertyName', this.#propertyName, 'cannot be set',
);
}
const access = {
__proto__: null,
type: 'set',
value: value,
// eslint-disable-next-line no-restricted-syntax
stack: new Error(),
};
ArrayPrototypePush(this.#accesses, access);
this.#value = value;
}

/**
* Resets the recorded accesses to the property.
*/
resetAccesses() {
this.#accesses = [];
}

/**
* Restores the original value of the property that was mocked.
*/
restore() {
ObjectDefineProperty(this.#object, this.#propertyName, {
__proto__: null,
...this.#descriptor,
value: this.#originalValue,
});
}
}

const { restore: restoreProperty } = MockPropertyContext.prototype;

class MockTracker {
#mocks = [];
#timers;
Expand Down Expand Up @@ -573,6 +673,39 @@ class MockTracker {
return ctx;
}

/**
* Creates a property tracker for a specified object.
* @param {(object)} object - The object whose value is being tracked.
* @param {string} propertyName - The identifier of the property on object to be tracked.
* @param {any} value - A value used as the mock value for object[valueName].
* @returns {ProxyConstructor} The mock property tracker.
*/
property(
object,
propertyName,
value,
) {
validateObject(object, 'object');
validateStringOrSymbol(propertyName, 'propertyName');

const ctx = new MockPropertyContext(object, propertyName, value);
ArrayPrototypePush(this.#mocks, {
__proto__: null,
ctx,
restore: restoreProperty,
});

return new Proxy(object, {
__proto__: null,
get(target, property, receiver) {
if (property === 'mock') {
return ctx;
}
return ReflectGet(target, property, receiver);
},
});
}

/**
* Resets the mock tracker, restoring all mocks and clearing timers.
*/
Expand Down
Loading
Loading