Skip to content

Commit 905a722

Browse files
authored
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 3aaa2eb commit 905a722

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
@@ -2048,6 +2048,87 @@ added:
20482048

20492049
Resets the implementation of the mock module.
20502050

2051+
## Class: `MockPropertyContext`
2052+
2053+
<!-- YAML
2054+
added: REPLACEME
2055+
-->
2056+
2057+
The `MockPropertyContext` class is used to inspect or manipulate the behavior
2058+
of property mocks created via the [`MockTracker`][] APIs.
2059+
2060+
### `ctx.accesses`
2061+
2062+
* {Array}
2063+
2064+
A getter that returns a copy of the internal array used to track accesses (get/set) to
2065+
the mocked property. Each entry in the array is an object with the following properties:
2066+
2067+
* `type` {string} Either `'get'` or `'set'`, indicating the type of access.
2068+
* `value` {any} The value that was read (for `'get'`) or written (for `'set'`).
2069+
* `stack` {Error} An `Error` object whose stack can be used to determine the
2070+
callsite of the mocked function invocation.
2071+
2072+
### `ctx.accessCount()`
2073+
2074+
* Returns: {integer} The number of times that the property was accessed (read or written).
2075+
2076+
This function returns the number of times that the property was accessed.
2077+
This function is more efficient than checking `ctx.accesses.length` because
2078+
`ctx.accesses` is a getter that creates a copy of the internal access tracking array.
2079+
2080+
### `ctx.mockImplementation(value)`
2081+
2082+
* `value` {any} The new value to be set as the mocked property value.
2083+
2084+
This function is used to change the value returned by the mocked property getter.
2085+
2086+
### `ctx.mockImplementationOnce(value[, onAccess])`
2087+
2088+
* `value` {any} The value to be used as the mock's
2089+
implementation for the invocation number specified by `onAccess`.
2090+
* `onAccess` {integer} The invocation number that will use `value`. If
2091+
the specified invocation has already occurred then an exception is thrown.
2092+
**Default:** The number of the next invocation.
2093+
2094+
This function is used to change the behavior of an existing mock for a single
2095+
invocation. Once invocation `onAccess` has occurred, the mock will revert to
2096+
whatever behavior it would have used had `mockImplementationOnce()` not been
2097+
called.
2098+
2099+
The following example creates a mock function using `t.mock.property()`, calls the
2100+
mock property, changes the mock implementation to a different value for the
2101+
next invocation, and then resumes its previous behavior.
2102+
2103+
```js
2104+
test('changes a mock behavior once', (t) => {
2105+
const obj = { foo: 1 };
2106+
2107+
const prop = t.mock.property(obj, 'foo', 5);
2108+
2109+
assert.strictEqual(obj.foo, 5);
2110+
prop.mock.mockImplementationOnce(25);
2111+
assert.strictEqual(obj.foo, 25);
2112+
assert.strictEqual(obj.foo, 5);
2113+
});
2114+
```
2115+
2116+
#### Caveat
2117+
2118+
For consistency with the rest of the mocking API, this function treats both property gets and sets
2119+
as accesses. If a property set occurs at the same access index, the "once" value will be consumed
2120+
by the set operation, and the mocked property value will be changed to the "once" value. This may
2121+
lead to unexpected behavior if you intend the "once" value to only be used for a get operation.
2122+
2123+
### `ctx.resetAccesses()`
2124+
2125+
Resets the access history of the mocked property.
2126+
2127+
### `ctx.restore()`
2128+
2129+
Resets the implementation of the mock property to its original behavior. The
2130+
mock can still be used after calling this function.
2131+
20512132
## Class: `MockTracker`
20522133

20532134
<!-- YAML
@@ -2252,6 +2333,43 @@ test('mocks a builtin module in both module systems', async (t) => {
22522333
});
22532334
```
22542335

2336+
### `mock.property(object, propertyName[, value])`
2337+
2338+
<!-- YAML
2339+
added: REPLACEME
2340+
-->
2341+
2342+
* `object` {Object} The object whose value is being mocked.
2343+
* `propertyName` {string|symbol} The identifier of the property on `object` to mock.
2344+
* `value` {any} An optional value used as the mock value
2345+
for `object[propertyName]`. **Default:** The original property value.
2346+
* Returns: {Proxy} A proxy to the mocked object. The mocked object contains a
2347+
special `mock` property, which is an instance of [`MockPropertyContext`][], and
2348+
can be used for inspecting and changing the behavior of the mocked property.
2349+
2350+
Creates a mock for a property value on an object. This allows you to track and control access to a specific property,
2351+
including how many times it is read (getter) or written (setter), and to restore the original value after mocking.
2352+
2353+
```js
2354+
test('mocks a property value', (t) => {
2355+
const obj = { foo: 42 };
2356+
const prop = t.mock.property(obj, 'foo', 100);
2357+
2358+
assert.strictEqual(obj.foo, 100);
2359+
assert.strictEqual(prop.mock.accessCount(), 1);
2360+
assert.strictEqual(prop.mock.accesses[0].type, 'get');
2361+
assert.strictEqual(prop.mock.accesses[0].value, 100);
2362+
2363+
obj.foo = 200;
2364+
assert.strictEqual(prop.mock.accessCount(), 2);
2365+
assert.strictEqual(prop.mock.accesses[1].type, 'set');
2366+
assert.strictEqual(prop.mock.accesses[1].value, 200);
2367+
2368+
prop.mock.restore();
2369+
assert.strictEqual(obj.foo, 42);
2370+
});
2371+
```
2372+
22552373
### `mock.reset()`
22562374

22572375
<!-- YAML
@@ -3790,6 +3908,7 @@ Can be used to abort test subtasks when the test has been aborted.
37903908
[`--test-update-snapshots`]: cli.md#--test-update-snapshots
37913909
[`--test`]: cli.md#--test
37923910
[`MockFunctionContext`]: #class-mockfunctioncontext
3911+
[`MockPropertyContext`]: #class-mockpropertycontext
37933912
[`MockTimers`]: #class-mocktimers
37943913
[`MockTracker.method`]: #mockmethodobject-methodname-implementation-options
37953914
[`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)