From 73c610ca6812de7e652d8b5608e72da0976eb058 Mon Sep 17 00:00:00 2001 From: cjihrig Date: Mon, 4 Apr 2022 18:36:40 -0400 Subject: [PATCH] test_runner: support function mocking This commit allows tests in the test runner to mock functions and methods. --- lib/internal/test_runner/mock.js | 303 +++++++++++ lib/internal/test_runner/test.js | 11 + lib/test.js | 17 +- test/parallel/test-runner-mocking.js | 734 +++++++++++++++++++++++++++ 4 files changed, 1064 insertions(+), 1 deletion(-) create mode 100644 lib/internal/test_runner/mock.js create mode 100644 test/parallel/test-runner-mocking.js diff --git a/lib/internal/test_runner/mock.js b/lib/internal/test_runner/mock.js new file mode 100644 index 00000000000000..700b5b104c8348 --- /dev/null +++ b/lib/internal/test_runner/mock.js @@ -0,0 +1,303 @@ +'use strict'; +const { + ArrayPrototypePush, + ArrayPrototypeSlice, + Error, + 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', 0); + + if (call < nextCall) { + // The call number has already passed. + return; + } + + 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; + + constructor() { + this.#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 = { + 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++) { + restore.call(this.#mocks[i]); + } + } + + #setupMock(ctx, fnToMatch) { + const mock = new Proxy(fnToMatch, { + __proto__: null, + apply(_fn, thisArg, argList) { + const fn = nextImpl.call(ctx); + let result; + let error; + + try { + result = ReflectApply(fn, thisArg, argList); + } catch (err) { + error = err; + throw err; + } finally { + trackCall.call(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 = nextImpl.call(ctx); + let result; + let error; + + try { + result = ReflectConstruct(realTarget, argList, newTarget); + } catch (err) { + error = err; + throw err; + } finally { + trackCall.call(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); + }, + }); + + this.#mocks.push(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 75a3a1558924b1..9b5bfd7807a627 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, @@ -111,6 +112,11 @@ class TestContext { this.#test.diagnostic(message); } + get mock() { + this.#test.mock ??= new MockTracker(); + return this.#test.mock; + } + runOnly(value) { this.#test.runOnlySubtests = !!value; } @@ -238,6 +244,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; @@ -589,6 +596,10 @@ class Test extends AsyncResource { this.#outerSignal?.removeEventListener('abort', this.#abortHandler); + if (this.mock !== null) { + this.mock.reset(); + } + if (this.parent !== null) { this.parent.activeSubtests--; this.parent.addReadySubtest(this); diff --git a/lib/test.js b/lib/test.js index 767d0e83a5f56f..346b18cb4cc1f5 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,18 @@ ObjectAssign(module.exports, { run, test, }); + +let lazyMock; + +ObjectDefineProperty(module.exports, 'mock', { + __proto__: null, + 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 00000000000000..625aac4dab860c --- /dev/null +++ b/test/parallel/test-runner-mocking.js @@ -0,0 +1,734 @@ +'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); + + fn.mock.mockImplementationOnce(common.mustNotCall(), 6); + 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); +});