test_runner: support object property mocking

PR-URL: https://github.com/nodejs/node/pull/58438
Fixes: https://github.com/nodejs/node/issues/58322
Reviewed-By: Colin Ihrig <cjihrig@gmail.com>
Reviewed-By: Chemi Atlow <chemi@atlow.co.il>
This commit is contained in:
Idan Goshen 2025-06-09 20:57:07 +03:00 committed by GitHub
parent 3aaa2ebe19
commit 905a722df3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 501 additions and 1 deletions

View File

@ -2048,6 +2048,87 @@ added:
Resets the implementation of the mock module. 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.mockImplementationOnce(value[, onAccess])`
* `value` {any} The value to be used as the mock's
implementation for the invocation number specified by `onAccess`.
* `onAccess` {integer} The invocation number that will use `value`. If
the specified invocation has already occurred then an exception is thrown.
**Default:** The number of the next invocation.
This function is used to change the behavior of an existing mock for a single
invocation. Once invocation `onAccess` has occurred, the mock will revert to
whatever behavior it would have used had `mockImplementationOnce()` not been
called.
The following example creates a mock function using `t.mock.property()`, calls the
mock property, changes the mock implementation to a different value for the
next invocation, and then resumes its previous behavior.
```js
test('changes a mock behavior once', (t) => {
const obj = { foo: 1 };
const prop = t.mock.property(obj, 'foo', 5);
assert.strictEqual(obj.foo, 5);
prop.mock.mockImplementationOnce(25);
assert.strictEqual(obj.foo, 25);
assert.strictEqual(obj.foo, 5);
});
```
#### Caveat
For consistency with the rest of the mocking API, this function treats both property gets and sets
as accesses. If a property set occurs at the same access index, the "once" value will be consumed
by the set operation, and the mocked property value will be changed to the "once" value. This may
lead to unexpected behavior if you intend the "once" value to only be used for a get operation.
### `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` ## Class: `MockTracker`
<!-- YAML <!-- YAML
@ -2252,6 +2333,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[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()` ### `mock.reset()`
<!-- YAML <!-- YAML
@ -3790,6 +3908,7 @@ Can be used to abort test subtasks when the test has been aborted.
[`--test-update-snapshots`]: cli.md#--test-update-snapshots [`--test-update-snapshots`]: cli.md#--test-update-snapshots
[`--test`]: cli.md#--test [`--test`]: cli.md#--test
[`MockFunctionContext`]: #class-mockfunctioncontext [`MockFunctionContext`]: #class-mockfunctioncontext
[`MockPropertyContext`]: #class-mockpropertycontext
[`MockTimers`]: #class-mocktimers [`MockTimers`]: #class-mocktimers
[`MockTracker.method`]: #mockmethodobject-methodname-implementation-options [`MockTracker.method`]: #mockmethodobject-methodname-implementation-options
[`MockTracker`]: #class-mocktracker [`MockTracker`]: #class-mocktracker

View File

@ -284,6 +284,134 @@ class MockModuleContext {
const { restore: restoreModule } = MockModuleContext.prototype; const { restore: restoreModule } = MockModuleContext.prototype;
class MockPropertyContext {
#object;
#propertyName;
#value;
#originalValue;
#descriptor;
#accesses;
#onceValues;
constructor(object, propertyName, value) {
this.#onceValues = new SafeMap();
this.#accesses = [];
this.#object = object;
this.#propertyName = propertyName;
this.#originalValue = object[propertyName];
this.#value = arguments.length > 2 ? 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 nextValue = this.#getAccessValue(this.#value);
const access = {
__proto__: null,
type: 'get',
value: nextValue,
// eslint-disable-next-line no-restricted-syntax
stack: new Error(),
};
ArrayPrototypePush(this.#accesses, access);
return nextValue;
},
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 nextValue = this.#getAccessValue(value);
const access = {
__proto__: null,
type: 'set',
value: nextValue,
// eslint-disable-next-line no-restricted-syntax
stack: new Error(),
};
ArrayPrototypePush(this.#accesses, access);
this.#value = nextValue;
}
#getAccessValue(value) {
const accessIndex = this.#accesses.length;
let accessValue;
if (this.#onceValues.has(accessIndex)) {
accessValue = this.#onceValues.get(accessIndex);
this.#onceValues.delete(accessIndex);
} else {
accessValue = value;
}
return accessValue;
}
/**
* Sets a value to be used only for the next access (get or set), or a specific access index.
* @param {any} value - The value to be used once.
* @param {number} [onAccess] - The access index to be replaced.
*/
mockImplementationOnce(value, onAccess) {
const nextAccess = this.#accesses.length;
const accessIndex = onAccess ?? nextAccess;
validateInteger(accessIndex, 'onAccess', nextAccess);
this.#onceValues.set(accessIndex, 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 { class MockTracker {
#mocks = []; #mocks = [];
#timers; #timers;
@ -573,6 +701,41 @@ class MockTracker {
return ctx; 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 - An optional replacement 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 = arguments.length > 2 ?
new MockPropertyContext(object, propertyName, value) :
new MockPropertyContext(object, propertyName);
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. * Resets the mock tracker, restoring all mocks and clearing timers.
*/ */

View File

@ -391,7 +391,7 @@ test('spies on async static class methods', async (t) => {
}); });
test('given null to a mock.method it throws a invalid argument error', (t) => { test('given null to a mock.method it throws an invalid argument error', (t) => {
assert.throws(() => t.mock.method(null, {}), { code: 'ERR_INVALID_ARG_TYPE' }); assert.throws(() => t.mock.method(null, {}), { code: 'ERR_INVALID_ARG_TYPE' });
}); });
@ -1054,3 +1054,221 @@ test('setter() fails if getter options is true', (t) => {
t.mock.setter({}, 'method', { getter: true }); t.mock.setter({}, 'method', { getter: true });
}, /The property 'options\.setter' cannot be used with 'options\.getter'/); }, /The property 'options\.setter' cannot be used with 'options\.getter'/);
}); });
test('spies on a property', (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(obj.foo, 200);
assert.strictEqual(prop.mock.accesses.length, 3);
assert.strictEqual(prop.mock.accesses[1].type, 'set');
assert.strictEqual(prop.mock.accesses[1].value, 200);
assert.strictEqual(prop.mock.accesses[2].type, 'get');
assert.strictEqual(prop.mock.accesses[2].value, 200);
obj.foo = 300;
assert.strictEqual(obj.foo, 300);
assert.strictEqual(prop.mock.accessCount(), 5);
assert.strictEqual(prop.mock.accesses[3].type, 'set');
assert.strictEqual(prop.mock.accesses[3].value, 300);
assert.strictEqual(prop.mock.accesses[4].type, 'get');
assert.strictEqual(prop.mock.accesses[4].value, 300);
prop.mock.resetAccesses();
assert.strictEqual(prop.mock.accessCount(), 0);
obj.foo = 500;
assert.strictEqual(obj.foo, 500);
assert.strictEqual(prop.mock.accessCount(), 2);
assert.strictEqual(prop.mock.accesses[0].type, 'set');
assert.strictEqual(prop.mock.accesses[1].type, 'get');
prop.mock.resetAccesses();
assert.strictEqual(prop.mock.accessCount(), 0);
assert.strictEqual(obj.foo, 500);
assert.strictEqual(prop.mock.accessCount(), 1);
assert.strictEqual(prop.mock.accesses[0].type, 'get');
prop.mock.restore();
assert.strictEqual(obj.foo, 42);
});
test('spies on a property without providing a value', (t) => {
const obj = { foo: 123 };
const prop = t.mock.property(obj, 'foo');
assert.strictEqual(obj.foo, 123);
assert.strictEqual(prop.mock.accessCount(), 1);
assert.strictEqual(prop.mock.accesses[0].type, 'get');
assert.strictEqual(prop.mock.accesses[0].value, 123);
obj.foo = 456;
assert.strictEqual(obj.foo, 456);
assert.strictEqual(prop.mock.accessCount(), 3);
assert.strictEqual(prop.mock.accesses[1].type, 'set');
assert.strictEqual(prop.mock.accesses[1].value, 456);
assert.strictEqual(prop.mock.accesses[2].type, 'get');
assert.strictEqual(prop.mock.accesses[2].value, 456);
prop.mock.restore();
assert.strictEqual(obj.foo, 123);
});
test('spies on a symbol property', (t) => {
const symbol = Symbol('foo');
const obj = { [symbol]: 123 };
const prop = t.mock.property(obj, symbol, 456);
assert.strictEqual(obj[symbol], 456);
assert.strictEqual(prop.mock.accessCount(), 1);
obj[symbol] = 789;
assert.strictEqual(obj[symbol], 789);
assert.strictEqual(prop.mock.accessCount(), 3);
assert.strictEqual(prop.mock.accesses[1].type, 'set');
assert.strictEqual(prop.mock.accesses[2].type, 'get');
prop.mock.restore();
assert.strictEqual(obj[symbol], 123);
});
test('changes mocked property value dynamically', (t) => {
const obj = { foo: 1 };
const prop = t.mock.property(obj, 'foo', 2);
assert.strictEqual(obj.foo, 2);
assert.strictEqual(prop.mock.accessCount(), 1);
prop.mock.mockImplementation(99);
assert.strictEqual(obj.foo, 99);
assert.strictEqual(prop.mock.accessCount(), 3);
prop.mock.mockImplementationOnce(42);
assert.strictEqual(obj.foo, 42);
assert.strictEqual(obj.foo, 99);
assert.strictEqual(prop.mock.accessCount(), 5);
assert.throws(() => {
prop.mock.mockImplementationOnce(55, 4);
}, /The value of "onAccess" is out of range\. It must be >= 5/);
prop.mock.mockImplementationOnce(100, 5);
prop.mock.mockImplementationOnce(200, 6);
assert.strictEqual(obj.foo, 100);
assert.strictEqual(obj.foo, 200);
assert.strictEqual(obj.foo, 99);
assert.strictEqual(prop.mock.accessCount(), 8);
prop.mock.mockImplementationOnce(555, 10);
assert.strictEqual(obj.foo, 99);
assert.strictEqual(obj.foo, 99);
assert.strictEqual(obj.foo, 555);
prop.mock.mockImplementation(undefined);
assert.strictEqual(obj.foo, undefined);
});
test('mocks property value to undefined', (t) => {
const obj = { foo: 123 };
const prop = t.mock.property(obj, 'foo', undefined);
assert.strictEqual(obj.foo, undefined);
assert.strictEqual(prop.mock.accessCount(), 1);
assert.strictEqual(prop.mock.accesses[0].type, 'get');
assert.strictEqual(prop.mock.accesses[0].value, undefined);
prop.mock.restore();
assert.strictEqual(obj.foo, 123);
});
test('resetAccesses does not affect property value', (t) => {
const obj = { foo: 1 };
const prop = t.mock.property(obj, 'foo', 2);
obj.foo = 5;
assert.strictEqual(obj.foo, 5);
assert.strictEqual(prop.mock.accessCount(), 2);
prop.mock.resetAccesses();
assert.strictEqual(obj.foo, 5);
assert.strictEqual(prop.mock.accessCount(), 1);
assert.strictEqual(prop.mock.accesses[0].type, 'get');
});
test('restores original property value', (t) => {
const obj = {
foo: 10,
};
const prop = t.mock.property(obj, 'foo', 20);
assert.strictEqual(obj.foo, 20);
prop.mock.restore();
assert.strictEqual(obj.foo, 10);
});
test('throws if setting a non-writable property', (t) => {
const obj = {};
Object.defineProperty(obj, 'bar', {
value: 1,
writable: false,
configurable: true,
enumerable: true,
});
t.mock.property(obj, 'bar', 2);
assert.strictEqual(obj.bar, 2);
assert.throws(() => { obj.bar = 3; }, { code: 'ERR_INVALID_ARG_VALUE' });
});
test('throws if property does not exist', (t) => {
assert.throws(() => {
t.mock.property({}, 'doesNotExist', 1);
}, { code: 'ERR_INVALID_ARG_VALUE' });
});
test('throws if object is null', (t) => {
assert.throws(() => {
t.mock.property(null, 'foo', 1);
}, { code: 'ERR_INVALID_ARG_TYPE' });
});
test('local property mocks are auto restored after the test finishes', async (t) => {
const obj = { foo: 111, bar: 222 };
assert.strictEqual(obj.foo, 111);
assert.strictEqual(obj.bar, 222);
t.mock.property(obj, 'foo', 888);
assert.strictEqual(obj.foo, 888);
assert.strictEqual(obj.bar, 222);
t.beforeEach(() => {
assert.strictEqual(obj.foo, 888);
assert.strictEqual(obj.bar, 222);
});
t.afterEach(() => {
assert.strictEqual(obj.foo, 888);
assert.strictEqual(obj.bar, 999);
});
await t.test('creates property mocks that are auto restored', (t) => {
t.mock.property(obj, 'bar', 999);
assert.strictEqual(obj.foo, 888);
assert.strictEqual(obj.bar, 999);
});
assert.strictEqual(obj.foo, 888);
assert.strictEqual(obj.bar, 222);
});