diff options
author | cjihrig <cjihrig@gmail.com> | 2022-04-05 01:36:40 +0300 |
---|---|---|
committer | cjihrig <cjihrig@gmail.com> | 2022-11-08 02:25:54 +0300 |
commit | 7c6682957b3c5f86d0616cebc0ad09cc2a1fd50d (patch) | |
tree | 4c225bb187aed436f36d617981addfc2611d32e4 | |
parent | 6ef4368db4cffb536d4040961ffabdd2c1fc2cd0 (diff) |
test_runner: support function mocking
This commit allows tests in the test runner to mock functions
and methods.
PR-URL: https://github.com/nodejs/node/pull/45326
Reviewed-By: Moshe Atlow <moshe@atlow.co.il>
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
-rw-r--r-- | doc/api/test.md | 360 | ||||
-rw-r--r-- | lib/internal/test_runner/mock.js | 295 | ||||
-rw-r--r-- | lib/internal/test_runner/test.js | 8 | ||||
-rw-r--r-- | lib/test.js | 19 | ||||
-rw-r--r-- | test/parallel/test-runner-mocking.js | 801 | ||||
-rw-r--r-- | tools/doc/type-parser.mjs | 2 |
6 files changed, 1483 insertions, 2 deletions
diff --git a/doc/api/test.md b/doc/api/test.md index e9e4113fa25..8d3e84e48c7 100644 --- a/doc/api/test.md +++ b/doc/api/test.md @@ -352,6 +352,89 @@ Otherwise, the test is considered to be a failure. Test files must be executable by Node.js, but are not required to use the `node:test` module internally. +## Mocking + +The `node:test` module supports mocking during testing via a top-level `mock` +object. The following example creates a spy on a function that adds two numbers +together. The spy is then used to assert that the function was called as +expected. + +```mjs +import assert from 'node:assert'; +import { mock, test } from 'node:test'; + +test('spies on a function', () => { + const sum = mock.fn((a, b) => { + return a + b; + }); + + assert.strictEqual(sum.mock.calls.length, 0); + assert.strictEqual(sum(3, 4), 7); + assert.strictEqual(sum.mock.calls.length, 1); + + const call = sum.mock.calls[0]; + assert.deepStrictEqual(call.arguments, [3, 4]); + assert.strictEqual(call.result, 7); + assert.strictEqual(call.error, undefined); + + // Reset the globally tracked mocks. + mock.reset(); +}); +``` + +```cjs +'use strict'; +const assert = require('node:assert'); +const { mock, test } = require('node:test'); + +test('spies on a function', () => { + const sum = mock.fn((a, b) => { + return a + b; + }); + + assert.strictEqual(sum.mock.calls.length, 0); + assert.strictEqual(sum(3, 4), 7); + assert.strictEqual(sum.mock.calls.length, 1); + + const call = sum.mock.calls[0]; + assert.deepStrictEqual(call.arguments, [3, 4]); + assert.strictEqual(call.result, 7); + assert.strictEqual(call.error, undefined); + + // Reset the globally tracked mocks. + mock.reset(); +}); +``` + +The same mocking functionality is also exposed on the [`TestContext`][] object +of each test. The following example creates a spy on an object method using the +API exposed on the `TestContext`. The benefit of mocking via the test context is +that the test runner will automatically restore all mocked functionality once +the test finishes. + +```js +test('spies on an object method', (t) => { + const number = { + value: 5, + add(a) { + return this.value + a; + }, + }; + + t.mock.method(number, 'add'); + assert.strictEqual(number.add.mock.calls.length, 0); + assert.strictEqual(number.add(3), 8); + assert.strictEqual(number.add.mock.calls.length, 1); + + const call = number.add.mock.calls[0]; + + assert.deepStrictEqual(call.arguments, [3]); + assert.strictEqual(call.result, 8); + assert.strictEqual(call.target, undefined); + assert.strictEqual(call.this, number); +}); +``` + ## `run([options])` <!-- YAML @@ -644,6 +727,281 @@ describe('tests', async () => { }); ``` +## Class: `MockFunctionContext` + +<!-- YAML +added: REPLACEME +--> + +The `MockFunctionContext` class is used to inspect or manipulate the behavior of +mocks created via the [`MockTracker`][] APIs. + +### `ctx.calls` + +<!-- YAML +added: REPLACEME +--> + +* {Array} + +A getter that returns a copy of the internal array used to track calls to the +mock. Each entry in the array is an object with the following properties. + +* `arguments` {Array} An array of the arguments passed to the mock function. +* `error` {any} If the mocked function threw then this property contains the + thrown value. **Default:** `undefined`. +* `result` {any} The value returned by the mocked function. +* `stack` {Error} An `Error` object whose stack can be used to determine the + callsite of the mocked function invocation. +* `target` {Function|undefined} If the mocked function is a constructor, this + field contains the class being constructed. Otherwise this will be + `undefined`. +* `this` {any} The mocked function's `this` value. + +### `ctx.callCount()` + +<!-- YAML +added: REPLACEME +--> + +* Returns: {integer} The number of times that this mock has been invoked. + +This function returns the number of times that this mock has been invoked. This +function is more efficient than checking `ctx.calls.length` because `ctx.calls` +is a getter that creates a copy of the internal call tracking array. + +### `ctx.mockImplementation(implementation)` + +<!-- YAML +added: REPLACEME +--> + +* `implementation` {Function|AsyncFunction} The function to be used as the + mock's new implementation. + +This function is used to change the behavior of an existing mock. + +The following example creates a mock function using `t.mock.fn()`, calls the +mock function, and then changes the mock implementation to a different function. + +```js +test('changes a mock behavior', (t) => { + let cnt = 0; + + function addOne() { + cnt++; + return cnt; + } + + function addTwo() { + cnt += 2; + return cnt; + } + + const fn = t.mock.fn(addOne); + + assert.strictEqual(fn(), 1); + fn.mock.mockImplementation(addTwo); + assert.strictEqual(fn(), 3); + assert.strictEqual(fn(), 5); +}); +``` + +### `ctx.mockImplementationOnce(implementation[, onCall])` + +<!-- YAML +added: REPLACEME +--> + +* `implementation` {Function|AsyncFunction} The function to be used as the + mock's implementation for the invocation number specified by `onCall`. +* `onCall` {integer} The invocation number that will use `implementation`. 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 `onCall` 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.fn()`, calls the +mock function, changes the mock implementation to a different function for the +next invocation, and then resumes its previous behavior. + +```js +test('changes a mock behavior once', (t) => { + let cnt = 0; + + function addOne() { + cnt++; + return cnt; + } + + function addTwo() { + cnt += 2; + return cnt; + } + + const fn = t.mock.fn(addOne); + + assert.strictEqual(fn(), 1); + fn.mock.mockImplementationOnce(addTwo); + assert.strictEqual(fn(), 3); + assert.strictEqual(fn(), 4); +}); +``` + +### `ctx.restore()` + +<!-- YAML +added: REPLACEME +--> + +Resets the implementation of the mock function to its original behavior. The +mock can still be used after calling this function. + +## Class: `MockTracker` + +<!-- YAML +added: REPLACEME +--> + +The `MockTracker` class is used to manage mocking functionality. The test runner +module provides a top level `mock` export which is a `MockTracker` instance. +Each test also provides its own `MockTracker` instance via the test context's +`mock` property. + +### `mock.fn([original[, implementation]][, options])` + +<!-- YAML +added: REPLACEME +--> + +* `original` {Function|AsyncFunction} An optional function to create a mock on. + **Default:** A no-op function. +* `implementation` {Function|AsyncFunction} An optional function used as the + mock implementation for `original`. This is useful for creating mocks that + exhibit one behavior for a specified number of calls and then restore the + behavior of `original`. **Default:** The function specified by `original`. +* `options` {Object} Optional configuration options for the mock function. The + following properties are supported: + * `times` {integer} The number of times that the mock will use the behavior of + `implementation`. Once the mock function has been called `times` times, it + will automatically restore the behavior of `original`. This value must be an + integer greater than zero. **Default:** `Infinity`. +* Returns: {Proxy} The mocked function. The mocked function contains a special + `mock` property, which is an instance of [`MockFunctionContext`][], and can + be used for inspecting and changing the behavior of the mocked function. + +This function is used to create a mock function. + +The following example creates a mock function that increments a counter by one +on each invocation. The `times` option is used to modify the mock behavior such +that the first two invocations add two to the counter instead of one. + +```js +test('mocks a counting function', (t) => { + let cnt = 0; + + function addOne() { + cnt++; + return cnt; + } + + function addTwo() { + cnt += 2; + return cnt; + } + + const fn = t.mock.fn(addOne, addTwo, { times: 2 }); + + assert.strictEqual(fn(), 2); + assert.strictEqual(fn(), 4); + assert.strictEqual(fn(), 5); + assert.strictEqual(fn(), 6); +}); +``` + +### `mock.method(object, methodName[, implementation][, options])` + +<!-- YAML +added: REPLACEME +--> + +* `object` {Object} The object whose method is being mocked. +* `methodName` {string|symbol} The identifier of the method on `object` to mock. + If `object[methodName]` is not a function, an error is thrown. +* `implementation` {Function|AsyncFunction} An optional function used as the + mock implementation for `object[methodName]`. **Default:** The original method + specified by `object[methodName]`. +* `options` {Object} Optional configuration options for the mock method. The + following properties are supported: + * `getter` {boolean} If `true`, `object[methodName]` is treated as a getter. + This option cannot be used with the `setter` option. **Default:** false. + * `setter` {boolean} If `true`, `object[methodName]` is treated as a setter. + This option cannot be used with the `getter` option. **Default:** false. + * `times` {integer} The number of times that the mock will use the behavior of + `implementation`. Once the mocked method has been called `times` times, it + will automatically restore the original behavior. This value must be an + integer greater than zero. **Default:** `Infinity`. +* Returns: {Proxy} The mocked method. The mocked method contains a special + `mock` property, which is an instance of [`MockFunctionContext`][], and can + be used for inspecting and changing the behavior of the mocked method. + +This function is used to create a mock on an existing object method. The +following example demonstrates how a mock is created on an existing object +method. + +```js +test('spies on an object method', (t) => { + const number = { + value: 5, + subtract(a) { + return this.value - a; + }, + }; + + t.mock.method(number, 'subtract'); + assert.strictEqual(number.subtract.mock.calls.length, 0); + assert.strictEqual(number.subtract(3), 2); + assert.strictEqual(number.subtract.mock.calls.length, 1); + + const call = number.subtract.mock.calls[0]; + + assert.deepStrictEqual(call.arguments, [3]); + assert.strictEqual(call.result, 2); + assert.strictEqual(call.error, undefined); + assert.strictEqual(call.target, undefined); + assert.strictEqual(call.this, number); +}); +``` + +### `mock.reset()` + +<!-- YAML +added: REPLACEME +--> + +This function restores the default behavior of all mocks that were previously +created by this `MockTracker` and disassociates the mocks from the +`MockTracker` instance. Once disassociated, the mocks can still be used, but the +`MockTracker` instance can no longer be used to reset their behavior or +otherwise interact with them. + +After each test completes, this function is called on the test context's +`MockTracker`. If the global `MockTracker` is used extensively, calling this +function manually is recommended. + +### `mock.restoreAll()` + +<!-- YAML +added: REPLACEME +--> + +This function restores the default behavior of all mocks that were previously +created by this `MockTracker`. Unlike `mock.reset()`, `mock.restoreAll()` does +not disassociate the mocks from the `MockTracker` instance. + ## Class: `TapStream` <!-- YAML @@ -979,6 +1337,8 @@ added: [`--test-name-pattern`]: cli.md#--test-name-pattern [`--test-only`]: cli.md#--test-only [`--test`]: cli.md#--test +[`MockFunctionContext`]: #class-mockfunctioncontext +[`MockTracker`]: #class-mocktracker [`SuiteContext`]: #class-suitecontext [`TestContext`]: #class-testcontext [`context.diagnostic`]: #contextdiagnosticmessage diff --git a/lib/internal/test_runner/mock.js b/lib/internal/test_runner/mock.js new file mode 100644 index 00000000000..6b1d7e0c570 --- /dev/null +++ b/lib/internal/test_runner/mock.js @@ -0,0 +1,295 @@ +'use strict'; +const { + ArrayPrototypePush, + ArrayPrototypeSlice, + Error, + FunctionPrototypeCall, + ObjectDefineProperty, + ObjectGetOwnPropertyDescriptor, + Proxy, + ReflectApply, + ReflectConstruct, + ReflectGet, + SafeMap, +} = primordials; +const { + codes: { + ERR_INVALID_ARG_TYPE, + ERR_INVALID_ARG_VALUE, + }, +} = require('internal/errors'); +const { kEmptyObject } = require('internal/util'); +const { + validateBoolean, + validateFunction, + validateInteger, + validateObject, +} = require('internal/validators'); + +function kDefaultFunction() {} + +class MockFunctionContext { + #calls; + #mocks; + #implementation; + #restore; + #times; + + constructor(implementation, restore, times) { + this.#calls = []; + this.#mocks = new SafeMap(); + this.#implementation = implementation; + this.#restore = restore; + this.#times = times; + } + + get calls() { + return ArrayPrototypeSlice(this.#calls, 0); + } + + callCount() { + return this.#calls.length; + } + + mockImplementation(implementation) { + validateFunction(implementation, 'implementation'); + this.#implementation = implementation; + } + + mockImplementationOnce(implementation, onCall) { + validateFunction(implementation, 'implementation'); + const nextCall = this.#calls.length; + const call = onCall ?? nextCall; + validateInteger(call, 'onCall', nextCall); + this.#mocks.set(call, implementation); + } + + restore() { + const { descriptor, object, original, methodName } = this.#restore; + + if (typeof methodName === 'string') { + // This is an object method spy. + ObjectDefineProperty(object, methodName, descriptor); + } else { + // This is a bare function spy. There isn't much to do here but make + // the mock call the original function. + this.#implementation = original; + } + } + + trackCall(call) { + ArrayPrototypePush(this.#calls, call); + } + + nextImpl() { + const nextCall = this.#calls.length; + const mock = this.#mocks.get(nextCall); + const impl = mock ?? this.#implementation; + + if (nextCall + 1 === this.#times) { + this.restore(); + } + + this.#mocks.delete(nextCall); + return impl; + } +} + +const { nextImpl, restore, trackCall } = MockFunctionContext.prototype; +delete MockFunctionContext.prototype.trackCall; +delete MockFunctionContext.prototype.nextImpl; + +class MockTracker { + #mocks = []; + + fn( + original = function() {}, + implementation = original, + options = kEmptyObject, + ) { + if (original !== null && typeof original === 'object') { + options = original; + original = function() {}; + implementation = original; + } else if (implementation !== null && typeof implementation === 'object') { + options = implementation; + implementation = original; + } + + validateFunction(original, 'original'); + validateFunction(implementation, 'implementation'); + validateObject(options, 'options'); + const { times = Infinity } = options; + validateTimes(times, 'options.times'); + const ctx = new MockFunctionContext(implementation, { original }, times); + return this.#setupMock(ctx, original); + } + + method( + object, + methodName, + implementation = kDefaultFunction, + options = kEmptyObject, + ) { + validateObject(object, 'object'); + validateStringOrSymbol(methodName, 'methodName'); + + if (implementation !== null && typeof implementation === 'object') { + options = implementation; + implementation = kDefaultFunction; + } + + validateFunction(implementation, 'implementation'); + validateObject(options, 'options'); + + const { + getter = false, + setter = false, + times = Infinity, + } = options; + + validateBoolean(getter, 'options.getter'); + validateBoolean(setter, 'options.setter'); + validateTimes(times, 'options.times'); + + if (setter && getter) { + throw new ERR_INVALID_ARG_VALUE( + 'options.setter', setter, "cannot be used with 'options.getter'" + ); + } + + const descriptor = ObjectGetOwnPropertyDescriptor(object, methodName); + let original; + + if (getter) { + original = descriptor?.get; + } else if (setter) { + original = descriptor?.set; + } else { + original = descriptor?.value; + } + + if (typeof original !== 'function') { + throw new ERR_INVALID_ARG_VALUE( + 'methodName', original, 'must be a method' + ); + } + + const restore = { descriptor, object, methodName }; + const impl = implementation === kDefaultFunction ? + original : implementation; + const ctx = new MockFunctionContext(impl, restore, times); + const mock = this.#setupMock(ctx, original); + const mockDescriptor = { + __proto__: null, + configurable: descriptor.configurable, + enumerable: descriptor.enumerable, + }; + + if (getter) { + mockDescriptor.get = mock; + mockDescriptor.set = descriptor.set; + } else if (setter) { + mockDescriptor.get = descriptor.get; + mockDescriptor.set = mock; + } else { + mockDescriptor.writable = descriptor.writable; + mockDescriptor.value = mock; + } + + ObjectDefineProperty(object, methodName, mockDescriptor); + + return mock; + } + + reset() { + this.restoreAll(); + this.#mocks = []; + } + + restoreAll() { + for (let i = 0; i < this.#mocks.length; i++) { + FunctionPrototypeCall(restore, this.#mocks[i]); + } + } + + #setupMock(ctx, fnToMatch) { + const mock = new Proxy(fnToMatch, { + __proto__: null, + apply(_fn, thisArg, argList) { + const fn = FunctionPrototypeCall(nextImpl, ctx); + let result; + let error; + + try { + result = ReflectApply(fn, thisArg, argList); + } catch (err) { + error = err; + throw err; + } finally { + FunctionPrototypeCall(trackCall, ctx, { + arguments: argList, + error, + result, + // eslint-disable-next-line no-restricted-syntax + stack: new Error(), + target: undefined, + this: thisArg, + }); + } + + return result; + }, + construct(target, argList, newTarget) { + const realTarget = FunctionPrototypeCall(nextImpl, ctx); + let result; + let error; + + try { + result = ReflectConstruct(realTarget, argList, newTarget); + } catch (err) { + error = err; + throw err; + } finally { + FunctionPrototypeCall(trackCall, ctx, { + arguments: argList, + error, + result, + // eslint-disable-next-line no-restricted-syntax + stack: new Error(), + target, + this: result, + }); + } + + return result; + }, + get(target, property, receiver) { + if (property === 'mock') { + return ctx; + } + + return ReflectGet(target, property, receiver); + }, + }); + + ArrayPrototypePush(this.#mocks, ctx); + return mock; + } +} + +function validateStringOrSymbol(value, name) { + if (typeof value !== 'string' && typeof value !== 'symbol') { + throw new ERR_INVALID_ARG_TYPE(name, ['string', 'symbol'], value); + } +} + +function validateTimes(value, name) { + if (value === Infinity) { + return; + } + + validateInteger(value, name, 1); +} + +module.exports = { MockTracker }; diff --git a/lib/internal/test_runner/test.js b/lib/internal/test_runner/test.js index 6d30f3aaa66..2176b87fbf1 100644 --- a/lib/internal/test_runner/test.js +++ b/lib/internal/test_runner/test.js @@ -32,6 +32,7 @@ const { AbortError, } = require('internal/errors'); const { getOptionValue } = require('internal/options'); +const { MockTracker } = require('internal/test_runner/mock'); const { TapStream } = require('internal/test_runner/tap_stream'); const { convertStringToRegExp, @@ -112,6 +113,11 @@ class TestContext { this.#test.diagnostic(message); } + get mock() { + this.#test.mock ??= new MockTracker(); + return this.#test.mock; + } + runOnly(value) { this.#test.runOnlySubtests = !!value; } @@ -239,6 +245,7 @@ class Test extends AsyncResource { this.#outerSignal?.addEventListener('abort', this.#abortHandler); this.fn = fn; + this.mock = null; this.name = name; this.parent = parent; this.cancelled = false; @@ -593,6 +600,7 @@ class Test extends AsyncResource { } this.#outerSignal?.removeEventListener('abort', this.#abortHandler); + this.mock?.reset(); if (this.parent !== null) { this.parent.activeSubtests--; diff --git a/lib/test.js b/lib/test.js index 767d0e83a5f..dc4045622a8 100644 --- a/lib/test.js +++ b/lib/test.js @@ -1,5 +1,5 @@ 'use strict'; -const { ObjectAssign } = primordials; +const { ObjectAssign, ObjectDefineProperty } = primordials; const { test, describe, it, before, after, beforeEach, afterEach } = require('internal/test_runner/harness'); const { run } = require('internal/test_runner/runner'); @@ -14,3 +14,20 @@ ObjectAssign(module.exports, { run, test, }); + +let lazyMock; + +ObjectDefineProperty(module.exports, 'mock', { + __proto__: null, + configurable: true, + enumerable: true, + get() { + if (lazyMock === undefined) { + const { MockTracker } = require('internal/test_runner/mock'); + + lazyMock = new MockTracker(); + } + + return lazyMock; + }, +}); diff --git a/test/parallel/test-runner-mocking.js b/test/parallel/test-runner-mocking.js new file mode 100644 index 00000000000..6d3233ec0ed --- /dev/null +++ b/test/parallel/test-runner-mocking.js @@ -0,0 +1,801 @@ +'use strict'; +const common = require('../common'); +const assert = require('node:assert'); +const { mock, test } = require('node:test'); + +test('spies on a function', (t) => { + const sum = t.mock.fn((arg1, arg2) => { + return arg1 + arg2; + }); + + assert.strictEqual(sum.mock.calls.length, 0); + assert.strictEqual(sum(3, 4), 7); + assert.strictEqual(sum.call(1000, 9, 1), 10); + assert.strictEqual(sum.mock.calls.length, 2); + + let call = sum.mock.calls[0]; + assert.deepStrictEqual(call.arguments, [3, 4]); + assert.strictEqual(call.error, undefined); + assert.strictEqual(call.result, 7); + assert.strictEqual(call.target, undefined); + assert.strictEqual(call.this, undefined); + + call = sum.mock.calls[1]; + assert.deepStrictEqual(call.arguments, [9, 1]); + assert.strictEqual(call.error, undefined); + assert.strictEqual(call.result, 10); + assert.strictEqual(call.target, undefined); + assert.strictEqual(call.this, 1000); +}); + +test('spies on a bound function', (t) => { + const bound = function(arg1, arg2) { + return this + arg1 + arg2; + }.bind(50); + const sum = t.mock.fn(bound); + + assert.strictEqual(sum.mock.calls.length, 0); + assert.strictEqual(sum(3, 4), 57); + assert.strictEqual(sum(9, 1), 60); + assert.strictEqual(sum.mock.calls.length, 2); + + let call = sum.mock.calls[0]; + assert.deepStrictEqual(call.arguments, [3, 4]); + assert.strictEqual(call.result, 57); + assert.strictEqual(call.target, undefined); + assert.strictEqual(call.this, undefined); + + call = sum.mock.calls[1]; + assert.deepStrictEqual(call.arguments, [9, 1]); + assert.strictEqual(call.result, 60); + assert.strictEqual(call.target, undefined); + assert.strictEqual(call.this, undefined); +}); + +test('spies on a constructor', (t) => { + class ParentClazz { + constructor(c) { + this.c = c; + } + } + + class Clazz extends ParentClazz { + #privateValue; + + constructor(a, b) { + super(a + b); + this.a = a; + this.#privateValue = b; + } + + getPrivateValue() { + return this.#privateValue; + } + } + + const ctor = t.mock.fn(Clazz); + const instance = new ctor(42, 85); + + assert(instance instanceof Clazz); + assert(instance instanceof ParentClazz); + assert.strictEqual(instance.a, 42); + assert.strictEqual(instance.getPrivateValue(), 85); + assert.strictEqual(instance.c, 127); + assert.strictEqual(ctor.mock.calls.length, 1); + + const call = ctor.mock.calls[0]; + + assert.deepStrictEqual(call.arguments, [42, 85]); + assert.strictEqual(call.error, undefined); + assert.strictEqual(call.result, instance); + assert.strictEqual(call.target, Clazz); + assert.strictEqual(call.this, instance); +}); + +test('a no-op spy function is created by default', (t) => { + const fn = t.mock.fn(); + + assert.strictEqual(fn.mock.calls.length, 0); + assert.strictEqual(fn(3, 4), undefined); + assert.strictEqual(fn.mock.calls.length, 1); + + const call = fn.mock.calls[0]; + assert.deepStrictEqual(call.arguments, [3, 4]); + assert.strictEqual(call.result, undefined); + assert.strictEqual(call.target, undefined); + assert.strictEqual(call.this, undefined); +}); + +test('internal no-op function can be reused', (t) => { + const fn1 = t.mock.fn(); + fn1.prop = true; + const fn2 = t.mock.fn(); + + fn1(1); + fn2(2); + fn1(3); + + assert.notStrictEqual(fn1.mock, fn2.mock); + assert.strictEqual(fn1.mock.calls.length, 2); + assert.strictEqual(fn2.mock.calls.length, 1); + assert.strictEqual(fn1.prop, true); + assert.strictEqual(fn2.prop, undefined); +}); + +test('functions can be mocked multiple times at once', (t) => { + function sum(a, b) { + return a + b; + } + + function difference(a, b) { + return a - b; + } + + function product(a, b) { + return a * b; + } + + const fn1 = t.mock.fn(sum, difference); + const fn2 = t.mock.fn(sum, product); + + assert.strictEqual(fn1(5, 3), 2); + assert.strictEqual(fn2(5, 3), 15); + assert.strictEqual(fn2(4, 2), 8); + assert(!('mock' in sum)); + assert(!('mock' in difference)); + assert(!('mock' in product)); + assert.notStrictEqual(fn1.mock, fn2.mock); + assert.strictEqual(fn1.mock.calls.length, 1); + assert.strictEqual(fn2.mock.calls.length, 2); +}); + +test('internal no-op function can be reused as methods', (t) => { + const obj = { + _foo: 5, + _bar: 9, + foo() { + return this._foo; + }, + bar() { + return this._bar; + }, + }; + + t.mock.method(obj, 'foo'); + obj.foo.prop = true; + t.mock.method(obj, 'bar'); + assert.strictEqual(obj.foo(), 5); + assert.strictEqual(obj.bar(), 9); + assert.strictEqual(obj.bar(), 9); + assert.notStrictEqual(obj.foo.mock, obj.bar.mock); + assert.strictEqual(obj.foo.mock.calls.length, 1); + assert.strictEqual(obj.bar.mock.calls.length, 2); + assert.strictEqual(obj.foo.prop, true); + assert.strictEqual(obj.bar.prop, undefined); +}); + +test('methods can be mocked multiple times but not at the same time', (t) => { + const obj = { + offset: 3, + sum(a, b) { + return this.offset + a + b; + }, + }; + + function difference(a, b) { + return this.offset + (a - b); + } + + function product(a, b) { + return this.offset + a * b; + } + + const originalSum = obj.sum; + const fn1 = t.mock.method(obj, 'sum', difference); + + assert.strictEqual(obj.sum(5, 3), 5); + assert.strictEqual(obj.sum(5, 1), 7); + assert.strictEqual(obj.sum, fn1); + assert.notStrictEqual(fn1.mock, undefined); + assert.strictEqual(originalSum.mock, undefined); + assert.strictEqual(difference.mock, undefined); + assert.strictEqual(product.mock, undefined); + assert.strictEqual(fn1.mock.calls.length, 2); + + const fn2 = t.mock.method(obj, 'sum', product); + + assert.strictEqual(obj.sum(5, 3), 18); + assert.strictEqual(obj.sum, fn2); + assert.notStrictEqual(fn1, fn2); + assert.strictEqual(fn2.mock.calls.length, 1); + + obj.sum.mock.restore(); + assert.strictEqual(obj.sum, fn1); + obj.sum.mock.restore(); + assert.strictEqual(obj.sum, originalSum); + assert.strictEqual(obj.sum.mock, undefined); +}); + +test('spies on an object method', (t) => { + const obj = { + prop: 5, + method(a, b) { + return a + b + this.prop; + }, + }; + + assert.strictEqual(obj.method(1, 3), 9); + t.mock.method(obj, 'method'); + assert.strictEqual(obj.method.mock.calls.length, 0); + assert.strictEqual(obj.method(1, 3), 9); + + const call = obj.method.mock.calls[0]; + + assert.deepStrictEqual(call.arguments, [1, 3]); + assert.strictEqual(call.result, 9); + assert.strictEqual(call.target, undefined); + assert.strictEqual(call.this, obj); + + assert.strictEqual(obj.method.mock.restore(), undefined); + assert.strictEqual(obj.method(1, 3), 9); + assert.strictEqual(obj.method.mock, undefined); +}); + +test('spies on a getter', (t) => { + const obj = { + prop: 5, + get method() { + return this.prop; + }, + }; + + assert.strictEqual(obj.method, 5); + + const getter = t.mock.method(obj, 'method', { getter: true }); + + assert.strictEqual(getter.mock.calls.length, 0); + assert.strictEqual(obj.method, 5); + + const call = getter.mock.calls[0]; + + assert.deepStrictEqual(call.arguments, []); + assert.strictEqual(call.result, 5); + assert.strictEqual(call.target, undefined); + assert.strictEqual(call.this, obj); + + assert.strictEqual(getter.mock.restore(), undefined); + assert.strictEqual(obj.method, 5); +}); + +test('spies on a setter', (t) => { + const obj = { + prop: 100, + // eslint-disable-next-line accessor-pairs + set method(val) { + this.prop = val; + }, + }; + + assert.strictEqual(obj.prop, 100); + obj.method = 88; + assert.strictEqual(obj.prop, 88); + + const setter = t.mock.method(obj, 'method', { setter: true }); + + assert.strictEqual(setter.mock.calls.length, 0); + obj.method = 77; + assert.strictEqual(obj.prop, 77); + assert.strictEqual(setter.mock.calls.length, 1); + + const call = setter.mock.calls[0]; + + assert.deepStrictEqual(call.arguments, [77]); + assert.strictEqual(call.result, undefined); + assert.strictEqual(call.target, undefined); + assert.strictEqual(call.this, obj); + + assert.strictEqual(setter.mock.restore(), undefined); + assert.strictEqual(obj.prop, 77); + obj.method = 65; + assert.strictEqual(obj.prop, 65); +}); + +test('spy functions can be bound', (t) => { + const sum = t.mock.fn(function(arg1, arg2) { + return this + arg1 + arg2; + }); + const bound = sum.bind(1000); + + assert.strictEqual(bound(9, 1), 1010); + assert.strictEqual(sum.mock.calls.length, 1); + + const call = sum.mock.calls[0]; + assert.deepStrictEqual(call.arguments, [9, 1]); + assert.strictEqual(call.result, 1010); + assert.strictEqual(call.target, undefined); + assert.strictEqual(call.this, 1000); + + assert.strictEqual(sum.mock.restore(), undefined); + assert.strictEqual(sum.bind(0)(2, 11), 13); +}); + +test('mocked functions report thrown errors', (t) => { + const testError = new Error('test error'); + const fn = t.mock.fn(() => { + throw testError; + }); + + assert.throws(fn, /test error/); + assert.strictEqual(fn.mock.calls.length, 1); + + const call = fn.mock.calls[0]; + + assert.deepStrictEqual(call.arguments, []); + assert.strictEqual(call.error, testError); + assert.strictEqual(call.result, undefined); + assert.strictEqual(call.target, undefined); + assert.strictEqual(call.this, undefined); +}); + +test('mocked constructors report thrown errors', (t) => { + const testError = new Error('test error'); + class Clazz { + constructor() { + throw testError; + } + } + + const ctor = t.mock.fn(Clazz); + + assert.throws(() => { + new ctor(); + }, /test error/); + assert.strictEqual(ctor.mock.calls.length, 1); + + const call = ctor.mock.calls[0]; + + assert.deepStrictEqual(call.arguments, []); + assert.strictEqual(call.error, testError); + assert.strictEqual(call.result, undefined); + assert.strictEqual(call.target, Clazz); + assert.strictEqual(call.this, undefined); +}); + +test('mocks a function', (t) => { + const sum = (arg1, arg2) => arg1 + arg2; + const difference = (arg1, arg2) => arg1 - arg2; + const fn = t.mock.fn(sum, difference); + + assert.strictEqual(fn.mock.calls.length, 0); + assert.strictEqual(fn(3, 4), -1); + assert.strictEqual(fn(9, 1), 8); + assert.strictEqual(fn.mock.calls.length, 2); + + let call = fn.mock.calls[0]; + assert.deepStrictEqual(call.arguments, [3, 4]); + assert.strictEqual(call.result, -1); + assert.strictEqual(call.target, undefined); + assert.strictEqual(call.this, undefined); + + call = fn.mock.calls[1]; + assert.deepStrictEqual(call.arguments, [9, 1]); + assert.strictEqual(call.result, 8); + assert.strictEqual(call.target, undefined); + assert.strictEqual(call.this, undefined); + + assert.strictEqual(fn.mock.restore(), undefined); + assert.strictEqual(fn(2, 11), 13); +}); + +test('mocks a constructor', (t) => { + class ParentClazz { + constructor(c) { + this.c = c; + } + } + + class Clazz extends ParentClazz { + #privateValue; + + constructor(a, b) { + super(a + b); + this.a = a; + this.#privateValue = b; + } + + getPrivateValue() { + return this.#privateValue; + } + } + + class MockClazz { + #privateValue; + + constructor(z) { + this.z = z; + } + } + + const ctor = t.mock.fn(Clazz, MockClazz); + const instance = new ctor(42, 85); + + assert(!(instance instanceof MockClazz)); + assert(instance instanceof Clazz); + assert(instance instanceof ParentClazz); + assert.strictEqual(instance.a, undefined); + assert.strictEqual(instance.c, undefined); + assert.strictEqual(instance.z, 42); + assert.strictEqual(ctor.mock.calls.length, 1); + + const call = ctor.mock.calls[0]; + + assert.deepStrictEqual(call.arguments, [42, 85]); + assert.strictEqual(call.result, instance); + assert.strictEqual(call.target, Clazz); + assert.strictEqual(call.this, instance); + assert.throws(() => { + instance.getPrivateValue(); + }, /TypeError: Cannot read private member #privateValue /); +}); + +test('mocks an object method', (t) => { + const obj = { + prop: 5, + method(a, b) { + return a + b + this.prop; + }, + }; + + function mockMethod(a) { + return a + this.prop; + } + + assert.strictEqual(obj.method(1, 3), 9); + t.mock.method(obj, 'method', mockMethod); + assert.strictEqual(obj.method.mock.calls.length, 0); + assert.strictEqual(obj.method(1, 3), 6); + + const call = obj.method.mock.calls[0]; + + assert.deepStrictEqual(call.arguments, [1, 3]); + assert.strictEqual(call.result, 6); + assert.strictEqual(call.target, undefined); + assert.strictEqual(call.this, obj); + + assert.strictEqual(obj.method.mock.restore(), undefined); + assert.strictEqual(obj.method(1, 3), 9); + assert.strictEqual(obj.method.mock, undefined); +}); + +test('mocks a getter', (t) => { + const obj = { + prop: 5, + get method() { + return this.prop; + }, + }; + + function mockMethod() { + return this.prop - 1; + } + + assert.strictEqual(obj.method, 5); + + const getter = t.mock.method(obj, 'method', mockMethod, { getter: true }); + + assert.strictEqual(getter.mock.calls.length, 0); + assert.strictEqual(obj.method, 4); + + const call = getter.mock.calls[0]; + + assert.deepStrictEqual(call.arguments, []); + assert.strictEqual(call.result, 4); + assert.strictEqual(call.target, undefined); + assert.strictEqual(call.this, obj); + + assert.strictEqual(getter.mock.restore(), undefined); + assert.strictEqual(obj.method, 5); +}); + +test('mocks a setter', (t) => { + const obj = { + prop: 100, + // eslint-disable-next-line accessor-pairs + set method(val) { + this.prop = val; + }, + }; + + function mockMethod(val) { + this.prop = -val; + } + + assert.strictEqual(obj.prop, 100); + obj.method = 88; + assert.strictEqual(obj.prop, 88); + + const setter = t.mock.method(obj, 'method', mockMethod, { setter: true }); + + assert.strictEqual(setter.mock.calls.length, 0); + obj.method = 77; + assert.strictEqual(obj.prop, -77); + assert.strictEqual(setter.mock.calls.length, 1); + + const call = setter.mock.calls[0]; + + assert.deepStrictEqual(call.arguments, [77]); + assert.strictEqual(call.result, undefined); + assert.strictEqual(call.target, undefined); + assert.strictEqual(call.this, obj); + + assert.strictEqual(setter.mock.restore(), undefined); + assert.strictEqual(obj.prop, -77); + obj.method = 65; + assert.strictEqual(obj.prop, 65); +}); + +test('mocked functions match name and length', (t) => { + function getNameAndLength(fn) { + return { + name: Object.getOwnPropertyDescriptor(fn, 'name'), + length: Object.getOwnPropertyDescriptor(fn, 'length'), + }; + } + + function func1() {} + const func2 = function(a) {}; // eslint-disable-line func-style + const arrow = (a, b, c) => {}; + const obj = { method(a, b) {} }; + + assert.deepStrictEqual( + getNameAndLength(func1), + getNameAndLength(t.mock.fn(func1)) + ); + assert.deepStrictEqual( + getNameAndLength(func2), + getNameAndLength(t.mock.fn(func2)) + ); + assert.deepStrictEqual( + getNameAndLength(arrow), + getNameAndLength(t.mock.fn(arrow)) + ); + assert.deepStrictEqual( + getNameAndLength(obj.method), + getNameAndLength(t.mock.method(obj, 'method', func1)) + ); +}); + +test('method() fails if method cannot be redefined', (t) => { + const obj = { + prop: 5, + }; + + Object.defineProperty(obj, 'method', { + configurable: false, + value(a, b) { + return a + b + this.prop; + } + }); + + function mockMethod(a) { + return a + this.prop; + } + + assert.throws(() => { + t.mock.method(obj, 'method', mockMethod); + }, /Cannot redefine property: method/); + assert.strictEqual(obj.method(1, 3), 9); + assert.strictEqual(obj.method.mock, undefined); +}); + +test('method() fails if field is a property instead of a method', (t) => { + const obj = { + prop: 5, + method: 100, + }; + + function mockMethod(a) { + return a + this.prop; + } + + assert.throws(() => { + t.mock.method(obj, 'method', mockMethod); + }, /The argument 'methodName' must be a method/); + assert.strictEqual(obj.method, 100); + assert.strictEqual(obj.method.mock, undefined); +}); + +test('mocks can be auto-restored', (t) => { + let cnt = 0; + + function addOne() { + cnt++; + return cnt; + } + + function addTwo() { + cnt += 2; + return cnt; + } + + const fn = t.mock.fn(addOne, addTwo, { times: 2 }); + + assert.strictEqual(fn(), 2); + assert.strictEqual(fn(), 4); + assert.strictEqual(fn(), 5); + assert.strictEqual(fn(), 6); +}); + +test('mock implementation can be changed dynamically', (t) => { + let cnt = 0; + + function addOne() { + cnt++; + return cnt; + } + + function addTwo() { + cnt += 2; + return cnt; + } + + function addThree() { + cnt += 3; + return cnt; + } + + const fn = t.mock.fn(addOne); + + assert.strictEqual(fn.mock.callCount(), 0); + assert.strictEqual(fn(), 1); + assert.strictEqual(fn(), 2); + assert.strictEqual(fn(), 3); + assert.strictEqual(fn.mock.callCount(), 3); + + fn.mock.mockImplementation(addTwo); + assert.strictEqual(fn(), 5); + assert.strictEqual(fn(), 7); + assert.strictEqual(fn.mock.callCount(), 5); + + fn.mock.restore(); + assert.strictEqual(fn(), 8); + assert.strictEqual(fn(), 9); + assert.strictEqual(fn.mock.callCount(), 7); + + assert.throws(() => { + fn.mock.mockImplementationOnce(common.mustNotCall(), 6); + }, /The value of "onCall" is out of range\. It must be >= 7/); + + fn.mock.mockImplementationOnce(addThree, 7); + fn.mock.mockImplementationOnce(addTwo, 8); + assert.strictEqual(fn(), 12); + assert.strictEqual(fn(), 14); + assert.strictEqual(fn(), 15); + assert.strictEqual(fn.mock.callCount(), 10); + fn.mock.mockImplementationOnce(addThree); + assert.strictEqual(fn(), 18); + assert.strictEqual(fn(), 19); + assert.strictEqual(fn.mock.callCount(), 12); +}); + +test('local mocks are auto restored after the test finishes', async (t) => { + const obj = { + foo() {}, + bar() {}, + }; + const originalFoo = obj.foo; + const originalBar = obj.bar; + + assert.strictEqual(originalFoo, obj.foo); + assert.strictEqual(originalBar, obj.bar); + + const mockFoo = t.mock.method(obj, 'foo'); + + assert.strictEqual(mockFoo, obj.foo); + assert.notStrictEqual(originalFoo, obj.foo); + assert.strictEqual(originalBar, obj.bar); + + t.beforeEach(() => { + assert.strictEqual(mockFoo, obj.foo); + assert.strictEqual(originalBar, obj.bar); + }); + + t.afterEach(() => { + assert.strictEqual(mockFoo, obj.foo); + assert.notStrictEqual(originalBar, obj.bar); + }); + + await t.test('creates mocks that are auto restored', (t) => { + const mockBar = t.mock.method(obj, 'bar'); + + assert.strictEqual(mockFoo, obj.foo); + assert.strictEqual(mockBar, obj.bar); + assert.notStrictEqual(originalBar, obj.bar); + }); + + assert.strictEqual(mockFoo, obj.foo); + assert.strictEqual(originalBar, obj.bar); +}); + +test('uses top level mock', () => { + function sum(a, b) { + return a + b; + } + + function difference(a, b) { + return a - b; + } + + const fn = mock.fn(sum, difference); + + assert.strictEqual(fn.mock.calls.length, 0); + assert.strictEqual(fn(3, 4), -1); + assert.strictEqual(fn.mock.calls.length, 1); + mock.reset(); + assert.strictEqual(fn(3, 4), 7); + assert.strictEqual(fn.mock.calls.length, 2); +}); + +test('the getter and setter options cannot be used together', (t) => { + assert.throws(() => { + t.mock.method({}, 'method', { getter: true, setter: true }); + }, /The property 'options\.setter' cannot be used with 'options\.getter'/); +}); + +test('method names must be strings or symbols', (t) => { + const symbol = Symbol(); + const obj = { + method() {}, + [symbol]() {}, + }; + + t.mock.method(obj, 'method'); + t.mock.method(obj, symbol); + + assert.throws(() => { + t.mock.method(obj, {}); + }, /The "methodName" argument must be one of type string or symbol/); +}); + +test('the times option must be an integer >= 1', (t) => { + assert.throws(() => { + t.mock.fn({ times: null }); + }, /The "options\.times" property must be of type number/); + + assert.throws(() => { + t.mock.fn({ times: 0 }); + }, /The value of "options\.times" is out of range/); + + assert.throws(() => { + t.mock.fn(() => {}, { times: 3.14159 }); + }, /The value of "options\.times" is out of range/); +}); + +test('spies on a class prototype method', (t) => { + class Clazz { + constructor(c) { + this.c = c; + } + + getC() { + return this.c; + } + } + + const instance = new Clazz(85); + + assert.strictEqual(instance.getC(), 85); + t.mock.method(Clazz.prototype, 'getC'); + + assert.strictEqual(instance.getC.mock.calls.length, 0); + assert.strictEqual(instance.getC(), 85); + assert.strictEqual(instance.getC.mock.calls.length, 1); + assert.strictEqual(Clazz.prototype.getC.mock.calls.length, 1); + + const call = instance.getC.mock.calls[0]; + assert.deepStrictEqual(call.arguments, []); + assert.strictEqual(call.result, 85); + assert.strictEqual(call.error, undefined); + assert.strictEqual(call.target, undefined); + assert.strictEqual(call.this, instance); +}); diff --git a/tools/doc/type-parser.mjs b/tools/doc/type-parser.mjs index b660949ce3c..0053b62b31f 100644 --- a/tools/doc/type-parser.mjs +++ b/tools/doc/type-parser.mjs @@ -14,7 +14,7 @@ const jsPrimitives = { const jsGlobalObjectsUrl = `${jsDocPrefix}Reference/Global_Objects/`; const jsGlobalTypes = [ 'AggregateError', 'Array', 'ArrayBuffer', 'DataView', 'Date', 'Error', - 'EvalError', 'Function', 'Map', 'Object', 'Promise', 'RangeError', + 'EvalError', 'Function', 'Map', 'Object', 'Promise', 'Proxy', 'RangeError', 'ReferenceError', 'RegExp', 'Set', 'SharedArrayBuffer', 'SyntaxError', 'TypeError', 'TypedArray', 'URIError', 'Uint8Array', ]; |