Testing with mock clocks can often turn into a real struggle when
dealing with situations where some work in the test is truly async and
other work is captured by the mock clock. This can happen for many
reasons, but as one example:
An asynchonrous change from a task in the mocked clock may change DOM where
a resize observer then gets triggered. This browser API is truly asynchronous
and would require the user to wait real time for it to fire. If there is
follow-up work after the resize observer fires, it may be captured by the mock
clock again. This would require the tester to write something like the
following:
```
// flush the timer
jasmine.clock().tick();
// wait for resize observer
await new Promise(resolve => setTimeout(resolve));
// flush follow-up work from the resize observer callback
jasmine.clock().tick();
```
When using mock clocks, testers are always forced to write tests with intimate
knowledge of when the mock clock needs to be ticked. Oftentimes, the
purpose of using a mock clock is to speed up the execution time of the
test when there are timeouts involved. It is not often a goal to test
the exact timeout values. This can cause tests to be riddled with
`tick`. It ideal for test code to be written in a way
that is independent of whether a mock clock is installed. For example:
```
document.getElementById('submit');
// https://testing-library.com/docs/dom-testing-library/api-async/#waitfor
await waitFor(() => expect(mockAPI).toHaveBeenCalledTimes(1))
```
When mock clocks are involved, the above may not be possible if there is
some delay involved between the click and the request to the API.
Instead, developers would need to manually tick the clock beyond the
delay to trigger the API call.
This commit attempts to resolve these issues by adding a feature to the
clock which allows it to advance on its own with the passage of time,
just as clocks do without mocks installed. It also allows for some
breathing time so any unmocked micro and macrotasks are given space to
execute as well.
This feature would also address both #1725 and #1932. `asyncTick` can be
accomplished by enabling the auto tick feature and then waiting for a
promise with a timout to be resolved
(`await new Promise(resolve => setTimeout(resolve, 20))`) where
`setTimeout` is captured by the mock clock and flushed while the code is
waiting for the promise to resolve.
resolves #1725
resolves #1932
All credit goes to @stephenfarrar for this.
370 lines
11 KiB
JavaScript
370 lines
11 KiB
JavaScript
describe('DelayedFunctionScheduler', function() {
|
|
it('schedules a function for later execution', function() {
|
|
const scheduler = new jasmineUnderTest.DelayedFunctionScheduler(),
|
|
fn = jasmine.createSpy('fn');
|
|
|
|
scheduler.scheduleFunction(fn, 0);
|
|
|
|
expect(fn).not.toHaveBeenCalled();
|
|
|
|
scheduler.tick(0);
|
|
|
|
expect(fn).toHaveBeenCalled();
|
|
});
|
|
|
|
it('schedules a string for later execution', function() {
|
|
const scheduler = new jasmineUnderTest.DelayedFunctionScheduler(),
|
|
strfn = 'horrible = true;';
|
|
|
|
scheduler.scheduleFunction(strfn, 0);
|
|
|
|
scheduler.tick(0);
|
|
|
|
expect(horrible).toEqual(true);
|
|
});
|
|
|
|
it('#tick defaults to 0', function() {
|
|
const scheduler = new jasmineUnderTest.DelayedFunctionScheduler(),
|
|
fn = jasmine.createSpy('fn');
|
|
|
|
scheduler.scheduleFunction(fn, 0);
|
|
|
|
expect(fn).not.toHaveBeenCalled();
|
|
|
|
scheduler.tick();
|
|
|
|
expect(fn).toHaveBeenCalled();
|
|
});
|
|
|
|
it('defaults delay to 0', function() {
|
|
const scheduler = new jasmineUnderTest.DelayedFunctionScheduler(),
|
|
fn = jasmine.createSpy('fn');
|
|
|
|
scheduler.scheduleFunction(fn);
|
|
|
|
expect(fn).not.toHaveBeenCalled();
|
|
|
|
scheduler.tick(0);
|
|
|
|
expect(fn).toHaveBeenCalled();
|
|
});
|
|
|
|
it('optionally passes params to scheduled functions', function() {
|
|
const scheduler = new jasmineUnderTest.DelayedFunctionScheduler(),
|
|
fn = jasmine.createSpy('fn');
|
|
|
|
scheduler.scheduleFunction(fn, 0, ['foo', 'bar']);
|
|
|
|
expect(fn).not.toHaveBeenCalled();
|
|
|
|
scheduler.tick(0);
|
|
|
|
expect(fn).toHaveBeenCalledWith('foo', 'bar');
|
|
});
|
|
|
|
it('scheduled fns can optionally reoccur', function() {
|
|
const scheduler = new jasmineUnderTest.DelayedFunctionScheduler(),
|
|
fn = jasmine.createSpy('fn');
|
|
|
|
scheduler.scheduleFunction(fn, 20, [], true);
|
|
|
|
expect(fn).not.toHaveBeenCalled();
|
|
|
|
scheduler.tick(20);
|
|
|
|
expect(fn.calls.count()).toBe(1);
|
|
|
|
scheduler.tick(40);
|
|
|
|
expect(fn.calls.count()).toBe(3);
|
|
|
|
scheduler.tick(21);
|
|
|
|
expect(fn.calls.count()).toBe(4);
|
|
});
|
|
|
|
it('increments scheduled fns ids unless one is passed', function() {
|
|
const scheduler = new jasmineUnderTest.DelayedFunctionScheduler();
|
|
|
|
expect(scheduler.scheduleFunction(function() {}, 0)).toBe(1);
|
|
expect(scheduler.scheduleFunction(function() {}, 0)).toBe(2);
|
|
expect(scheduler.scheduleFunction(function() {}, 0, [], false, 123)).toBe(
|
|
123
|
|
);
|
|
expect(scheduler.scheduleFunction(function() {}, 0)).toBe(3);
|
|
});
|
|
|
|
it('#removeFunctionWithId removes a previously scheduled function with a given id', function() {
|
|
const scheduler = new jasmineUnderTest.DelayedFunctionScheduler(),
|
|
fn = jasmine.createSpy('fn'),
|
|
timeoutKey = scheduler.scheduleFunction(fn, 0);
|
|
|
|
expect(fn).not.toHaveBeenCalled();
|
|
|
|
scheduler.removeFunctionWithId(timeoutKey);
|
|
|
|
scheduler.tick(0);
|
|
|
|
expect(fn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('executes recurring functions interleaved with regular functions in the correct order', function() {
|
|
const scheduler = new jasmineUnderTest.DelayedFunctionScheduler();
|
|
const fn = jasmine.createSpy('fn');
|
|
let recurringCallCount = 0;
|
|
const recurring = jasmine.createSpy('recurring').and.callFake(function() {
|
|
recurringCallCount++;
|
|
if (recurringCallCount < 5) {
|
|
expect(fn).not.toHaveBeenCalled();
|
|
}
|
|
});
|
|
|
|
scheduler.scheduleFunction(recurring, 10, [], true);
|
|
scheduler.scheduleFunction(fn, 50);
|
|
|
|
scheduler.tick(60);
|
|
|
|
expect(recurring).toHaveBeenCalled();
|
|
expect(recurring.calls.count()).toBe(6);
|
|
expect(fn).toHaveBeenCalled();
|
|
});
|
|
|
|
it('schedules a function for later execution during a tick', function() {
|
|
const scheduler = new jasmineUnderTest.DelayedFunctionScheduler(),
|
|
fn = jasmine.createSpy('fn'),
|
|
fnDelay = 10;
|
|
|
|
scheduler.scheduleFunction(function() {
|
|
scheduler.scheduleFunction(fn, fnDelay);
|
|
}, 0);
|
|
|
|
expect(fn).not.toHaveBeenCalled();
|
|
|
|
scheduler.tick(fnDelay);
|
|
|
|
expect(fn).toHaveBeenCalled();
|
|
});
|
|
|
|
it('#removeFunctionWithId removes a previously scheduled function with a given id during a tick', function() {
|
|
const scheduler = new jasmineUnderTest.DelayedFunctionScheduler(),
|
|
fn = jasmine.createSpy('fn'),
|
|
fnDelay = 10;
|
|
let timeoutKey;
|
|
|
|
scheduler.scheduleFunction(function() {
|
|
scheduler.removeFunctionWithId(timeoutKey);
|
|
}, 0);
|
|
timeoutKey = scheduler.scheduleFunction(fn, fnDelay);
|
|
|
|
expect(fn).not.toHaveBeenCalled();
|
|
|
|
scheduler.tick(fnDelay);
|
|
|
|
expect(fn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('executes recurring functions interleaved with regular functions and functions scheduled during a tick in the correct order', function() {
|
|
const scheduler = new jasmineUnderTest.DelayedFunctionScheduler();
|
|
const fn = jasmine.createSpy('fn');
|
|
let recurringCallCount = 0;
|
|
const recurring = jasmine.createSpy('recurring').and.callFake(function() {
|
|
recurringCallCount++;
|
|
if (recurringCallCount < 5) {
|
|
expect(fn).not.toHaveBeenCalled();
|
|
}
|
|
});
|
|
const innerFn = jasmine.createSpy('innerFn').and.callFake(function() {
|
|
expect(recurring.calls.count()).toBe(4);
|
|
expect(fn).not.toHaveBeenCalled();
|
|
});
|
|
const scheduling = jasmine.createSpy('scheduling').and.callFake(function() {
|
|
expect(recurring.calls.count()).toBe(3);
|
|
expect(fn).not.toHaveBeenCalled();
|
|
scheduler.scheduleFunction(innerFn, 10); // 41ms absolute
|
|
});
|
|
|
|
scheduler.scheduleFunction(recurring, 10, [], true);
|
|
scheduler.scheduleFunction(fn, 50);
|
|
scheduler.scheduleFunction(scheduling, 31);
|
|
|
|
scheduler.tick(60);
|
|
|
|
expect(recurring).toHaveBeenCalled();
|
|
expect(recurring.calls.count()).toBe(6);
|
|
expect(fn).toHaveBeenCalled();
|
|
expect(scheduling).toHaveBeenCalled();
|
|
expect(innerFn).toHaveBeenCalled();
|
|
});
|
|
|
|
it('executes recurring functions after rescheduling them', function() {
|
|
const scheduler = new jasmineUnderTest.DelayedFunctionScheduler(),
|
|
recurring = function() {
|
|
expect(scheduler.scheduleFunction).toHaveBeenCalled();
|
|
};
|
|
|
|
scheduler.scheduleFunction(recurring, 10, [], true);
|
|
|
|
spyOn(scheduler, 'scheduleFunction');
|
|
|
|
scheduler.tick(10);
|
|
});
|
|
|
|
it('removes functions during a tick that runs the function', function() {
|
|
const scheduler = new jasmineUnderTest.DelayedFunctionScheduler(),
|
|
spy = jasmine.createSpy('fn'),
|
|
spyAndRemove = jasmine.createSpy('fn'),
|
|
fnDelay = 10;
|
|
let timeoutKey;
|
|
|
|
spyAndRemove.and.callFake(function() {
|
|
scheduler.removeFunctionWithId(timeoutKey);
|
|
});
|
|
|
|
scheduler.scheduleFunction(spyAndRemove, fnDelay);
|
|
|
|
timeoutKey = scheduler.scheduleFunction(spy, fnDelay, [], true);
|
|
|
|
scheduler.tick(2 * fnDelay);
|
|
|
|
expect(spy).not.toHaveBeenCalled();
|
|
expect(spyAndRemove).toHaveBeenCalled();
|
|
});
|
|
|
|
it('removes functions during the first tick that runs the function', function() {
|
|
const scheduler = new jasmineUnderTest.DelayedFunctionScheduler(),
|
|
fn = jasmine.createSpy('fn'),
|
|
fnDelay = 10;
|
|
let timeoutKey;
|
|
|
|
timeoutKey = scheduler.scheduleFunction(fn, fnDelay, [], true);
|
|
scheduler.scheduleFunction(function() {
|
|
scheduler.removeFunctionWithId(timeoutKey);
|
|
}, fnDelay);
|
|
|
|
expect(fn).not.toHaveBeenCalled();
|
|
|
|
scheduler.tick(3 * fnDelay);
|
|
|
|
expect(fn).toHaveBeenCalled();
|
|
expect(fn.calls.count()).toBe(1);
|
|
});
|
|
|
|
it("does not remove a function that hasn't been added yet", function() {
|
|
const scheduler = new jasmineUnderTest.DelayedFunctionScheduler(),
|
|
fn = jasmine.createSpy('fn'),
|
|
fnDelay = 10;
|
|
|
|
scheduler.removeFunctionWithId('foo');
|
|
scheduler.scheduleFunction(fn, fnDelay, [], false, 'foo');
|
|
|
|
expect(fn).not.toHaveBeenCalled();
|
|
|
|
scheduler.tick(fnDelay + 1);
|
|
|
|
expect(fn).toHaveBeenCalled();
|
|
});
|
|
|
|
it('runs the next scheduled funtion', function() {
|
|
const scheduler = new jasmineUnderTest.DelayedFunctionScheduler();
|
|
const fn = jasmine.createSpy('fn');
|
|
const tickSpy = jasmine.createSpy('tick');
|
|
|
|
scheduler.scheduleFunction(fn, 10, [], false, 'foo');
|
|
|
|
expect(fn).not.toHaveBeenCalled();
|
|
|
|
scheduler.runNextQueuedFunction(tickSpy);
|
|
|
|
expect(fn).toHaveBeenCalled();
|
|
expect(tickSpy).toHaveBeenCalledWith(10);
|
|
});
|
|
|
|
it('runs the only a single scheduled funtion in a time slot', function() {
|
|
const scheduler = new jasmineUnderTest.DelayedFunctionScheduler();
|
|
const fn1 = jasmine.createSpy('fn');
|
|
const fn2 = jasmine.createSpy('fn2');
|
|
const tickSpy = jasmine.createSpy('tick');
|
|
|
|
scheduler.scheduleFunction(fn1, 10, [], false, 'foo1');
|
|
scheduler.scheduleFunction(fn2, 10, [], false, 'foo2');
|
|
|
|
scheduler.runNextQueuedFunction(tickSpy);
|
|
|
|
expect(fn1).toHaveBeenCalled();
|
|
expect(fn2).not.toHaveBeenCalled();
|
|
expect(tickSpy).toHaveBeenCalledWith(10);
|
|
|
|
tickSpy.calls.reset();
|
|
scheduler.runNextQueuedFunction(tickSpy);
|
|
expect(fn2).toHaveBeenCalled();
|
|
expect(tickSpy).toHaveBeenCalledWith(0);
|
|
});
|
|
|
|
it('updates the mockDate per scheduled time', function() {
|
|
const scheduler = new jasmineUnderTest.DelayedFunctionScheduler(),
|
|
tickDate = jasmine.createSpy('tickDate');
|
|
|
|
scheduler.scheduleFunction(function() {});
|
|
scheduler.scheduleFunction(function() {}, 1);
|
|
|
|
scheduler.tick(1, tickDate);
|
|
|
|
expect(tickDate).toHaveBeenCalledWith(0);
|
|
expect(tickDate).toHaveBeenCalledWith(1);
|
|
});
|
|
|
|
describe('ticking inside a scheduled function', function() {
|
|
let clock;
|
|
|
|
// Runner function calls the callback until it returns false
|
|
function runWork(workCallback) {
|
|
while (workCallback()) {}
|
|
}
|
|
|
|
// Make a worker that takes a little time and tracks when it finished
|
|
function mockWork(times) {
|
|
return () => {
|
|
clock.tick(1);
|
|
const now = new Date().getTime();
|
|
expect(lastWork)
|
|
.withContext('Previous function calls should always be in the past')
|
|
.toBeLessThan(now);
|
|
lastWork = now;
|
|
times--;
|
|
return times > 0;
|
|
};
|
|
}
|
|
let lastWork = 0;
|
|
|
|
beforeEach(() => {
|
|
clock = jasmineUnderTest.getEnv().clock;
|
|
clock.install();
|
|
clock.mockDate(new Date(1));
|
|
});
|
|
|
|
afterEach(function() {
|
|
jasmineUnderTest.getEnv().clock.uninstall();
|
|
});
|
|
|
|
it('preserves monotonically-increasing current time', () => {
|
|
const work1 = mockWork(3);
|
|
setTimeout(() => {
|
|
runWork(work1);
|
|
}, 1);
|
|
clock.tick(1);
|
|
expect(lastWork)
|
|
.withContext('tick should advance past last-scheduled function')
|
|
.toBeLessThanOrEqual(new Date().getTime());
|
|
|
|
const work2 = mockWork(3);
|
|
setTimeout(() => {
|
|
runWork(work2);
|
|
}, 1);
|
|
clock.tick(1);
|
|
expect(lastWork)
|
|
.withContext('tick should advance past last-scheduled function')
|
|
.toBeLessThanOrEqual(new Date().getTime());
|
|
});
|
|
});
|
|
});
|