Direct answer
Structure the production code to accept an injected scheduler (rather than calling setTimeout/setInterval or awaiting real timers directly), then in the test supply a fake scheduler that runs queued callbacks immediately and synchronously when flushed, so the whole queue drains deterministically with no real waiting and no flakiness from timing.
Structured elaboration
javascript
class FakeScheduler {
constructor() { this.pending = []; }
schedule(fn, delayMs) { this.pending.push({ fn, delayMs }); }
async flush() {
const jobs = this.pending;
this.pending = [];
for (const job of jobs) { await job.fn(); }
}
}
class QueueProcessor {
constructor(scheduler) {
this.scheduler = scheduler;
this.queue = [];
this.processedOrder = [];
}
enqueue(task) { this.queue.push(task); }
start() {
const step = async () => {
if (this.queue.length === 0) return;
const task = this.queue.shift();
await task();
this.processedOrder.push(task.name);
this.scheduler.schedule(step, 0);
};
this.scheduler.schedule(step, 0);
}
}
Test (executed with node):
javascript
function assertEqual(actual, expected, msg) {
if (actual !== expected) {
throw new Error(`FAIL: ${msg} (expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)})`);
}
}
const scheduler = new FakeScheduler();
const processor = new QueueProcessor(scheduler);
const order = [];
const makeTask = (name) => { const t = async () => { order.push(name); }; Object.defineProperty(t, 'name', { value: name }); return t; };
processor.enqueue(makeTask('task-A'));
processor.enqueue(makeTask('task-B'));
processor.enqueue(makeTask('task-C'));
processor.start();
let iterations = 0;
while (scheduler.pending.length > 0 && iterations < 100) {
await scheduler.flush();
iterations++;
}
assertEqual(order.join(','), 'task-A,task-B,task-C', 'tasks must process in first-in-first-out (FIFO) order');
console.log(`ALL ASSERTIONS PASSED, iterations = ${iterations}`);
Output: ALL ASSERTIONS PASSED, iterations = 4.
Production-code shape matters here: the queue processor is written to accept a scheduler object rather than calling the real timer APIs directly, which is the same dependency-injection style used to make time, randomness, and other side effects mockable in a test. This shape reads naturally whether the real scheduler ends up being Node's event loop, a browser's timer, or (in a mobile app) a coroutine dispatcher; the fake in the test just needs to implement the same schedule contract.
Worked example
Without the injected scheduler, this test would need real setTimeout calls and either sleep for a fixed wall-clock duration (slow, and still racy if the real processing takes longer than assumed) or poll for completion (flaky under CI load). With the fake scheduler, flush() runs every currently-queued callback to completion before returning, so draining the whole queue is just "call flush until nothing is pending", deterministic regardless of how fast or slow the actual machine running the test is.
Trade-offs and pitfalls
A fake scheduler that runs everything synchronously and instantly can hide real concurrency bugs that only manifest under genuine interleaving (two tasks actually racing against each other); it's excellent for testing processing order and business logic deterministically, but a separate test strategy built specifically to force real interleavings is needed to hunt for race conditions, since this fake's whole point is to remove concurrency, not exercise it.