Skip to content

Commit 62bb80c

Browse files
idango10richardlau
authored andcommitted
test_runner: support object property mocking
PR-URL: #58438 Fixes: #58322 Reviewed-By: Colin Ihrig <[email protected]> Reviewed-By: Chemi Atlow <[email protected]>
1 parent a93a8b5 commit 62bb80c

File tree

3 files changed

+501
-1
lines changed

3 files changed

+501
-1
lines changed

doc/api/test.md

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1971,6 +1971,87 @@ added: v22.3.0
19711971

19721972
Resets the implementation of the mock module.
19731973

1974+
## Class: `MockPropertyContext`
1975+
1976+
<!-- YAML
1977+
added: REPLACEME
1978+
-->
1979+
1980+
The `MockPropertyContext` class is used to inspect or manipulate the behavior
1981+
of property mocks created via the [`MockTracker`][] APIs.
1982+
1983+
### `ctx.accesses`
1984+
1985+
* {Array}
1986+
1987+
A getter that returns a copy of the internal array used to track accesses (get/set) to
1988+
the mocked property. Each entry in the array is an object with the following properties:
1989+
1990+
* `type` {string} Either `'get'` or `'set'`, indicating the type of access.
1991+
* `value` {any} The value that was read (for `'get'`) or written (for `'set'`).
1992+
* `stack` {Error} An `Error` object whose stack can be used to determine the
1993+
callsite of the mocked function invocation.
1994+
1995+
### `ctx.accessCount()`
1996+
1997+
* Returns: {integer} The number of times that the property was accessed (read or written).
1998+
1999+
This function returns the number of times that the property was accessed.
2000+
This function is more efficient than checking `ctx.accesses.length` because
2001+
`ctx.accesses` is a getter that creates a copy of the internal access tracking array.
2002+
2003+
### `ctx.mockImplementation(value)`
2004+
2005+
* `value` {any} The new value to be set as the mocked property value.
2006+
2007+
This function is used to change the value returned by the mocked property getter.
2008+
2009+
### `ctx.mockImplementationOnce(value[, onAccess])`
2010+
2011+
* `value` {any} The value to be used as the mock's
2012+
implementation for the invocation number specified by `onAccess`.
2013+
* `onAccess` {integer} The invocation number that will use `value`. If
2014+
the specified invocation has already occurred then an exception is thrown.
2015+
**Default:** The number of the next invocation.
2016+
2017+
This function is used to change the behavior of an existing mock for a single
2018+
invocation. Once invocation `onAccess` has occurred, the mock will revert to
2019+
whatever behavior it would have used had `mockImplementationOnce()` not been
2020+
called.
2021+
2022+
The following example creates a mock function using `t.mock.property()`, calls the
2023+
mock property, changes the mock implementation to a different value for the
2024+
next invocation, and then resumes its previous behavior.
2025+
2026+
```js
2027+
test('changes a mock behavior once', (t) => {
2028+
const obj = { foo: 1 };
2029+
2030+
const prop = t.mock.property(obj, 'foo', 5);
2031+
2032+
assert.strictEqual(obj.foo, 5);
2033+
prop.mock.mockImplementationOnce(25);
2034+
assert.strictEqual(obj.foo, 25);
2035+
assert.strictEqual(obj.foo, 5);
2036+
});
2037+
```
2038+
2039+
#### Caveat
2040+
2041+
For consistency with the rest of the mocking API, this function treats both property gets and sets
2042+
as accesses. If a property set occurs at the same access index, the "once" value will be consumed
2043+
by the set operation, and the mocked property value will be changed to the "once" value. This may
2044+
lead to unexpected behavior if you intend the "once" value to only be used for a get operation.
2045+
2046+
### `ctx.resetAccesses()`
2047+
2048+
Resets the access history of the mocked property.
2049+
2050+
### `ctx.restore()`
2051+
2052+
Resets the implementation of the mock property to its original behavior. The
2053+
mock can still be used after calling this function.
2054+
19742055
## Class: `MockTracker`
19752056

19762057
<!-- YAML
@@ -2173,6 +2254,43 @@ test('mocks a builtin module in both module systems', async (t) => {
21732254
});
21742255
```
21752256

2257+
### `mock.property(object, propertyName[, value])`
2258+
2259+
<!-- YAML
2260+
added: REPLACEME
2261+
-->
2262+
2263+
* `object` {Object} The object whose value is being mocked.
2264+
* `propertyName` {string|symbol} The identifier of the property on `object` to mock.
2265+
* `value` {any} An optional value used as the mock value
2266+
for `object[propertyName]`. **Default:** The original property value.
2267+
* Returns: {Proxy} A proxy to the mocked object. The mocked object contains a
2268+
special `mock` property, which is an instance of [`MockPropertyContext`][], and
2269+
can be used for inspecting and changing the behavior of the mocked property.
2270+
2271+
Creates a mock for a property value on an object. This allows you to track and control access to a specific property,
2272+
including how many times it is read (getter) or written (setter), and to restore the original value after mocking.
2273+
2274+
```js
2275+
test('mocks a property value', (t) => {
2276+
const obj = { foo: 42 };
2277+
const prop = t.mock.property(obj, 'foo', 100);
2278+
2279+
assert.strictEqual(obj.foo, 100);
2280+
assert.strictEqual(prop.mock.accessCount(), 1);
2281+
assert.strictEqual(prop.mock.accesses[0].type, 'get');
2282+
assert.strictEqual(prop.mock.accesses[0].value, 100);
2283+
2284+
obj.foo = 200;
2285+
assert.strictEqual(prop.mock.accessCount(), 2);
2286+
assert.strictEqual(prop.mock.accesses[1].type, 'set');
2287+
assert.strictEqual(prop.mock.accesses[1].value, 200);
2288+
2289+
prop.mock.restore();
2290+
assert.strictEqual(obj.foo, 42);
2291+
});
2292+
```
2293+
21762294
### `mock.reset()`
21772295

21782296
<!-- YAML
@@ -3703,6 +3821,7 @@ Can be used to abort test subtasks when the test has been aborted.
37033821
[`--test-update-snapshots`]: cli.md#--test-update-snapshots
37043822
[`--test`]: cli.md#--test
37053823
[`MockFunctionContext`]: #class-mockfunctioncontext
3824+
[`MockPropertyContext`]: #class-mockpropertycontext
37063825
[`MockTimers`]: #class-mocktimers
37073826
[`MockTracker.method`]: #mockmethodobject-methodname-implementation-options
37083827
[`MockTracker`]: #class-mocktracker

lib/internal/test_runner/mock/mock.js

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,134 @@ class MockModuleContext {
284284

285285
const { restore: restoreModule } = MockModuleContext.prototype;
286286

287+
class MockPropertyContext {
288+
#object;
289+
#propertyName;
290+
#value;
291+
#originalValue;
292+
#descriptor;
293+
#accesses;
294+
#onceValues;
295+
296+
constructor(object, propertyName, value) {
297+
this.#onceValues = new SafeMap();
298+
this.#accesses = [];
299+
this.#object = object;
300+
this.#propertyName = propertyName;
301+
this.#originalValue = object[propertyName];
302+
this.#value = arguments.length > 2 ? value : this.#originalValue;
303+
this.#descriptor = ObjectGetOwnPropertyDescriptor(object, propertyName);
304+
if (!this.#descriptor) {
305+
throw new ERR_INVALID_ARG_VALUE(
306+
'propertyName', propertyName, 'is not a property of the object',
307+
);
308+
}
309+
310+
const { configurable, enumerable } = this.#descriptor;
311+
ObjectDefineProperty(object, propertyName, {
312+
__proto__: null,
313+
configurable,
314+
enumerable,
315+
get: () => {
316+
const nextValue = this.#getAccessValue(this.#value);
317+
const access = {
318+
__proto__: null,
319+
type: 'get',
320+
value: nextValue,
321+
// eslint-disable-next-line no-restricted-syntax
322+
stack: new Error(),
323+
};
324+
ArrayPrototypePush(this.#accesses, access);
325+
return nextValue;
326+
},
327+
set: this.mockImplementation.bind(this),
328+
});
329+
}
330+
331+
/**
332+
* Gets an array of recorded accesses (get/set) to the property.
333+
* @returns {Array} An array of access records.
334+
*/
335+
get accesses() {
336+
return ArrayPrototypeSlice(this.#accesses, 0);
337+
}
338+
339+
/**
340+
* Retrieves the number of times the property was accessed (get or set).
341+
* @returns {number} The total number of accesses.
342+
*/
343+
accessCount() {
344+
return this.#accesses.length;
345+
}
346+
347+
/**
348+
* Sets a new value for the property.
349+
* @param {any} value - The new value to be set.
350+
* @throws {Error} If the property is not writable.
351+
*/
352+
mockImplementation(value) {
353+
if (!this.#descriptor.writable) {
354+
throw new ERR_INVALID_ARG_VALUE(
355+
'propertyName', this.#propertyName, 'cannot be set',
356+
);
357+
}
358+
const nextValue = this.#getAccessValue(value);
359+
const access = {
360+
__proto__: null,
361+
type: 'set',
362+
value: nextValue,
363+
// eslint-disable-next-line no-restricted-syntax
364+
stack: new Error(),
365+
};
366+
ArrayPrototypePush(this.#accesses, access);
367+
this.#value = nextValue;
368+
}
369+
370+
#getAccessValue(value) {
371+
const accessIndex = this.#accesses.length;
372+
let accessValue;
373+
if (this.#onceValues.has(accessIndex)) {
374+
accessValue = this.#onceValues.get(accessIndex);
375+
this.#onceValues.delete(accessIndex);
376+
} else {
377+
accessValue = value;
378+
}
379+
return accessValue;
380+
}
381+
382+
/**
383+
* Sets a value to be used only for the next access (get or set), or a specific access index.
384+
* @param {any} value - The value to be used once.
385+
* @param {number} [onAccess] - The access index to be replaced.
386+
*/
387+
mockImplementationOnce(value, onAccess) {
388+
const nextAccess = this.#accesses.length;
389+
const accessIndex = onAccess ?? nextAccess;
390+
validateInteger(accessIndex, 'onAccess', nextAccess);
391+
this.#onceValues.set(accessIndex, value);
392+
}
393+
394+
/**
395+
* Resets the recorded accesses to the property.
396+
*/
397+
resetAccesses() {
398+
this.#accesses = [];
399+
}
400+
401+
/**
402+
* Restores the original value of the property that was mocked.
403+
*/
404+
restore() {
405+
ObjectDefineProperty(this.#object, this.#propertyName, {
406+
__proto__: null,
407+
...this.#descriptor,
408+
value: this.#originalValue,
409+
});
410+
}
411+
}
412+
413+
const { restore: restoreProperty } = MockPropertyContext.prototype;
414+
287415
class MockTracker {
288416
#mocks = [];
289417
#timers;
@@ -573,6 +701,41 @@ class MockTracker {
573701
return ctx;
574702
}
575703

704+
/**
705+
* Creates a property tracker for a specified object.
706+
* @param {(object)} object - The object whose value is being tracked.
707+
* @param {string} propertyName - The identifier of the property on object to be tracked.
708+
* @param {any} value - An optional replacement value used as the mock value for object[valueName].
709+
* @returns {ProxyConstructor} The mock property tracker.
710+
*/
711+
property(
712+
object,
713+
propertyName,
714+
value,
715+
) {
716+
validateObject(object, 'object');
717+
validateStringOrSymbol(propertyName, 'propertyName');
718+
719+
const ctx = arguments.length > 2 ?
720+
new MockPropertyContext(object, propertyName, value) :
721+
new MockPropertyContext(object, propertyName);
722+
ArrayPrototypePush(this.#mocks, {
723+
__proto__: null,
724+
ctx,
725+
restore: restoreProperty,
726+
});
727+
728+
return new Proxy(object, {
729+
__proto__: null,
730+
get(target, property, receiver) {
731+
if (property === 'mock') {
732+
return ctx;
733+
}
734+
return ReflectGet(target, property, receiver);
735+
},
736+
});
737+
}
738+
576739
/**
577740
* Resets the mock tracker, restoring all mocks and clearing timers.
578741
*/

0 commit comments

Comments
 (0)