Repository: nodejs/node
Pull request: test_runner: support object property mocking

<!--
Before submitting a pull request, please read:

- the CONTRIBUTING guide at https://github.com/nodejs/node/blob/HEAD/CONTRIBUTING.md
- the commit message formatting guidelines at
  https://github.com/nodejs/node/blob/HEAD/doc/contributing/pull-requests.md#commit-message-guidelines

For code changes:
1. Include tests for any bug fixes or new features.
2. Update documentation if relevant.
3. Ensure that `make -j4 test` (UNIX), or `vcbuild test` (Windows) passes.

If you believe this PR should be highlighted in the Node.js CHANGELOG
please add the `notable-change` label.

Developer's Certificate of Origin 1.1

By making a contribution to this project, I certify that:

(a) The contribution was created in whole or in part by me and I
    have the right to submit it under the open source license
    indicated in the file; or

(b) The contribution is based upon previous work that, to the best
    of my knowledge, is covered under an appropriate open source
    license and I have the right under that license to submit that
    work with modifications, whether created in whole or in part
    by me, under the same open source license (unless I am
    permitted to submit under a different license), as indicated
    in the file; or

(c) The contribution was provided directly to me by some other
    person who certified (a), (b) or (c) and I have not modified
    it.

(d) I understand and agree that this project and the contribution
    are public and that a record of the contribution (including all
    personal information I submit with it, including my sign-off) is
    maintained indefinitely and may be redistributed consistent with
    this project or the open source license(s) involved.
-->

Fixes: https://github.com/nodejs/node/issues/58322

---

This PR adds object property mocking to the test runner.

**Example usage:**
```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);
});
```

Changed files:
filename: doc/api/test.md
patch: @@ -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

filename: lib/internal/test_runner/mock/mock.js
patch: @@ -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.
    */

filename: test/parallel/test-runner-mocking.js
patch: @@ -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);
+});