Skip to content

Instantly share code, notes, and snippets.

@weshouman
Last active January 5, 2024 15:36
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save weshouman/98fbc490d729c946a9de63ca47aa966f to your computer and use it in GitHub Desktop.
Save weshouman/98fbc490d729c946a9de63ca47aa966f to your computer and use it in GitHub Desktop.
Sinon structured fake clock

Testing in Both Real and Fake Time in Javascript

The code shows how to jump between the fake and real time allowing to:

  • Execute real-time dependent stubs (in real time), in this scenario it's the HIL simulation.
  • Execute long test in time controlled manner (in fake time), in this scenario it's the HIL test.

Current Implementation

Avoid using await Promise.resolve() by ticking asynchronously, for example using fakeClock.tickAsync() and then you could move between fake and real time, the code is updated to show this solution.

Previous Implementation

Initially jumping to the real time was avoided as the promise was not resolved, thus it became necessary to use await Promise.resolve(), however not moving to the real time would come at the cost of granual control, for example in this demo HIL simulation won't be posssible.
Note: It could be necessary to use await Promise.resolve() a couple of times, based on the number of promises we want to resolve.

Note: The original code which had the issue is left to ease comparisons, however the implementation with the await Promise.resolve() is not shown.

References

import sinon from 'sinon';
export const timerUtils = {
currentClock: null,
elapsedFakeTime: 0,
useFakeTimer: function() {
console.log('Starting fake timer');
this.currentClock = sinon.useFakeTimers();
this.elapsedFakeTime = 0;
},
pauseFakeTimer: function() {
if (this.currentClock) {
this.elapsedFakeTime = this.currentClock.now;
console.log('Pausing fake timer at:', this.elapsedFakeTime);
this.currentClock.restore();
}
},
resumeFakeTimer: function() {
console.log('Resuming fake timer from:', this.elapsedFakeTime);
this.currentClock = sinon.useFakeTimers({ now: this.elapsedFakeTime });
},
restoreRealTimer: function() {
if (this.currentClock) {
console.log('Restoring real timer');
this.currentClock.restore();
this.currentClock = null;
}
}
};
async function subFunction(index) {
console.log(`UUT: Starting subfunction ${index} at ${new Date().toISOString()}`);
// Simulate an asynchronous operation
await new Promise((resolve) => setTimeout(resolve, 1000));
console.log(`UUT: Completed subfunction ${index} at ${new Date().toISOString()}`);
}
async function mainFunction() {
for (let i = 1; i <= 5; i++) {
// await subFunction(i); // this won't work
await UUT.subFunction(i);
}
console.log(`UUT: Waiting a couple of seconds after subfunctions at ${new Date().toISOString()}`);
await new Promise((resolve) => setTimeout(resolve, 2000)); // Wait for 2 seconds
console.log(`UUT: mainFunction completed at ${new Date().toISOString()}`);
}
export const UUT = {
mainFunction,
subFunction
};
// UUT.test.js
import { timerUtils } from './old_timerUtils.js';
import { UUT } from './old_UUT.js';
import sinon from 'sinon';
import { expect } from 'chai';
const promiseResolvers = [];
describe('Main Function Test', function() {
beforeEach(function() {
timerUtils.useFakeTimer();
console.log(UUT.subFunction)
sinon.stub(UUT, 'subFunction').callsFake((index) => {
console.log(`Stub: subFunction ${index} called, ${new Date().toISOString()}`);
return new Promise((resolve) => {
promiseResolvers.push(resolve);
});
});
console.log(UUT.subFunction)
});
afterEach(function() {
timerUtils.restoreRealTimer();
promiseResolvers.length = 0;
UUT.subFunction.restore();
});
it('should complete mainFunction with controlled promise resolution', async function() {
const mainFunctionPromise = UUT.mainFunction();
let clock;
// Ensure we advance time and resolve promises only after they are pushed
for (let i = 1; i <= 5; i++) {
console.log(`Test: Advancing fake timer for subfunction ${i}`);
timerUtils.currentClock.tick(1000); // Advance time for each subfunction
timerUtils.pauseFakeTimer();
await new Promise(resolve => setTimeout(resolve, 50));
// This does not resume the timer
timerUtils.resumeFakeTimer();
// This resumes the timer
// clock = sinon.useFakeTimers();
console.log(`Test: Resolving subfunction ${i}`);
console.log(`Resolvers count ${promiseResolvers.length}, resolving at index: ${i-1}`)
if (typeof promiseResolvers[i - 1] === 'function') {
promiseResolvers[i - 1](); // Resolve the i-th subfunction's promise
console.log("resolved")
} else {
throw new Error(`Resolver for subfunction ${i} is not a function`);
}
}
console.log('Test: All subfunctions resolved, advancing time for the final wait');
timerUtils.currentClock.tick(2000); // Advance time for the final 2-second wait
await mainFunctionPromise;
console.log('Test: mainFunction should be completed now');
expect(UUT.subFunction.callCount).to.equal(5);
});
});
import sinon from 'sinon';
export const timerUtils = {
currentClock: null,
elapsedFakeTime: 0,
// Make the timer restoration safe for idempotency
isFakeTimerActive: false,
useFakeTimer: function(startTime = 0) {
this.elapsedFakeTime = startTime;
this.currentClock = sinon.useFakeTimers(this.elapsedFakeTime);
this.isFakeTimerActive = true;
return this.currentClock;
},
pauseFakeTimer: function() {
if (this.currentClock && this.isFakeTimerActive) {
this.elapsedFakeTime = this.currentClock.now;
this.currentClock.restore();
this.isFakeTimerActive = false;
}
},
resumeFakeTimer: function() {
if (!this.isFakeTimerActive) {
this.currentClock = sinon.useFakeTimers({ now: this.elapsedFakeTime });
this.isFakeTimerActive = true;
}
return this.currentClock;
},
restoreRealTimer: function() {
if (this.currentClock) {
this.currentClock.restore();
this.currentClock = null;
this.isFakeTimerActive = false;
}
}
};
const enableFurtherWait = false;
const callCount = 3;
async function subFunction(index) {
// if stubbed, this should not be called
console.log(`UUT: Starting real subfunction ${index} at ${new Date().toISOString()}`);
// Simulate an asynchronous operation
await new Promise((resolve) => setTimeout(resolve, 1000));
// if stubbed, this should not be called
console.log(`UUT: Completed real subfunction ${index} at ${new Date().toISOString()}`);
}
function unexpectedTimeJump() {
var currentDate = new Date();
var thresholdDate = new Date('2000-01-01');
if (currentDate > thresholdDate) {
throw new Error('Current date is newer than January 1, 2000');
}
}
async function mainFunction() {
for (let i = 1; i <= callCount; i++) {
// await subFunction(i); // this won't work
console.log(`UUT: Invoking subfunction ${i} at ${new Date().toISOString()}`);
await UUT.subFunction(i);
console.log(`UUT: Invoked subfunction ${i} at ${new Date().toISOString()}`);
unexpectedTimeJump()
}
if (enableFurtherWait) {
console.log(`UUT: Waiting a couple of seconds after subfunctions at ${new Date().toISOString()}`);
await new Promise((resolve) => { console.log("Promise started"); setTimeout(resolve, 2000);}); // Wait for 2 seconds
}
console.log(`UUT: mainFunction completed at ${new Date().toISOString()}`);
}
export const UUT = {
mainFunction,
subFunction
};
// UUT.test.js
import { timerUtils } from './timerUtils.js';
import { UUT } from './UUT.js';
import sinon from 'sinon';
import { expect } from 'chai';
const promiseResolvers = [];
const useGlobalClock = false;
let clock;
const callCount = 3;
const HIL_SIM_TIME = 1000;
async function realTimeOut(ms) {
if (useGlobalClock) {
clock.restore()
} else {
timerUtils.pauseFakeTimer();
}
await new Promise(resolve => setTimeout(resolve, ms));
if (useGlobalClock) {
clock = sinon.useFakeTimers();
} else {
timerUtils.resumeFakeTimer();
}
}
describe('Main Function Test', function () {
beforeEach(function () {
if (useGlobalClock) {
clock = sinon.useFakeTimers();
} else {
timerUtils.useFakeTimer();
}
sinon.stub(UUT, 'subFunction').callsFake(async (index) => {
console.log(`Stub: subFunction ${index} called, ${new Date().toISOString()}`);
return new Promise((resolve) => {
promiseResolvers.push(() => {
// HIL Simulation calls are stubbed here
timerUtils.pauseFakeTimer(); // Pause the fake timer
console.log(`Stub: [HIL-SIM] call #${index} starting on ${new Date().toISOString()}`)
setTimeout(() => {
resolve();
console.log(`Stub: [HIL-SIM] call #${index} ended on ${new Date().toISOString()}`)
timerUtils.resumeFakeTimer(); // Resume the fake timer
}, HIL_SIM_TIME); // Real-time timeout
});
});
});
});
afterEach(function () {
if (useGlobalClock) {
clock.restore();
} else {
timerUtils.restoreRealTimer();
}
promiseResolvers.length = 0;
UUT.subFunction.restore();
});
it('should complete mainFunction with controlled promise resolution', async function () {
this.timeout((callCount + 2) * HIL_SIM_TIME);
const mainFunctionPromise = UUT.mainFunction();
// Ensure we advance time and resolve promises only after they are pushed
for (let i = 1; i <= callCount; i++) {
// Wait for real time based stub, at least HIL_SIM_TIME
console.log(`Test: Start waiting in real time (for the stub) [subfunction ${i}]`);
await realTimeOut(1000);
console.log(`Test: Finish waiting in real time (for the stub) [subfunction ${i}]`);
console.log(`Test: Start waiting in fake time (for the UUT) [subfunction ${i}]`);
if (useGlobalClock) {
await clock.tickAsync(1000)
} else {
await timerUtils.currentClock.tickAsync(1000);
}
console.log(`Test: Finish waiting in fake time (for the UUT) [subfunction ${i}]`);
let rCount = promiseResolvers.length;
expect(rCount, `Expected ${i} resolvers but received ${rCount}`).to.equal(i);
console.log(`Test: Resolving subfunction ${i}`);
if (typeof promiseResolvers[i - 1] === 'function') {
promiseResolvers[i - 1](); // Resolve the i-th subfunction's promise
console.log(`Test: Resolved subfunction ${i}`)
} else {
// This should not be reached as the previous expectation should fire
throw new Error(`Test: Resolver for subfunction ${i} is not a function`);
}
}
console.log(`Test: All ${promiseResolvers.length} subfunction promises are resolved`);
console.log('Test: Advancing time for the final wait');
if (useGlobalClock) {
await clock.tickAsync(4000)
} else {
await timerUtils.currentClock.tickAsync(4000);
}
console.log('Test: awaiting mainFunction promise');
await mainFunctionPromise;
console.log('Test: mainFunction should be completed now');
expect(UUT.subFunction.callCount).to.equal(callCount);
});
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment