node icon indicating copy to clipboard operation
node copied to clipboard

`MockFunctionContext` cannot be used to track callback / continuation-passing methods

Open filmaj opened this issue 8 months ago • 4 comments

Version

v22.14.0

Platform

Darwin MacBookPro 24.4.0 Darwin Kernel Version 24.4.0: Fri Apr 11 18:28:23 PDT 2025; root:xnu-11417.101.15~117/RELEASE_X86_64 x86_64

Subsystem

node:test

What steps will reproduce the bug?

Consider the following test:

let fs = require('node:fs')

function writer (cb) {
  console.log('calling fs.writeFile')
  fs.writeFile('test.test', 'hi', cb)
}

let { test } = require('node:test')
test('callback test mocking not working?', (t, done) => {
  console.log('mocking fs.writeFile')
  t.mock.method(fs, 'writeFile', (dest, data, cb) => {
    console.log('mock writeFile executing, calling back')
    cb()
  })
  writer(err => {
    if (err) console.warn('got a writeFile error', err)
    console.log('fs.writeFile mock callcount:', fs.writeFile.mock.callCount())
    done()
  })
})

When I run the above test, the call count on the mocked method is 0, even though the console.log from within the mocked method is output:

➜ node --test callback-test.js
mocking fs.writeFile
calling fs.writeFile
mock writeFile executing, calling back
fs.writeFile mock callcount: 0
✔ callback test mocking not working? (1.868146ms)
ℹ tests 1
ℹ suites 0
ℹ pass 1
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 79.29258

How often does it reproduce? Is there a required condition?

Every time.

What is the expected behavior? Why is that the expected behavior?

I would expect that the call count for the mocked method is 1 in the above test. If the test method provides an optional done callback parameter, that signaled to me that node:test could be used to test continuation-passing style of source code.

What do you see instead?

Instead, call count is 0:

➜ node --test callback-test.js
mocking fs.writeFile
calling fs.writeFile
mock writeFile executing, calling back
fs.writeFile mock callcount: 0
✔ callback test mocking not working? (1.868146ms)
ℹ tests 1
ℹ suites 0
ℹ pass 1
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 79.29258

Additional information

I am in the process of updating old modules - several years old and that have worked just fine since about 2017 - and removing third party test frameworks and moving to the native node test runner module (node:test). These modules are written in this continuation-passing style - functions that accept a callback.

I suppose the entire chain of callbacks (not sure what term to use for this) must complete before the mock method records a call? The event loop must complete a lap before the method tracking is registered? If I extend the test in my example with a promisified version, then my expectations are met:

let fs = require('node:fs')
let { promisify } = require('node:util')

function writer (cb) {
  console.log('calling fs.writeFile')
  fs.writeFile('test.test', 'hi', cb)
}
let promisifiedWriter = promisify(writer)

let { test } = require('node:test')
test('callback test mocking not working?', (t, done) => {
  console.log('mocking fs.writeFile')
  t.mock.method(fs, 'writeFile', (dest, data, cb) => {
    console.log('mock writeFile executing, calling back')
    cb()
  })
  writer(err => {
    if (err) console.warn('got a writeFile error', err)
    console.log('fs.writeFile mock callcount:', fs.writeFile.mock.callCount())
    done()
  })
})
test('promisified test mocking working', async (t) => {
  console.log('mocking fs.writeFile')
  t.mock.method(fs, 'writeFile', (dest, data, cb) => {
    console.log('mock writeFile executing, calling back')
    cb()
  })
  await promisifiedWriter()
  console.log('fs.writeFile mock callcount:', fs.writeFile.mock.callCount())
})
➜ node --test callback-test.js
mocking fs.writeFile
calling fs.writeFile
mock writeFile executing, calling back
fs.writeFile mock callcount: 0
mocking fs.writeFile
calling fs.writeFile
mock writeFile executing, calling back
fs.writeFile mock callcount: 1
✔ callback test mocking not working? (1.858847ms)
✔ promisified test mocking working (0.314972ms)

filmaj avatar May 04 '25 13:05 filmaj

I suppose the entire chain of callbacks (not sure what term to use for this) must complete before the mock method records a call?

It's basically this. The callbacks don't necessarily have to run, but the mocked function must complete. Since your callbacks are all synchronous, the mocked function doesn't actually return yet. The reason it works like this is because we don't track the call until we have the result (which is included in the public API).

cjihrig avatar May 04 '25 14:05 cjihrig

Fair enough and thanks for the confirmation. In the mean time, I will use the promisify-the-source-under-test workaround in conjunction with async tests - that seems to work fine. I'm rewriting the tests anyways, and that let's me leave old continuation-passing code alone, since it is battle tested and proven over years that it works.

Perhaps worth calling this out in the node:test docs? If you have suggestions I am all ears and happy to send a PR.

filmaj avatar May 04 '25 14:05 filmaj

The docs are here if you want to mention the fact that the calls aren't updated until the mocked function returns.

You also don't have to promisify anything. You could use process.nextTick() or a similar API to make the callbacks asynchronous like the real writeFile() would.

cjihrig avatar May 04 '25 16:05 cjihrig

Cheers, I tried my hand at a PR here: https://github.com/nodejs/node/pull/58170

filmaj avatar May 04 '25 18:05 filmaj

Thanks bro @filmaj

Andreymyski avatar May 05 '25 07:05 Andreymyski