Repository: exadel-inc/esl
Pull request #367 (2021-08-05 21:37:42)

feat(esl-utils): promise utils fixes & test updates
- fix unstable tests in the async directory
- update promise utils with functional export (to achieve tree shakable structure) 
- fix missing leading tryUntill callback call
Note: PromiseUtils is still available and has no API changes


Patch:
diff --git a/src/modules/esl-utils/async/debounce.ts b/src/modules/esl-utils/async/debounce.ts
index 6a203efc1..4a1ff1358 100644
--- a/src/modules/esl-utils/async/debounce.ts
+++ b/src/modules/esl-utils/async/debounce.ts
@@ -1,11 +1,11 @@
-import {PromiseUtils} from './promise';
+import {createDeferred} from './promise';
 
 import type {AnyToAnyFnSignature} from '../misc/functions';
 import type {Deferred, PromisifyResultFn} from './promise';
 
 /** Debounced<F> is a function wrapper type for a function decorated via debounce */
 export interface Debounced<F extends AnyToAnyFnSignature> extends PromisifyResultFn<F> {
-  /** {Promise} of deferred function call */
+  /** Promise of deferred function call */
   promise: Promise<ReturnType<F> | void>;
   /** Cancel debounced call */
   cancel(): void;
@@ -16,16 +16,15 @@ export interface Debounced<F extends AnyToAnyFnSignature> extends PromisifyResul
  * Debounced function delays invoking func until after wait milliseconds have elapsed
  * since the last time the debounced function was invoked.
  * The func is invoked with the last arguments provided to the debounced function.
- * @param fn
- * @param [wait]
- * @returns {Function}
+ * @param fn - function to decorate
+ * @param wait - time to debounce
  */
 export function debounce<F extends AnyToAnyFnSignature>(fn: F, wait = 10): Debounced<F> {
   let timeout: number | null = null;
   let deferred: Deferred<ReturnType<F>> | null = null;
 
   function debouncedSubject(...args: any[]) {
-    deferred = deferred || PromiseUtils.deferred();
+    deferred = deferred || createDeferred();
     (typeof timeout === 'number') && clearTimeout(timeout);
     timeout = window.setTimeout(() => {
       timeout = null;
diff --git a/src/modules/esl-utils/async/delayed-task.ts b/src/modules/esl-utils/async/delayed-task.ts
index faeaa038d..8fe8a181b 100644
--- a/src/modules/esl-utils/async/delayed-task.ts
+++ b/src/modules/esl-utils/async/delayed-task.ts
@@ -15,13 +15,14 @@ export class DelayedTask {
     this._fn && this._fn();
   };
 
-  /** @return {Function} of currently deferred (planned) task */
+  /** @returns Function of currently deferred (planned) task */
   public get fn() {
     return this._fn;
   }
 
   /**
-   * Cancel deferred task and planning passed {@param task}
+   * Cancel deferred task and planning passed
+   * @param task - task function
    * @param delay - time to delay task execution
    *  - pass negative or false to execute task immediately
    *  - pass 0 to plan task to the macrotask
diff --git a/src/modules/esl-utils/async/promise.ts b/src/modules/esl-utils/async/promise.ts
index db8bb332f..57ff63cc8 100644
--- a/src/modules/esl-utils/async/promise.ts
+++ b/src/modules/esl-utils/async/promise.ts
@@ -1,5 +1,14 @@
 import type {AnyToAnyFnSignature} from '../misc/functions';
 
+/**
+ * @returns Promise that will be resolved in `timeout` with optional `payload`
+ */
+export function promisifyTimeout<T>(timeout: number, payload?: T): Promise<T> {
+  return new Promise<T>((resolve) =>
+    setTimeout(resolve.bind(null, payload), timeout)
+  );
+}
+
 /** Interface to describe abstract listenable target */
 export type ListenableTarget = {
   addEventListener: (
@@ -14,6 +23,64 @@ export type ListenableTarget = {
   ) => void;
 };
 
+/**
+ * @returns Promise that will be resolved by dispatching `event` on `target`
+ * Or it will be rejected in `timeout` if it's specified
+ * Optional `options` for addEventListener can be also specified
+ */
+export function promisifyEvent(
+  target: ListenableTarget,
+  event: string,
+  timeout?: number | null | undefined,
+  options?: boolean | AddEventListenerOptions
+): Promise<Event> {
+  return new Promise((resolve, reject) => {
+    function eventCallback(e: Event) {
+      target.removeEventListener(event, eventCallback, options);
+      resolve(e);
+    }
+
+    target.addEventListener(event, eventCallback, options);
+    if (typeof timeout === 'number' && timeout >= 0) {
+      setTimeout(() => reject(new Error('Rejected by timeout')), timeout);
+    }
+  });
+}
+
+/**
+ * Short helper to make Promise from element state marker
+ * @returns Promise that will be resolved if the target `marker` property is truthful or `event` is dispatched
+ * @example
+ * `const imgReady = promisifyMarker(eslImage, 'ready');`
+ */
+export function promisifyMarker(target: HTMLElement, marker: string, event: string = marker): Promise<HTMLElement> {
+  if ((target as any)[marker]) return Promise.resolve(target);
+  return promisifyEvent(target, event).then(() => target);
+}
+
+/**
+ * Call `callback` limited by `tryCount` amount of times with interval in `timeout` ms
+ * @returns Promise that will be resolved as soon as callback returns truthy value, or reject it by limit.
+ */
+export function tryUntil<T>(callback: () => T, tryCount = 2, timeout = 100): Promise<T> {
+  return new Promise((resolve, reject) => {
+    (function check(){
+      let result: T | undefined;
+      try {
+        result = callback();
+      } catch {
+        result = undefined;
+      }
+
+      if (result || (tryCount--) < 0) {
+        result ? resolve(result) : reject(new Error('Rejected by limit of tries'));
+      } else {
+        setTimeout(check, timeout);
+      }
+    })();
+  });
+}
+
 /** Deferred object represents promise with it's resolve/reject methods */
 export type Deferred<T> = {
   /** Wrapped promise */
@@ -24,108 +91,54 @@ export type Deferred<T> = {
   reject: (arg?: any) => void;
 };
 
-/** Return function type with the same signature but with the result type wrapped into promise */
+/**
+ * Create Deferred Object that wraps promise and its resolve and reject callbacks
+ */
+export function createDeferred<T>(): Deferred<T> {
+  let reject: any;
+  let resolve: any;
+  // Both reject and resolve will be assigned anyway while the Promise constructing.
+  const promise = new Promise<T>((res, rej) => {
+    resolve = res;
+    reject = rej;
+  });
+  return {promise, resolve, reject};
+}
+
+/** Creates function type with the same signature but with the result type wrapped into promise */
 export type PromisifyResultFn<F extends AnyToAnyFnSignature> =
   ((...args: Parameters<F>) => Promise<ReturnType<F> | void>);
 
 /**
- * Promise utils helper class
+ * Safe wrap for Promise.resolve to use in Promise chain
+ * @example
+ * `const resolvedPromise = rejectedPromise.catch(resolvePromise);`
  */
-export abstract class PromiseUtils {
-  /**
-   * @return {Promise} that will be resolved in {@param timeout} with optional {@param payload}
-   */
-  static fromTimeout<T>(timeout: number, payload?: T): Promise<T> {
-    return new Promise<T>((resolve) =>
-      setTimeout(resolve.bind(null, payload), timeout)
-    );
-  }
-
-  /**
-   * @return {Promise} that will be resolved by dispatching {@param event} on {@param target}
-   * Or it will be rejected in {@param timeout} if it's specified
-   * Optional {@param options} for addEventListener can be also specified
-   */
-  static fromEvent(
-    target: ListenableTarget,
-    event: string,
-    timeout?: number | null | undefined,
-    options?: boolean | AddEventListenerOptions
-  ): Promise<Event> {
-    return new Promise((resolve, reject) => {
-      function eventCallback(e: Event) {
-        target.removeEventListener(event, eventCallback, options);
-        resolve(e);
-      }
-
-      target.addEventListener(event, eventCallback, options);
-      if (typeof timeout === 'number' && timeout >= 0) {
-        setTimeout(() => reject(new Error('Rejected by timeout')), timeout);
-      }
-    });
-  }
-
-  /**
-   * Short helper to make Promise from element state marker
-   * Marker should be accessible and listenable
-   * @example
-   * const imgReady = PromiseUtils.fromMarker(eslImage, 'ready');
-   */
-  static fromMarker(target: HTMLElement, marker: string, event?: string): Promise<HTMLElement> {
-    if ((target as any)[marker]) return Promise.resolve(target);
-    return PromiseUtils.fromEvent(target, event || marker).then(() => target);
-  }
+export function resolvePromise<T>(arg: T | PromiseLike<T>): Promise<T> {
+  return Promise.resolve(arg);
+}
 
-  /**
-   * Safe wrap for Promise.resolve to use in Promise chain
-   * @example
-   * const resolvedPromise = rejectedPromise.catch(PromiseUtils.resolve);
-   */
-  static resolve<T>(arg: T | PromiseLike<T>): Promise<T> {
-    return Promise.resolve(arg);
-  }
+/**
+ * Safe wrap for Promise.reject to use in Promise chain
+ * @example
+ * `const rejectedPromise = resolvedPromise.then(rejectPromise);`
+ */
+export function rejectPromise<T = never>(arg?: T | PromiseLike<T>): Promise<T> {
+  return Promise.reject(arg);
+}
 
-  /**
-   * Safe wrap for Promise.reject to use in Promise chain
-   * @example
-   * const rejectedPromise = resolvedPromise.then(PromiseUtils.resolve);
-   */
-  static reject<T = never>(arg?: T | PromiseLike<T>): Promise<T> {
-    return Promise.reject(arg);
-  }
+/**
+ * Promise utils helper class
+ * Note: use individual methods in case you need correct "tree shaking"
+ */
+export abstract class PromiseUtils {
+  static fromTimeout = promisifyTimeout;
+  static fromEvent = promisifyEvent;
+  static fromMarker = promisifyMarker;
 
-  /**
-   * Call {@param callback} limited by {@param tryCount} amount of times with interval in {@param timeout} ms
-   * @return {Promise} that will be resolved as soon as callback returns truthy value, or reject it by limit.
-   */
-  static tryUntil<T>(callback: () => T, tryCount = 2, timeout = 100): Promise<T> {
-    return new Promise((resolve, reject) => {
-      const interval = setInterval(() => {
-        let result: T | undefined;
-        try {
-          result = callback();
-        } catch {
-          result = undefined;
-        }
-        if (result || --tryCount < 0) {
-          clearInterval(interval);
-          result ? resolve(result) : reject(new Error('Rejected by limit of tries'));
-        }
-      }, timeout);
-    });
-  }
+  static tryUntil = tryUntil;
 
-  /**
-   * Create Deferred Object that wraps promise and its resolve and reject callbacks
-   */
-  static deferred<T>(): Deferred<T> {
-    let reject: any;
-    let resolve: any;
-    // Both reject and resolve will be assigned anyway while the Promise constructing.
-    const promise = new Promise<T>((res, rej) => {
-      resolve = res;
-      reject = rej;
-    });
-    return {promise, resolve, reject};
-  }
+  static deferred = createDeferred;
+  static resolve = resolvePromise;
+  static reject = rejectPromise;
 }
diff --git a/src/modules/esl-utils/async/raf.ts b/src/modules/esl-utils/async/raf.ts
index 956564511..047469268 100644
--- a/src/modules/esl-utils/async/raf.ts
+++ b/src/modules/esl-utils/async/raf.ts
@@ -2,14 +2,12 @@ import type {AnyToVoidFnSignature} from '../misc/functions';
 
 /**
  * Postpone action after next render
- * @param {function} callback
  */
 export const afterNextRender = (callback: () => void) => requestAnimationFrame(() => requestAnimationFrame(callback));
 
 /**
  * Decorate function to schedule execution after next render
- * @param {function} fn
- * @returns {function} - decorated function
+ * @returns decorated function
  */
 export const rafDecorator = <T extends AnyToVoidFnSignature>(fn: T): T => {
   let lastArgs: any[] | null = null; // null if no calls requested
diff --git a/src/modules/esl-utils/async/throttle.ts b/src/modules/esl-utils/async/throttle.ts
index 80e1126fa..a07c68bee 100644
--- a/src/modules/esl-utils/async/throttle.ts
+++ b/src/modules/esl-utils/async/throttle.ts
@@ -1,20 +1,19 @@
-import {PromiseUtils} from './promise';
+import {createDeferred} from './promise';
 
 import type {AnyToAnyFnSignature} from '../misc/functions';
 import type {Deferred, PromisifyResultFn} from './promise';
 
 /** Throttled<F> is a function wrapper type for a function decorated via throttle */
 export interface Throttled<F extends AnyToAnyFnSignature> extends PromisifyResultFn<F> {
-  /** {Promise} of throttled function call */
+  /** Promise of throttled function call */
   promise: Promise<ReturnType<F> | void>;
 }
 
 /**
  * Creates a throttled executed function.
  * The func is invoked with the last arguments provided to the throttled function.
- * @param fn
+ * @param fn - function to decorate
  * @param threshold - indicates how often function could be called
- * @returns {Function}
  */
 export function throttle<F extends AnyToAnyFnSignature>(fn: F, threshold = 250): Throttled<F> {
   let last: number;
@@ -29,7 +28,7 @@ export function throttle<F extends AnyToAnyFnSignature>(fn: F, threshold = 250):
       return Promise.resolve(fn.apply(this, args));
     }
 
-    deferred = deferred || PromiseUtils.deferred();
+    deferred = deferred || createDeferred();
     (typeof timeout === 'number') && clearTimeout(timeout);
     timeout = window.setTimeout(() => {
       last = now;


Test patch:
diff --git a/src/modules/esl-utils/async/test/debounce.test.ts b/src/modules/esl-utils/async/test/debounce.test.ts
index def5e68e4..6e03a3124 100644
--- a/src/modules/esl-utils/async/test/debounce.test.ts
+++ b/src/modules/esl-utils/async/test/debounce.test.ts
@@ -1,29 +1,30 @@
 import {debounce} from '../debounce';
 
 describe('async/debounce', () => {
-  test('basic scenario', (done) => {
-    const fn = jest.fn();
-    const debounced = debounce(fn, 20);
+  beforeAll(() =>  jest.useFakeTimers());
+  afterAll(() => jest.useRealTimers());
 
-    expect(typeof debounced).toBe('function');
+  test('basic scenario', () => {
+    const fn = jest.fn();
+    const debounced = debounce(fn, 50);
 
     expect(debounced()).toBeInstanceOf(Promise);
     expect(debounced()).toBeInstanceOf(Promise);
-    setTimeout(() => debounced());
+    jest.advanceTimersByTime(25);
+    expect(debounced()).toBeInstanceOf(Promise);
     expect(fn).toBeCalledTimes(0);
-    setTimeout(() => {
-      expect(fn).toBeCalledTimes(1);
-      done();
-    }, 100);
-  }, 150);
+    jest.advanceTimersByTime(50);
+    expect(fn).toBeCalledTimes(1);
+  });
 
   test('call context', () => {
     const fn = function () { return this; };
     const debounced = debounce(fn, 0);
-
     const context = {};
-    return debounced.call(context).then((val: any) => expect(val).toBe(context));
-  }, 50);
+    const promise$ = debounced.call(context);
+    jest.runAllTimers();
+    return expect(promise$).resolves.toBe(context);
+  });
 
   test('cancel debounce', () => {
     const fn = jest.fn();
@@ -37,18 +38,19 @@ describe('async/debounce', () => {
 
   test('deferred result', () => {
     const fn = jest.fn((n) => n + 1);
-    const debounced = debounce(fn as (n: number) => number);
+    const debounced = debounce(fn as (n: number) => number, 20);
 
     expect(debounced.promise).toBeInstanceOf(Promise);
     expect(debounced(1)).toBe(debounced(2));
     expect(debounced.promise).toBe(debounced(3));
     expect(debounced.promise).toBeInstanceOf(Promise);
-    setTimeout(() => debounced(4), 20);
     expect(fn).toBeCalledTimes(0);
+    jest.advanceTimersByTime(10);
+    expect(fn).toBeCalledTimes(0);
+    const promise$ = debounced(4);
+    jest.advanceTimersByTime(20);
+    expect(fn).toBeCalledTimes(1);
 
-    return debounced.promise.then((n) => {
-      expect(n).toBe(4);
-      expect(fn).toBeCalledTimes(1);
-    });
-  }, 100);
+    return expect(promise$).resolves.toBe(5);
+  });
 });
diff --git a/src/modules/esl-utils/async/test/promise.test.ts b/src/modules/esl-utils/async/test/promise.test.ts
index feab9c26d..2c8846a27 100644
--- a/src/modules/esl-utils/async/test/promise.test.ts
+++ b/src/modules/esl-utils/async/test/promise.test.ts
@@ -1,83 +1,122 @@
-import {PromiseUtils} from '../promise';
+import {createDeferred, PromiseUtils, promisifyEvent, promisifyMarker, promisifyTimeout, rejectPromise, resolvePromise, tryUntil} from '../promise';
 
-describe('PromiseUtils', () => {
-  describe('common', () => {
-    test('resolve', () => PromiseUtils.resolve(true).then((v) => expect(v).toBe(true)));
-    test('reject', () => PromiseUtils.reject(true).catch((v) => expect(v).toBe(true)));
+describe('promise utils', () => {
+  beforeAll(() =>  jest.useFakeTimers());
+  afterAll(() => jest.useRealTimers());
+
+  describe('promise chain', () => {
+    test('resolve', () => {
+      const payload = Symbol();
+      return expect(Promise.reject(payload).catch(resolvePromise)).resolves.toBe(payload);
+    });
+    test('reject', () => {
+      const payload = Symbol();
+      return expect(Promise.resolve(payload).then(rejectPromise)).rejects.toBe(payload);
+    });
   });
 
   describe('deferred', () => {
     test('resolve', () => {
-      const def$$ = PromiseUtils.deferred();
+      const def$$ = createDeferred();
       def$$.resolve(1);
       return def$$.promise.then((n) => expect(n).toBe(1));
     });
     test('reject', () => {
-      const def$$ = PromiseUtils.deferred();
+      const def$$ = createDeferred();
       def$$.reject(1);
       return def$$.promise.catch((n) => expect(n).toBe(1));
     });
   });
 
-  describe('fromTimeout', () => {
-    test('fromTimeout', () =>
-        PromiseUtils.fromTimeout(30, true)
-          .then((v) => expect(v).toBe(true))
-    );
+  describe('promisifyTimeout', () => {
+    test('promisifyTimeout', () => {
+      const resCb = jest.fn();
+      const rejCb = jest.fn();
+
+      const promise$ = promisifyTimeout(100, true);
+      promise$.then(resCb, rejCb);
+
+      jest.advanceTimersByTime(50);
+      expect(resCb).not.toBeCalled();
+      expect(rejCb).not.toBeCalled();
+      jest.advanceTimersByTime(100);
+
+      return expect(promise$).resolves.toBe(true);
+    });
   });
 
-  describe('fromEvent', () => {
+  describe('promisifyEvent', () => {
     test('simple', () => {
       const el = document.createElement('div');
-      setTimeout(() => el.dispatchEvent(new CustomEvent('test')), 20);
-      return PromiseUtils.fromEvent(el, 'test').then((e) => expect(e.type).toBe('test'));
+      const promise$ = promisifyEvent(el, 'test');
+      el.dispatchEvent(new CustomEvent('test'));
+      return expect(promise$.then((e) => e.type)).resolves.toBe('test');
     });
     test('timeout', () => {
       const el = document.createElement('div');
-      setTimeout(() => el.dispatchEvent(new CustomEvent('test')), 100);
-      return PromiseUtils.fromEvent(el, 'test', 10).catch(() => expect(true).toBe(true));
+      const promise$ = promisifyEvent(el, 'test', 10);
+      jest.advanceTimersByTime(100);
+      return expect(promise$).rejects.toBeInstanceOf(Error);
     });
   });
 
-  describe('fromMarker', () => {
+  describe('promisifyMarker', () => {
     test('attribute', () => {
       const el = document.createElement('div');
       (el as any).test = true;
-      return PromiseUtils.fromMarker(el, 'test').then((e) => expect(true).toBe(true));
+      const promise$ = promisifyMarker(el, 'test');
+      return expect(promise$).resolves.toBe(el);
     });
 
     test('event', () => {
       const el = document.createElement('div');
-      setTimeout(() => el.dispatchEvent(new CustomEvent('test')), 10);
-      return PromiseUtils.fromMarker(el, 'test').then((e) => expect(true).toBe(true));
+      const promise$ = promisifyMarker(el, 'test');
+      jest.advanceTimersByTime(50);
+      el.dispatchEvent(new CustomEvent('test'));
+      return expect(promise$).resolves.toBe(el);
     });
   });
 
   describe('tryUntil', () => {
-    test('successful', () => {
-      let i = 0;
+    test.each([
+      [(): any => undefined],
+      [(): any => {throw new Error()}]
+    ])('successful ( %s )', (testFn) => {
+      const observedFn = jest.fn(testFn);
+      const successFn = jest.fn();
+      const promise$ = tryUntil(observedFn, 3, 100);
+      promise$.then(successFn);
 
-      setTimeout(() => i++, 30);
-      setTimeout(() => i++, 60);
+      expect(observedFn).toBeCalled();
+      expect(successFn).not.toBeCalled();
 
-      return PromiseUtils.tryUntil(() => i > 1, 10, 10)
-        .then(() => expect(true).toBe(true));
-    });
+      jest.advanceTimersByTime(110);
+
+      expect(observedFn).toBeCalledTimes(2);
+      expect(successFn).not.toBeCalled();
+
+      observedFn.mockReturnValue(true);
+      jest.advanceTimersByTime(110);
 
-    test('error', () => {
-      let i = false;
-      function fn() {
-        if (i) return true;
-        i = true;
-        throw new Error('Error test');
-      }
-      return PromiseUtils.tryUntil(fn, 2, 10)
-        .then(() => expect(true).toBe(true));
+      return expect(promise$).resolves.toBe(true);
     });
 
-    test('failed', () => {
-      return PromiseUtils.tryUntil(() => false, 5, 10)
-        .catch(() => expect(true).toBe(true));
+    test('failed (() => false)', () => {
+      const promise$ = tryUntil(() => false, 3, 100);
+      jest.advanceTimersByTime(500);
+      return expect(promise$).rejects.toBeTruthy();
     });
   });
+
+  test('PromiseUtils wrapper', () => {
+    expect(PromiseUtils.fromEvent).toBe(promisifyEvent);
+    expect(PromiseUtils.fromTimeout).toBe(promisifyTimeout);
+    expect(PromiseUtils.fromMarker).toBe(promisifyMarker);
+
+    expect(PromiseUtils.deferred).toBe(createDeferred);
+    expect(PromiseUtils.tryUntil).toBe(tryUntil);
+
+    expect(PromiseUtils.resolve).toBe(resolvePromise);
+    expect(PromiseUtils.reject).toBe(rejectPromise);
+  });
 });
diff --git a/src/modules/esl-utils/async/test/throttle.test.ts b/src/modules/esl-utils/async/test/throttle.test.ts
index cb1e87c4c..6c2d43c2b 100644
--- a/src/modules/esl-utils/async/test/throttle.test.ts
+++ b/src/modules/esl-utils/async/test/throttle.test.ts
@@ -1,28 +1,24 @@
 import {throttle} from '../throttle';
 
-// TODO: sometimes test timeouts is ont ok!
 describe('async/throttle', () => {
-  test('basic scenario', (done) => {
-    const fn = jest.fn();
-    const throttled = throttle(fn, 80);
+  beforeAll(() =>  jest.useFakeTimers());
+  afterAll(() => jest.useRealTimers());
 
-    expect(typeof throttled).toBe('function');
+  test('basic scenario', () => {
+    jest.useFakeTimers()
+    const fn = jest.fn();
+    const throttled = throttle(fn, 100);
 
     expect(throttled()).toBeInstanceOf(Promise);
     expect(fn).toBeCalledTimes(1);
+    jest.advanceTimersByTime(50)
     expect(throttled()).toBeInstanceOf(Promise);
     expect(fn).toBeCalledTimes(1);
-    setTimeout(() => {
-      throttled();
-      expect(fn).toBeCalledTimes(1);
-    }, 20);
-    setTimeout(() => {
-      expect(fn).toBeCalledTimes(2);
-      throttled();
-      expect(fn).toBeCalledTimes(3);
-      done();
-    }, 250);
-  }, 350);
+    jest.advanceTimersByTime(100)
+    expect(fn).toBeCalledTimes(2);
+    expect(throttled()).toBeInstanceOf(Promise);
+    expect(fn).toBeCalledTimes(3);
+  });
 
   test('test context', () => {
     const fn = function () { return this; };
@@ -37,16 +33,18 @@ describe('async/throttle', () => {
     const throttled = throttle(fn as (n: number) => number, 50);
 
     expect(throttled.promise).toBeInstanceOf(Promise);
-
-    throttled(1);
+    expect(throttled(1)).toBeInstanceOf(Promise);
     expect(throttled(2)).toBe(throttled.promise);
     expect(throttled.promise).toBeInstanceOf(Promise);
-    throttled(4);
+    expect(throttled(4)).toBeInstanceOf(Promise);
+
     expect(fn).toBeCalledTimes(1);
+    expect(fn).lastCalledWith(1);
+
+    const {promise} = throttled;
 
-    return throttled.promise.then((n) => {
-      expect(n).toBe(5);
-      expect(fn).toBeCalledTimes(2);
-    });
-  }, 100);
+    jest.advanceTimersByTime(100);
+    expect(fn).toBeCalledTimes(2);
+    return expect(promise).resolves.toBe(5);
+  });
 });