23
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Jest で Promise + setTimeout のテストがつまる件

Posted at

概要

Jest で Promise の返り値のテストを書いていたときに
setTimeout が絡むと非同期のテストがうまく完了しないことに気づきました。

たとえば文字列を指定回数繰り返す非同期関数のテストを書いてみます (非同期でなくてもいい処理ですが):

repeat.test.ts
test('repeat should repeat text given times', async () => {
    await expect(repeat('go', 5))
        .resolves
        .toBe('gogogogogo');
});

repeat('go', 5)'go' を 5 回繰り返した文字列を返すことをテストする感じにします。
それに沿うように実装してみます:

repeat.ts
async function repeat(text: string, length: number): Promise<string> {
    // ウェイトをいれる
    await new Promise(
        (resolve, _) => setTimeout(resolve, 10000)
    );

    return Array
        .from({ length }, () => text)
        .join('');
}

無駄に setTimeout を使ってウェイト処理を入れてみました。
10 秒のウェイトをおくようにしています。
実際のケースではもろもろあってウェイトが必要なケースであると想定しています。

このテストの結果はタイムアウトで失敗します:

Timeout - Async callback was not invoked within the 5000ms timeout specified by jest.setTimeout.

Jest の非同期テストのデフォルトのタイムアウトが 5000 ms. だからですね。
しかもこのテストには実際に 10 秒かかってしまいます。
テストケースが増えると面倒なことにもなりそうですね。

useFakeTimers() を使う

Jest にタイマーをフェイクのものに差し替えて
タイミングをコントロールできるようにします:

repeat.test.ts
jest.useFakeTimers();

test('repeat should repeat text given times', async () => {
    const job = repeat('go', 5);
    jest.runAllTimers();
    await expect(job)
        .resolves
        .toBe('gogogogogo');
});

jest.runAllTimers() でフェイクの setTimeout を即時実行することで
非同期テストのタイムアウト問題が解決します。
テストの時間も短縮されました。

Promise + setTimeout

このケースでは文字列の整形結果の取得は同期的に行われていますが、
ウェイトとは別に結果が Promise で非同期に取得されるとしてみます
(実際のケースでは WebAPI 経由での取得が考えられます):

repeat.ts
async function repeat(text: string, length: number): Promise<string> {
    // 結果を WebAPI とかから取得するとする
    const result = await Promise.resolve(
        Array.from({ length }, () => text)
            .join('')
    );
    // ウェイトをいれる
    await new Promise(
        (resolve, _) => setTimeout(resolve, 10000)
    );

    return result;
}

この実装では再び Jest のテストタイムアウトのエラーでテストが失敗してしまいます。
たとえウェイトを 10 秒から 1 msec. に変更したとしてもです。
解決したはずのタイムアウト問題がまた起こってしまいました。

これについては Jest の Issue で報告がされています:
useFakeTimers breaks with native promise implementation
フェイクのタイマーが Promise の実装を壊しているのかもという Issue です。

この問題によって結果の Promise
いつまでたっても resolve されないことが要因になっているようですね。

Workaround

このケースに限って むりやりどうにかする方法を考えてみました:

function useImmediateTimers(global: any = window) {
    global.setTimeout = (
        handler: TimerHandler,
        timeout?: number | undefined,
        ...args: any[]
    ): number => {
        const callableHandler = (typeof handler === 'string')
            ? Function(handler)
            : handler;
        Promise
            .resolve()
            .then(() => callableHandler.apply(args));

        return -1;
    };
}

setTimeout を即時実行するように実装を置き換えてみる方法です。

repeat.test.ts
test('repeat should repeat text given times', async () => {
    useImmediateTimers();
    await expect(repeat('go', 5))
        .resolves
        .toBe('gogogogogo');
});

これは (この場合) うまくいきます。

しかしながら、実際に経過時刻を見ていたりするケースであったり、
タイマーのキャンセルを行うケースであったりには対応できません。
その場合、内部実装を考えてタイマーを差し替えるというよくない感じになりそうです。
(そもそもタイマーの関連する処理を切り出して Inject できる実装にしておくのも考えられますね)
なにか他に方法があれば教えてください。

23
9
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
23
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?