Skip to content

doc: call out potentially unexpected results when mocking and testing synchronous callback-based APIs #58170

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 98 additions & 2 deletions doc/api/test.md
Original file line number Diff line number Diff line change
Expand Up @@ -652,6 +652,86 @@ test('spies on an object method', (t) => {
});
```

### Mocking callback-based APIs
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we should document this as specific to callback based APIs. It probably makes sense to call them out as a case where you can encounter this issue, but it doesn't inherently have anything to do with callbacks. The problem also goes away when making callbacks asynchronous, as they generally are within Node.

The issue is really that the call is not recorded until the mocked function returns. You would run into the same issue if you tried to access the call from within a mock of a synchronous functon.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's fair. I struggled with how to frame this particular situation in the context of these docs; I think it deserves call-out status in the docs, but I am also biased to think that given I spent some time digging into this issue to figure out what was going on 😛

You would run into the same issue if you tried to access the call from within a mock of a synchronous function.

Is this a reasonable equivalency? From my own perspective, I wouldn't expect to be able to access MockFunctionContext references as the mock itself is still executing. But I did expect to be able to do so when mocking a sync callback. Perhaps this reveals more about my lack of understanding of node internals more than anything else 😆 but I also see this discussion as the magic that is consumers of a project hashing out some point with maintainers of a project - we each bring our own biases to bear but hopefully can meet in the middle somewhere.

I'll have a think about how to frame this gotcha as the result of the mock still executing rather than something unique to callback-based APIs. Open to suggestions / ideas!


When mocking callback-based APIs (continuation-passing style), note that call
tracking is updated _after_ the mocked function completes execution. This can
lead to unexpected behavior when inspecting the mock's [`MockFunctionContext`][]
object when asserting on how it was invoked. Using [`process.nextTick()`][] or
[`util.promisify()`][] in your tests can help avoid this problem:

```mjs
import fs from 'node:fs';
import { test } from 'node:test';
import util from 'node:util';

test('callback-style API mocking behavior', (t, done) => {
// Mock the fs.writeFile method
t.mock.method(fs, 'writeFile', (path, data, cb) => {
// Invoke callback synchronously
cb(null, 'success');
});

fs.writeFile('test.txt', 'hello', (err, result) => {
// This will show 0 because call tracking is updated after function completion
console.log('Immediate call count:', fs.writeFile.mock.callCount());

// Use process.nextTick to check after the event loop tick
process.nextTick(() => {
// This will correctly show 1
console.log('Call count after nextTick:', fs.writeFile.mock.callCount());

// Another approach is to use util.promisify
const writeFilePromise = util.promisify(fs.writeFile);

// With promises, the call count will be correctly updated
// after the promise is resolved
writeFilePromise('test.txt', 'world')
.then(() => {
console.log('Call count with promises:', fs.writeFile.mock.callCount());
done();
});
});
});
});
```

```cjs
const fs = require('node:fs')
const { test } = require('node:test')
const util = require('node:util')

test('callback-style API mocking behavior', (t, done) => {
// Mock the fs.writeFile method
t.mock.method(fs, 'writeFile', (path, data, cb) => {
// Invoke callback synchronously
cb(null, 'success');
});

fs.writeFile('test.txt', 'hello', (err, result) => {
// This will show 0 because call tracking is updated after function completion
console.log('Immediate call count:', fs.writeFile.mock.callCount());

// Use process.nextTick to check after the event loop tick
process.nextTick(() => {
// This will correctly show 1
console.log('Call count after nextTick:', fs.writeFile.mock.callCount());

// Another approach is to use util.promisify
const writeFilePromise = util.promisify(fs.writeFile);

// With promises, the call count will be correctly updated
// after the promise is resolved
writeFilePromise('test.txt', 'world')
.then(() => {
console.log('Call count with promises:', fs.writeFile.mock.callCount());
done();
});
});
});
});
```

### Timers

Mocking timers is a technique commonly used in software testing to simulate and
Expand Down Expand Up @@ -1886,6 +1966,9 @@ mock. Each entry in the array is an object with the following properties.
`undefined`.
* `this` {any} The mocked function's `this` value.

> When mocking and testing callback-based APIs, please read the
> [Mocking callback-based APIs][mocking callbacks] section.

### `ctx.callCount()`

<!-- YAML
Expand All @@ -1900,6 +1983,9 @@ 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.

> When mocking and testing callback-based APIs, please read the
> [Mocking callback-based APIs][mocking callbacks] section.

### `ctx.mockImplementation(implementation)`

<!-- YAML
Expand Down Expand Up @@ -2068,6 +2154,9 @@ added:

This function is used to create a mock function.

> When mocking and testing callback-based APIs, please read the
> [Mocking callback-based APIs][mocking callbacks] section.

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.
Expand Down Expand Up @@ -2134,8 +2223,12 @@ added:
`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
This function is used to create a mock on an existing object method.

> When mocking and testing callback-based APIs, please read the
> [Mocking callback-based APIs][mocking callbacks] section.

The following example demonstrates how a mock is created on an existing object
method.

```js
Expand Down Expand Up @@ -3761,6 +3854,8 @@ Can be used to abort test subtasks when the test has been aborted.
[`--test-skip-pattern`]: cli.md#--test-skip-pattern
[`--test-update-snapshots`]: cli.md#--test-update-snapshots
[`--test`]: cli.md#--test
[`process.nextTick()`]: process.md#processnexttickcallback-args
[`util.promisify()`]: util.md#utilpromisifyoriginal
[`MockFunctionContext`]: #class-mockfunctioncontext
[`MockTimers`]: #class-mocktimers
[`MockTracker.method`]: #mockmethodobject-methodname-implementation-options
Expand All @@ -3780,6 +3875,7 @@ Can be used to abort test subtasks when the test has been aborted.
[code coverage]: #collecting-code-coverage
[describe options]: #describename-options-fn
[it options]: #testname-options-fn
[mocking callbacks]: #mocking-callback-based-apis
[stream.compose]: stream.md#streamcomposestreams
[subtests]: #subtests
[suite options]: #suitename-options-fn
Expand Down