Commit fcfb7ecc0ba for nodejs
commit fcfb7ecc0ba1199eb336877f2e652bcb21e867be
Author: Guilherme Araújo <arauujogui@gmail.com>
Date: Tue Oct 6 02:55:47 2026 -0300
test_runner: add mock call assertions to t.assert
Add t.assert.called(), t.assert.callCount(), t.assert.nthCalledWith()
and t.assert.lastCalledWith() for asserting on mock function calls.
Assisted-by: Claude
Signed-off-by: Guilherme Araújo <arauujogui@gmail.com>
PR-URL: https://github.com/nodejs/node/pull/66476
Reviewed-By: Chemi Atlow <chemi@atlow.co.il>
Reviewed-By: James M Snell <jasnell@gmail.com>
diff --git a/doc/api/test.md b/doc/api/test.md
index 77bf17544df..a2c98388fa4 100644
--- a/doc/api/test.md
+++ b/doc/api/test.md
@@ -4359,6 +4359,46 @@ test('test', (t) => {
});
```
+#### `context.assert.callCount(fn, times[, message])`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* `fn` {Function} A mock function created by the test runner's mocking API.
+* `times` {integer} The expected number of calls.
+* `message` {string} Optional error message.
+
+Asserts that the mock function `fn` has been called exactly `times` times.
+
+```js
+test('mock was called twice', (t) => {
+ const fn = t.mock.fn();
+ fn();
+ fn();
+ t.assert.callCount(fn, 2);
+});
+```
+
+#### `context.assert.called(fn[, message])`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* `fn` {Function} A mock function created by the test runner's mocking API.
+* `message` {string} Optional error message.
+
+Asserts that the mock function `fn` has been called at least once.
+
+```js
+test('mock was called', (t) => {
+ const fn = t.mock.fn();
+ fn();
+ t.assert.called(fn);
+});
+```
+
#### `context.assert.fileSnapshot(value, path[, options])`
<!-- YAML
@@ -4398,6 +4438,52 @@ This function differs from `context.assert.snapshot()` in the following ways:
These differences allow snapshot files to better support features such as syntax
highlighting.
+#### `context.assert.lastCalledWith(fn[, ...args])`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* `fn` {Function} A mock function created by the test runner's mocking API.
+* `...args` {any} The expected arguments.
+
+Asserts that the most recent call to the mock function `fn` received arguments
+deeply and strictly equal to `args`, using the same comparison as
+[`assert.deepStrictEqual()`][].
+
+```js
+test('mock was last called with arguments', (t) => {
+ const fn = t.mock.fn();
+ fn(1);
+ fn(2);
+ t.assert.lastCalledWith(fn, 2);
+});
+```
+
+#### `context.assert.nthCalledWith(fn, n[, ...args])`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* `fn` {Function} A mock function created by the test runner's mocking API.
+* `n` {integer} The 1-based index of the call to check.
+* `...args` {any} The expected arguments.
+
+Asserts that the `n`th call to the mock function `fn` received arguments
+deeply and strictly equal to `args`, using the same comparison as
+[`assert.deepStrictEqual()`][]. `n` starts at `1`, so
+`t.assert.nthCalledWith(fn, 1)` checks `fn.mock.calls[0]`.
+
+```js
+test('mock was called with arguments on the second call', (t) => {
+ const fn = t.mock.fn();
+ fn(1);
+ fn(2);
+ t.assert.nthCalledWith(fn, 2, 2);
+});
+```
+
#### `context.assert.snapshot(value[, options])`
<!-- YAML
@@ -5036,6 +5122,7 @@ test.describe('my suite', (suite) => {
[`SuiteContext`]: #class-suitecontext
[`TestContext`]: #class-testcontext
[`TracingChannel`]: diagnostics_channel.md#class-tracingchannel
+[`assert.deepStrictEqual()`]: assert.md#assertdeepstrictequalactual-expected-message
[`assert.throws`]: assert.md#assertthrowsfn-error-message
[`context.diagnostic`]: #contextdiagnosticmessage
[`context.log`]: #contextlogmessage-data
diff --git a/lib/internal/test_runner/assert.js b/lib/internal/test_runner/assert.js
index 776c1e25cbf..98cf4f283ff 100644
--- a/lib/internal/test_runner/assert.js
+++ b/lib/internal/test_runner/assert.js
@@ -2,11 +2,19 @@
const {
SafeMap,
} = primordials;
+const {
+ codes: {
+ ERR_INVALID_ARG_TYPE,
+ },
+} = require('internal/errors');
const {
validateFunction,
+ validateInteger,
validateString,
} = require('internal/validators');
+const { isDeepStrictEqual } = require('internal/util/comparisons');
const assert = require('assert');
+const { AssertionError } = assert;
const methodsToCopy = [
'deepEqual',
'deepStrictEqual',
@@ -28,6 +36,98 @@ const methodsToCopy = [
];
let assertMap;
+function getMockContext(fn) {
+ const { MockFunctionContext } = require('internal/test_runner/mock/mock');
+ const ctx = fn?.mock;
+
+ if (!(ctx instanceof MockFunctionContext)) {
+ throw new ERR_INVALID_ARG_TYPE('fn', 'mock function', fn);
+ }
+
+ return ctx;
+}
+
+function called(fn, message) {
+ const count = getMockContext(fn).callCount();
+
+ if (count === 0) {
+ throw new AssertionError({
+ __proto__: null,
+ message: message ?? 'Expected mock function to have been called',
+ actual: count,
+ operator: 'called',
+ stackStartFn: called,
+ });
+ }
+}
+
+function callCount(fn, times, message) {
+ validateInteger(times, 'times', 0);
+ const count = getMockContext(fn).callCount();
+
+ if (count !== times) {
+ throw new AssertionError({
+ __proto__: null,
+ message: message ??
+ `Expected mock function to have been called ${times} times, but it was called ${count} times`,
+ actual: count,
+ expected: times,
+ operator: 'callCount',
+ stackStartFn: callCount,
+ });
+ }
+}
+
+function assertCalledWithAt(fn, n, args, stackStartFn) {
+ const calls = getMockContext(fn).calls;
+
+ if (n > calls.length) {
+ throw new AssertionError({
+ __proto__: null,
+ message: `Expected mock function to have been called at least ${n} times, ` +
+ `but it was called ${calls.length} times`,
+ actual: calls.length,
+ expected: n,
+ operator: stackStartFn.name,
+ stackStartFn,
+ });
+ }
+
+ const actual = calls[n - 1].arguments;
+
+ if (!isDeepStrictEqual(actual, args)) {
+ throw new AssertionError({
+ __proto__: null,
+ message: `Expected call ${n} of mock function to have been called with:`,
+ actual,
+ expected: args,
+ operator: 'deepStrictEqual',
+ stackStartFn,
+ });
+ }
+}
+
+function nthCalledWith(fn, n, ...args) {
+ validateInteger(n, 'n', 1);
+ assertCalledWithAt(fn, n, args, nthCalledWith);
+}
+
+function lastCalledWith(fn, ...args) {
+ const count = getMockContext(fn).callCount();
+
+ if (count === 0) {
+ throw new AssertionError({
+ __proto__: null,
+ message: 'Expected mock function to have been called',
+ actual: count,
+ operator: 'lastCalledWith',
+ stackStartFn: lastCalledWith,
+ });
+ }
+
+ assertCalledWithAt(fn, count, args, lastCalledWith);
+}
+
function getAssertionMap() {
if (assertMap === undefined) {
assertMap = new SafeMap();
@@ -35,6 +135,11 @@ function getAssertionMap() {
for (let i = 0; i < methodsToCopy.length; i++) {
assertMap.set(methodsToCopy[i], assert[methodsToCopy[i]]);
}
+
+ assertMap.set('called', called);
+ assertMap.set('callCount', callCount);
+ assertMap.set('lastCalledWith', lastCalledWith);
+ assertMap.set('nthCalledWith', nthCalledWith);
}
return assertMap;
diff --git a/lib/internal/test_runner/mock/mock.js b/lib/internal/test_runner/mock/mock.js
index 4ac1e0a7e0b..996c8a7bcca 100644
--- a/lib/internal/test_runner/mock/mock.js
+++ b/lib/internal/test_runner/mock/mock.js
@@ -1118,5 +1118,6 @@ if (!enableModuleMocking) {
module.exports = {
ensureNodeScheme,
+ MockFunctionContext,
MockTracker,
};
diff --git a/test/parallel/test-runner-assert.js b/test/parallel/test-runner-assert.js
index 236f1851d6d..68c3a8e9cbb 100644
--- a/test/parallel/test-runner-assert.js
+++ b/test/parallel/test-runner-assert.js
@@ -11,10 +11,92 @@ test('expected methods are on t.assert', (t) => {
'options',
];
const assertKeys = Object.keys(assert).filter((key) => !uncopiedKeys.includes(key));
- const expectedKeys = ['snapshot', 'fileSnapshot'].concat(assertKeys).sort();
+ const expectedKeys = [
+ 'snapshot',
+ 'fileSnapshot',
+ 'called',
+ 'callCount',
+ 'lastCalledWith',
+ 'nthCalledWith',
+ ].concat(assertKeys).sort();
assert.deepStrictEqual(Object.keys(t.assert).sort(), expectedKeys);
});
test('t.assert.ok correctly parses the stacktrace', (t) => {
t.assert.throws(() => t.assert.ok(1 === 2), /t\.assert\.ok\(1 === 2\)/);
});
+
+test('t.assert.called', (t) => {
+ const fn = t.mock.fn();
+ t.assert.throws(() => t.assert.called(fn), {
+ code: 'ERR_ASSERTION',
+ message: 'Expected mock function to have been called',
+ });
+ fn();
+ t.assert.called(fn);
+ t.assert.throws(() => t.assert.called(() => {}), {
+ code: 'ERR_INVALID_ARG_TYPE',
+ });
+});
+
+test('t.assert.callCount', (t) => {
+ const fn = t.mock.fn();
+ t.assert.callCount(fn, 0);
+ fn();
+ fn();
+ t.assert.callCount(fn, 2);
+ t.assert.throws(() => t.assert.callCount(fn, 1), {
+ code: 'ERR_ASSERTION',
+ message: 'Expected mock function to have been called 1 times, but it was called 2 times',
+ });
+ t.assert.throws(() => t.assert.callCount(fn, -1), {
+ code: 'ERR_OUT_OF_RANGE',
+ });
+});
+
+test('t.assert.nthCalledWith', (t) => {
+ const fn = t.mock.fn();
+ fn(1);
+ fn({ a: 2 });
+ t.assert.nthCalledWith(fn, 1, 1);
+ t.assert.nthCalledWith(fn, 2, { a: 2 });
+ t.assert.throws(() => t.assert.nthCalledWith(fn, 2, { a: 3 }), {
+ code: 'ERR_ASSERTION',
+ message: /^Expected call 2 of mock function to have been called with:/,
+ actual: [{ a: 2 }],
+ expected: [{ a: 3 }],
+ });
+ t.assert.throws(() => t.assert.nthCalledWith(fn, 3, 1), {
+ code: 'ERR_ASSERTION',
+ message: 'Expected mock function to have been called at least 3 times, but it was called 2 times',
+ });
+ t.assert.throws(() => t.assert.nthCalledWith(fn, 0, 1), {
+ code: 'ERR_OUT_OF_RANGE',
+ });
+});
+
+test('t.assert.lastCalledWith', (t) => {
+ const fn = t.mock.fn();
+ t.assert.throws(() => t.assert.lastCalledWith(fn), {
+ code: 'ERR_ASSERTION',
+ message: 'Expected mock function to have been called',
+ });
+ fn(1);
+ fn(2);
+ t.assert.lastCalledWith(fn, 2);
+ t.assert.throws(() => t.assert.lastCalledWith(fn, 1), {
+ code: 'ERR_ASSERTION',
+ actual: [2],
+ expected: [1],
+ });
+});
+
+test('mock matchers count towards t.plan()', (t) => {
+ t.plan(4);
+ const fn = t.mock.fn();
+ fn(1);
+ t.assert.called(fn);
+ t.assert.callCount(fn, 1);
+ t.assert.nthCalledWith(fn, 1, 1);
+ t.assert.lastCalledWith(fn, 1);
+});