Testing
Test cancelable code with fake timers, hand the code under test a promise you control, and assert on the status instead of on timing.
Nothing to mock
A CancelablePromise is a real promise with no dependencies. It runs the same in Node, jsdom and the browser,
so tests use the real class.
Test that canceling stops the work
The claim of a cancelable function is that its work stops. Assert on that, not only on the rejection.
delay.ts
import { CancelablePromise } from 'easy-cancelable-promise';
/** Resolves with the value after the delay. Canceling it clears the timer. */
export const delay = <T>(value: T, milliseconds: number) =>
new CancelablePromise<T>((resolve, _reject, { onCancel }) => {
const timer = setTimeout(() => resolve(value), milliseconds);
onCancel(() => clearTimeout(timer));
});delay.test.ts
import { delay } from './delay';
describe('delay', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it('resolves with the value after the delay', async () => {
const task = delay('done', 1000);
expect(task.status).toBe('pending');
await vi.advanceTimersByTimeAsync(1000);
expect(await task).toBe('done');
expect(task.status).toBe('resolved');
});
it('stops the timer when it is canceled', async () => {
const task = delay('done', 1000);
task.cancel('Not needed');
// the status is known at once, no need to wait for the rejection
expect(task.status).toBe('canceled');
expect(vi.getTimerCount()).toBe(0);
await expect(task).rejects.toBe('Not needed');
});
});statuschanges as soon ascancelis called, so it can be asserted without awaiting anything.- With fake timers,
getTimerCountshows that the timer was cleared. - The rejection carries the reason that was passed to
cancel.
Hand the code a promise you control
Code that receives a promise is tested by giving it one. defer creates a promise the test resolves, rejects,
cancels or reports progress on, at the moment it chooses.
deferred.test.ts
import { defer } from 'easy-cancelable-promise';
import type { CancelablePromise } from 'easy-cancelable-promise';
/** The code under test receives the promise, so a test can hand it one it controls. */
const describeRequest = async (request: CancelablePromise<string>) => {
try {
return `Loaded ${await request}`;
} catch {
return request.status === 'canceled' ? 'Canceled' : 'Failed';
}
};
describe('describeRequest', () => {
it('describes each way a request can end', async () => {
const loaded = defer<string>();
const failed = defer<string>();
const canceled = defer<string>();
loaded.resolve('user');
failed.reject(new Error('500'));
canceled.cancel();
expect(await describeRequest(loaded.promise)).toBe('Loaded user');
expect(await describeRequest(failed.promise)).toBe('Failed');
expect(await describeRequest(canceled.promise)).toBe('Canceled');
});
it('receives the progress the test reports', () => {
const request = defer<string>();
const onProgress = vi.fn();
request.promise.onProgress(onProgress);
request.reportProgress(40, { loaded: 4 });
expect(onProgress).toHaveBeenCalledWith(40, { loaded: 4 });
});
});