概要
Jest で Promise
の返り値のテストを書いていたときに
setTimeout
が絡むと非同期のテストがうまく完了しないことに気づきました。
例
たとえば文字列を指定回数繰り返す非同期関数のテストを書いてみます (非同期でなくてもいい処理ですが):
test('repeat should repeat text given times', async () => {
await expect(repeat('go', 5))
.resolves
.toBe('gogogogogo');
});
repeat('go', 5)
は 'go'
を 5 回繰り返した文字列を返すことをテストする感じにします。
それに沿うように実装してみます:
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 にタイマーをフェイクのものに差し替えて
タイミングをコントロールできるようにします:
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 経由での取得が考えられます):
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
を即時実行するように実装を置き換えてみる方法です。
test('repeat should repeat text given times', async () => {
useImmediateTimers();
await expect(repeat('go', 5))
.resolves
.toBe('gogogogogo');
});
これは (この場合) うまくいきます。
しかしながら、実際に経過時刻を見ていたりするケースであったり、
タイマーのキャンセルを行うケースであったりには対応できません。
その場合、内部実装を考えてタイマーを差し替えるというよくない感じになりそうです。
(そもそもタイマーの関連する処理を切り出して Inject できる実装にしておくのも考えられますね)
なにか他に方法があれば教えてください。