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:
parent
3aaa2ebe19
commit
905a722df3
119
doc/api/test.md
119
doc/api/test.md
@ -2048,6 +2048,87 @@ 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.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`
|
||||
|
||||
<!-- 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()`
|
||||
|
||||
<!-- 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`]: cli.md#--test
|
||||
[`MockFunctionContext`]: #class-mockfunctioncontext
|
||||
[`MockPropertyContext`]: #class-mockpropertycontext
|
||||
[`MockTimers`]: #class-mocktimers
|
||||
[`MockTracker.method`]: #mockmethodobject-methodname-implementation-options
|
||||
[`MockTracker`]: #class-mocktracker
|
||||
|
@ -284,6 +284,134 @@ class MockModuleContext {
|
||||
|
||||
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 {
|
||||
#mocks = [];
|
||||
#timers;
|
||||
@ -573,6 +701,41 @@ 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 - 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.
|
||||
*/
|
||||
|
@ -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' });
|
||||
});
|
||||
|
||||
@ -1054,3 +1054,221 @@ test('setter() fails if getter options is true', (t) => {
|
||||
t.mock.setter({}, 'method', { getter: true });
|
||||
}, /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);
|
||||
});
|
||||
|
Loading…
x
Reference in New Issue
Block a user