Skip to content

Commit ac9bac0

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

File tree

4 files changed

+1064
-1
lines changed

4 files changed

+1064
-1
lines changed

lib/internal/test_runner/mock.js

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

lib/internal/test_runner/test.js

Lines changed: 8 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;
@@ -588,6 +595,7 @@ class Test extends AsyncResource {
588595
}
589596

590597
this.#outerSignal?.removeEventListener('abort', this.#abortHandler);
598+
this.mock?.reset();
591599

592600
if (this.parent !== null) {
593601
this.parent.activeSubtests--;

lib/test.js

Lines changed: 18 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,20 @@ ObjectAssign(module.exports, {
1414
run,
1515
test,
1616
});
17+
18+
let lazyMock;
19+
20+
ObjectDefineProperty(module.exports, 'mock', {
21+
__proto__: null,
22+
configurable: true,
23+
enumerable: true,
24+
get() {
25+
if (lazyMock === undefined) {
26+
const { MockTracker } = require('internal/test_runner/mock');
27+
28+
lazyMock = new MockTracker();
29+
}
30+
31+
return lazyMock;
32+
},
33+
});

0 commit comments

Comments
 (0)