Skip to content

Commit 73c610c

Browse files
committed
test_runner: support function mocking
This commit allows tests in the test runner to mock functions and methods.
1 parent 7124476 commit 73c610c

File tree

4 files changed

+1064
-1
lines changed

4 files changed

+1064
-1
lines changed

lib/internal/test_runner/mock.js

Lines changed: 303 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,303 @@
1+
'use strict';
2+
const {
3+
ArrayPrototypePush,
4+
ArrayPrototypeSlice,
5+
Error,
6+
ObjectDefineProperty,
7+
ObjectGetOwnPropertyDescriptor,
8+
Proxy,
9+
ReflectApply,
10+
ReflectConstruct,
11+
ReflectGet,
12+
SafeMap,
13+
} = primordials;
14+
const {
15+
codes: {
16+
ERR_INVALID_ARG_TYPE,
17+
ERR_INVALID_ARG_VALUE,
18+
}
19+
} = require('internal/errors');
20+
const { kEmptyObject } = require('internal/util');
21+
const {
22+
validateBoolean,
23+
validateFunction,
24+
validateInteger,
25+
validateObject,
26+
} = require('internal/validators');
27+
28+
function kDefaultFunction() {}
29+
30+
class MockFunctionContext {
31+
#calls;
32+
#mocks;
33+
#implementation;
34+
#restore;
35+
#times;
36+
37+
constructor(implementation, restore, times) {
38+
this.#calls = [];
39+
this.#mocks = new SafeMap();
40+
this.#implementation = implementation;
41+
this.#restore = restore;
42+
this.#times = times;
43+
}
44+
45+
get calls() {
46+
return ArrayPrototypeSlice(this.#calls, 0);
47+
}
48+
49+
callCount() {
50+
return this.#calls.length;
51+
}
52+
53+
mockImplementation(implementation) {
54+
validateFunction(implementation, 'implementation');
55+
this.#implementation = implementation;
56+
}
57+
58+
mockImplementationOnce(implementation, onCall) {
59+
validateFunction(implementation, 'implementation');
60+
const nextCall = this.#calls.length;
61+
const call = onCall ?? nextCall;
62+
validateInteger(call, 'onCall', 0);
63+
64+
if (call < nextCall) {
65+
// The call number has already passed.
66+
return;
67+
}
68+
69+
this.#mocks.set(call, implementation);
70+
}
71+
72+
restore() {
73+
const { descriptor, object, original, methodName } = this.#restore;
74+
75+
if (typeof methodName === 'string') {
76+
// This is an object method spy.
77+
ObjectDefineProperty(object, methodName, descriptor);
78+
} else {
79+
// This is a bare function spy. There isn't much to do here but make
80+
// the mock call the original function.
81+
this.#implementation = original;
82+
}
83+
}
84+
85+
trackCall(call) {
86+
ArrayPrototypePush(this.#calls, call);
87+
}
88+
89+
nextImpl() {
90+
const nextCall = this.#calls.length;
91+
const mock = this.#mocks.get(nextCall);
92+
const impl = mock ?? this.#implementation;
93+
94+
if (nextCall + 1 === this.#times) {
95+
this.restore();
96+
}
97+
98+
this.#mocks.delete(nextCall);
99+
return impl;
100+
}
101+
}
102+
103+
const { nextImpl, restore, trackCall } = MockFunctionContext.prototype;
104+
delete MockFunctionContext.prototype.trackCall;
105+
delete MockFunctionContext.prototype.nextImpl;
106+
107+
class MockTracker {
108+
#mocks;
109+
110+
constructor() {
111+
this.#mocks = [];
112+
}
113+
114+
fn(
115+
original = function() {},
116+
implementation = original,
117+
options = kEmptyObject
118+
) {
119+
if (original !== null && typeof original === 'object') {
120+
options = original;
121+
original = function() {};
122+
implementation = original;
123+
} else if (implementation !== null && typeof implementation === 'object') {
124+
options = implementation;
125+
implementation = original;
126+
}
127+
128+
validateFunction(original, 'original');
129+
validateFunction(implementation, 'implementation');
130+
validateObject(options, 'options');
131+
const { times = Infinity } = options;
132+
validateTimes(times, 'options.times');
133+
const ctx = new MockFunctionContext(implementation, { original }, times);
134+
return this.#setupMock(ctx, original);
135+
}
136+
137+
method(
138+
object,
139+
methodName,
140+
implementation = kDefaultFunction,
141+
options = kEmptyObject
142+
) {
143+
validateObject(object, 'object');
144+
validateStringOrSymbol(methodName, 'methodName');
145+
146+
if (implementation !== null && typeof implementation === 'object') {
147+
options = implementation;
148+
implementation = kDefaultFunction;
149+
}
150+
151+
validateFunction(implementation, 'implementation');
152+
validateObject(options, 'options');
153+
154+
const {
155+
getter = false,
156+
setter = false,
157+
times = Infinity,
158+
} = options;
159+
160+
validateBoolean(getter, 'options.getter');
161+
validateBoolean(setter, 'options.setter');
162+
validateTimes(times, 'options.times');
163+
164+
if (setter && getter) {
165+
throw new ERR_INVALID_ARG_VALUE(
166+
'options.setter', setter, "cannot be used with 'options.getter'"
167+
);
168+
}
169+
170+
const descriptor = ObjectGetOwnPropertyDescriptor(object, methodName);
171+
let original;
172+
173+
if (getter) {
174+
original = descriptor.get;
175+
} else if (setter) {
176+
original = descriptor.set;
177+
} else {
178+
original = descriptor.value;
179+
}
180+
181+
if (typeof original !== 'function') {
182+
throw new ERR_INVALID_ARG_VALUE(
183+
'methodName', original, 'must be a method'
184+
);
185+
}
186+
187+
const restore = { descriptor, object, methodName };
188+
const impl = implementation === kDefaultFunction ?
189+
original : implementation;
190+
const ctx = new MockFunctionContext(impl, restore, times);
191+
const mock = this.#setupMock(ctx, original);
192+
const mockDescriptor = {
193+
configurable: descriptor.configurable,
194+
enumerable: descriptor.enumerable,
195+
};
196+
197+
if (getter) {
198+
mockDescriptor.get = mock;
199+
mockDescriptor.set = descriptor.set;
200+
} else if (setter) {
201+
mockDescriptor.get = descriptor.get;
202+
mockDescriptor.set = mock;
203+
} else {
204+
mockDescriptor.writable = descriptor.writable;
205+
mockDescriptor.value = mock;
206+
}
207+
208+
ObjectDefineProperty(object, methodName, mockDescriptor);
209+
210+
return mock;
211+
}
212+
213+
reset() {
214+
this.restoreAll();
215+
this.#mocks = [];
216+
}
217+
218+
restoreAll() {
219+
for (let i = 0; i < this.#mocks.length; i++) {
220+
restore.call(this.#mocks[i]);
221+
}
222+
}
223+
224+
#setupMock(ctx, fnToMatch) {
225+
const mock = new Proxy(fnToMatch, {
226+
__proto__: null,
227+
apply(_fn, thisArg, argList) {
228+
const fn = nextImpl.call(ctx);
229+
let result;
230+
let error;
231+
232+
try {
233+
result = ReflectApply(fn, thisArg, argList);
234+
} catch (err) {
235+
error = err;
236+
throw err;
237+
} finally {
238+
trackCall.call(ctx, {
239+
arguments: argList,
240+
error,
241+
result,
242+
// eslint-disable-next-line no-restricted-syntax
243+
stack: new Error(),
244+
target: undefined,
245+
this: thisArg,
246+
});
247+
}
248+
249+
return result;
250+
},
251+
construct(target, argList, newTarget) {
252+
const realTarget = nextImpl.call(ctx);
253+
let result;
254+
let error;
255+
256+
try {
257+
result = ReflectConstruct(realTarget, argList, newTarget);
258+
} catch (err) {
259+
error = err;
260+
throw err;
261+
} finally {
262+
trackCall.call(ctx, {
263+
arguments: argList,
264+
error,
265+
result,
266+
// eslint-disable-next-line no-restricted-syntax
267+
stack: new Error(),
268+
target,
269+
this: result,
270+
});
271+
}
272+
273+
return result;
274+
},
275+
get(target, property, receiver) {
276+
if (property === 'mock') {
277+
return ctx;
278+
}
279+
280+
return ReflectGet(target, property, receiver);
281+
},
282+
});
283+
284+
this.#mocks.push(ctx);
285+
return mock;
286+
}
287+
}
288+
289+
function validateStringOrSymbol(value, name) {
290+
if (typeof value !== 'string' && typeof value !== 'symbol') {
291+
throw new ERR_INVALID_ARG_TYPE(name, ['string', 'symbol'], value);
292+
}
293+
}
294+
295+
function validateTimes(value, name) {
296+
if (value === Infinity) {
297+
return;
298+
}
299+
300+
validateInteger(value, name, 1);
301+
}
302+
303+
module.exports = { MockTracker };

lib/internal/test_runner/test.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ const {
3232
AbortError,
3333
} = require('internal/errors');
3434
const { getOptionValue } = require('internal/options');
35+
const { MockTracker } = require('internal/test_runner/mock');
3536
const { TapStream } = require('internal/test_runner/tap_stream');
3637
const {
3738
convertStringToRegExp,
@@ -111,6 +112,11 @@ class TestContext {
111112
this.#test.diagnostic(message);
112113
}
113114

115+
get mock() {
116+
this.#test.mock ??= new MockTracker();
117+
return this.#test.mock;
118+
}
119+
114120
runOnly(value) {
115121
this.#test.runOnlySubtests = !!value;
116122
}
@@ -238,6 +244,7 @@ class Test extends AsyncResource {
238244
this.#outerSignal?.addEventListener('abort', this.#abortHandler);
239245

240246
this.fn = fn;
247+
this.mock = null;
241248
this.name = name;
242249
this.parent = parent;
243250
this.cancelled = false;
@@ -589,6 +596,10 @@ class Test extends AsyncResource {
589596

590597
this.#outerSignal?.removeEventListener('abort', this.#abortHandler);
591598

599+
if (this.mock !== null) {
600+
this.mock.reset();
601+
}
602+
592603
if (this.parent !== null) {
593604
this.parent.activeSubtests--;
594605
this.parent.addReadySubtest(this);

lib/test.js

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
'use strict';
2-
const { ObjectAssign } = primordials;
2+
const { ObjectAssign, ObjectDefineProperty } = primordials;
33
const { test, describe, it, before, after, beforeEach, afterEach } = require('internal/test_runner/harness');
44
const { run } = require('internal/test_runner/runner');
55

@@ -14,3 +14,18 @@ ObjectAssign(module.exports, {
1414
run,
1515
test,
1616
});
17+
18+
let lazyMock;
19+
20+
ObjectDefineProperty(module.exports, 'mock', {
21+
__proto__: null,
22+
get() {
23+
if (lazyMock === undefined) {
24+
const { MockTracker } = require('internal/test_runner/mock');
25+
26+
lazyMock = new MockTracker();
27+
}
28+
29+
return lazyMock;
30+
},
31+
});

0 commit comments

Comments
 (0)