◆ 時間経過では関数は実行されなくなり tick 関数で時間を進める
◆ 即時全部実行もできるけど setInterval を使ってると分かりづらい挙動をする

Node.js 20.4 でテスト用のモック機能にタイマーが追加されたようです
最初見たときには Date で現在時刻系のテストし辛いのが改善されるのか と期待したのですが setTimeout 系の話でした
「タイマー」ですからね

enable

test 関数で呼び出される関数に引数として渡される TestContext を使って ctx.mock.timers でアクセスできるものです
有効にするには enable を呼び出さないとだめみたいです

ctx.mock.timers.enable(["setTimeout"])

enable せずに tick や runAll を実行すると enable を実行してとエラーになります

引数は省略すると全部のタイマー関数をモックしてくれるようです
タイマー系でこの関数だけをモックしたいってことはあんまりないですし基本は引数なしでいいかもです

指定方法は関数ではなく文字列でということで全部のタイプの setTimeout などが対象です
グローバル以外に node:timers や node:timers/promises でインポートしたものも対象になります

モックを有効にすると実時間経過では関数が呼び出されなくなります

test("test", async ctx => {
const _setTimeout = setTimeout
ctx.mock.timers.enable()

let n = 0
setTimeout(() => {
n++
}, 1000)

await new Promise(r => _setTimeout(r, 1200))
assert.strictEqual(n, 0)
})

tick

tick を使って時間を進めます

test("test", ctx => {
ctx.mock.timers.enable()

let n = 0
setTimeout(() => {
n++
}, 1000)

ctx.mock.timers.tick(500)
assert.strictEqual(n, 0)
ctx.mock.timers.tick(500)
assert.strictEqual(n, 1)
})

runAll

runAll を使うと全部のタイマーを即時実行できます
実行順はタイマーを動かしたときと一緒です

test("test", async ctx => {
ctx.mock.timers.enable()

const arr = []
setTimeout(() => arr.push(1), 3000)
setTimeout(() => arr.push(2), 1000)
setTimeout(() => arr.push(3), 2000)
setTimeout(() => arr.push(4), 2000)

ctx.mock.timers.runAll()
assert.deepStrictEqual(arr, [2, 3, 4, 1])
})

setInterval の場合は一番最初の 1 つだけが実行されて残りは実行されないようです
もう一度実行すると続きではなく最初からになってるようで 同じ関数が再度実行されることになります

test("test", async ctx => {
ctx.mock.timers.enable()

const arr = []
setInterval(() => arr.push(1), 1800)
setInterval(() => arr.push(2), 1000)
setInterval(() => arr.push(3), 1500)

ctx.mock.timers.runAll()
assert.deepStrictEqual(arr, [2])
ctx.mock.timers.runAll()
assert.deepStrictEqual(arr, [2, 2])
})

タイムアウトの時間が同じだと順番に実行されるようです

test("test", async ctx => {
ctx.mock.timers.enable()

const arr = []
setInterval(() => arr.push(1), 1000)
setInterval(() => arr.push(2), 1000)
setInterval(() => arr.push(3), 1000)

ctx.mock.timers.runAll()
assert.deepStrictEqual(arr, [1])
ctx.mock.timers.runAll()
assert.deepStrictEqual(arr, [1, 2])
ctx.mock.timers.runAll()
assert.deepStrictEqual(arr, [1, 2, 3])
ctx.mock.timers.runAll()
assert.deepStrictEqual(arr, [1, 2, 3, 1])
})

setTimeout と setInterval が混ざると複雑になります

test("test", async ctx => {
ctx.mock.timers.enable()

const arr = []
setTimeout(() => arr.push(1), 1000)
setInterval(() => arr.push(2), 1000)
setTimeout(() => arr.push(3), 1000)
setInterval(() => arr.push(4), 1000)
setTimeout(() => arr.push(5), 1000)
setInterval(() => arr.push(6), 2000)

ctx.mock.timers.runAll()
assert.deepStrictEqual(arr, [1, 2])
ctx.mock.timers.runAll()
assert.deepStrictEqual(arr, [1, 2, 3, 4])
ctx.mock.timers.runAll()
assert.deepStrictEqual(arr, [1, 2, 3, 4, 5, 2])
ctx.mock.timers.runAll()
assert.deepStrictEqual(arr, [1, 2, 3, 4, 5, 2, 4])
ctx.mock.timers.runAll()
assert.deepStrictEqual(arr, [1, 2, 3, 4, 5, 2, 4, 2])
})

setInterval の実行があると setTimeout が残っていても runAll がそこで止まります
なので最初は 1, 2 だけです
もう一度 runAll をすると 1 は終了してるので 3 が入ります
setInterval は 2 と 4 が同じタイムアウト時間で前回が 2 だったので 4 になります
さらにもう一度 runAll を実行すると 同じように次は setTimeout の 5 が来ます
setInterval では 6 は他より間隔が長いので来ることはなく 2 か 4 となり 前回が 4 だったので今回は 2 です
これ以降は setTimeout は残っていないので 2 と 4 が交互に続きます

期待するのは

これは本当に意図的な挙動なのか疑いたいほどにはおかしな挙動だと思います
個人的にはこうであってほしいです

test("test", async ctx => {
ctx.mock.timers.enable()

const arr = []
setTimeout(() => arr.push(1), 1000)
setInterval(() => arr.push(2), 1000)
setTimeout(() => arr.push(3), 1000)
setInterval(() => arr.push(4), 1000)
setTimeout(() => arr.push(5), 1000)
setInterval(() => arr.push(6), 2000)

ctx.mock.timers.runAll()
assert.deepStrictEqual(arr, [1, 2, 3, 4, 5])
ctx.mock.timers.runAll()
assert.deepStrictEqual(arr, [1, 2, 3, 4, 5, 2, 4, 6])
ctx.mock.timers.runAll()
assert.deepStrictEqual(arr, [1, 2, 3, 4, 5, 2, 4, 6, 2, 4])
ctx.mock.timers.runAll()
assert.deepStrictEqual(arr, [1, 2, 3, 4, 5, 2, 4, 6, 2, 4, 2, 4, 6])
})

1000 に setInterval が含まれるので 1000 で一旦終了となり 1000 で実行される 1, 2, 3, 4, 5 が実行されます
次の実行ではまた 1000 進んで 2 と 4 さらに合計が 2000 なので 6 も含まれます
その次は 2 と 4 は含まれますが合計では 3000 なので 6 は含まれません
さらにその次は 4000 なので 2, 4, 6 という感じです

最初の時点で 6 まで出ていいとも思うのですが それだと 2 秒経ってることになるので 1 秒で実行するものは 2 回実行されていないと困る場合もありそうです
それに対応すると 1 つ毎日みたいなタイマーが混ざると毎秒の処理が 86400 回行われることになりますし runAll と言っても途中で止めるのは仕方ないのかなと思います

まだ experimental ステータスですし もう少し改善してほしいですね