はじめに
Angular のプロジェクトで spec.ts を眺めていたら、こんな書き方が出てきました。
it('1秒後に値が入る', fakeAsync(() => {
// ...
tick(1000);
// ...
}));
it の第2引数がいきなり fakeAsync(...) で包まれていて、中には tick(1000) という見慣れない関数があります。「1000ミリ秒待つ」ように見えるのに、テストは一瞬で終わります。
-
fakeAsyncは何をしているのか -
tickは何を進めているのか -
waitForAsyncとは何が違うのか
このあたりが気になったので、調べてまとめました。
なお、fakeAsync は現在の Angular 公式ドキュメントでは「使用は推奨されない」とされています(API が deprecated になったわけではなく、ガイド上の推奨が変わったという話です)。この記事は既存プロジェクトで見かける fakeAsync を読み解くための内容で、その背景は最後の「今から新しく書くなら」で触れます。
そもそも何が面倒なのか
まず fakeAsync を使わずに、非同期処理をテストすることを考えてみます。
it('1秒後に値が入る', (done) => {
let value = '';
setTimeout(() => (value = 'done'), 1000);
setTimeout(() => {
expect(value).toBe('done');
done();
}, 1000);
});
setTimeout の中身は同期的には実行されないので、その場で expect を書いても検証できません。結果を確かめるには、コールバックの中に入るか done を待つ必要があります。
これには2つの問題があります。
- 本当に1秒待つ。同じようなテストが10個あれば10秒かかる
- 検証コードがコールバックの中に散らばる。テストの流れが追いづらくなる
setTimeout が入れ子になっている処理や、debounceTime のように時間そのものが仕様の一部になっている処理だと、この書き方はかなり厳しくなってきます。
fakeAsync は時間を「なかったこと」にする
fakeAsync は @angular/core/testing が提供する関数で、テスト本体を専用の Zone(fakeAsync test zone)の中で実行します。
この Zone の中では zone.js が setTimeout / setInterval などのタイマー系 API を差し替えていて、呼んでも登録されるだけで実行されない状態になります。Promise についても、.then の続きがマイクロタスクとして zone.js に追跡され、その場では実行されません。そして tick() を呼ぶと「時計の針をそのぶん進めたことにして」、対象のタスクを同期的に実行します。
最初の例を fakeAsync で書き直すとこうなります。
import { fakeAsync, tick } from '@angular/core/testing';
it('1秒後に値が入る', fakeAsync(() => {
let value = '';
setTimeout(() => (value = 'done'), 1000);
expect(value).toBe(''); // まだタイマーは発火していない
tick(1000); // 仮想時間を1秒進める
expect(value).toBe('done');
}));
done もコールバックもなくなり、上から下へ読める形になりました。しかも指定した1秒を実際に待つことはないので一瞬で終わります。
「時間が経つのを待つ」のではなく「時間が経ったことにする」というのが fakeAsync の発想です。
針を進める関数たち
tick 以外にも、キューを操作する関数がいくつか用意されています。
| 関数 | やること |
|---|---|
tick(ms) |
仮想時間を ms 進め、その間に発火するタイマーを実行する。開始時と各タイマーの実行後にマイクロタスクも消化する |
tick() |
0ミリ秒ぶん進める。Promise の解決を進めたいときに使う |
flush() |
マイクロタスクを消化したうえで、キューに残っている非周期タイマーを空になるまで実行する。進んだ仮想時間を返す |
flushMicrotasks() |
Promise などのマイクロタスクだけを流す |
discardPeriodicTasks() |
setInterval のような繰り返しタスクをキューから捨てる |
tick(1000) のように具体的な時間がわかっているならそれが一番わかりやすいですが、「何ミリ秒かは知らないが、残っている非周期タイマーを最後まで進めたい」という場面では flush() が便利です。
it('チェーンした非同期処理が最後まで進む', fakeAsync(() => {
let value = '';
setTimeout(() => {
setTimeout(() => (value = 'done'), 500);
}, 1000);
flush(); // 入れ子のタイマーも含めて全部消化する
expect(value).toBe('done');
}));
flush() が対象にするのは非周期のタイマーだけです。setInterval のような繰り返しタイマーは消化されずに残るので、そちらは discardPeriodicTasks() で片付けます。
また flush() は無限にタスクを消化し続けるわけではなく、消化の回数に上限があります(デフォルトは20)。setTimeout のコールバックから次の setTimeout を登録し続けるポーリング処理などに対して使うと、flush failed after reaching the limit of 20 tasks. というエラーになります。上限は flush(50) のように引数で変えられます。
Promise だけを扱う場合は tick() を引数なしで呼ぶか、flushMicrotasks() を使います。
it('Promise が解決される', fakeAsync(() => {
let value = '';
Promise.resolve('done').then((v) => (value = v));
expect(value).toBe('');
flushMicrotasks();
expect(value).toBe('done');
}));
タイマーが残ったままテストを抜けたらどうなるか
現在は、テスト関数を抜けるときに残っているタイマーを fakeAsync が自動で消化します。消し忘れてもエラーにはなりません。
以前は逆で、実行されていないタスクが残っていると例外を投げていました。1 timer(s) still in the queue. や 1 periodic timer(s) still in the queue. というメッセージで落ちるやつです。ネット上の記事でこのエラーの対処法をよく見かけるのは、こちらが長くデフォルトだったためです。この挙動は 2024年8月にリリースされた zone.js 0.15 で変わっています。
昔の挙動に戻したい場合は、オプションで明示します。
it('タイマーを消し忘れたら落ちてほしい', fakeAsync(() => {
setTimeout(() => {}, 1000);
// ここで抜けると "1 timer(s) still in the queue." で落ちる
}, { flush: false }));
{ flush: false } を付けたときは、残っているタイマーを自分で片付ける必要があります。
- 通常のタイマーが残っている →
tick(...)かflush()で消化する -
setIntervalが残っている → 止まらないのでclearIntervalするかdiscardPeriodicTasks()で捨てる
it('ポーリングが動く', fakeAsync(() => {
let count = 0;
setInterval(() => count++, 1000);
tick(3000);
expect(count).toBe(3);
discardPeriodicTasks(); // 繰り返しタイマーをキューから捨てる
}, { flush: false }));
「タスクが残っていたら教えてほしい」というのはテストとして妥当な要求だと思うので、この挙動が明示指定になったのは少し意外でした。とはいえ、fixture.detectChanges() 起点でアニメーションのタイマーが増えるようなケースだと、テストの本題と関係ないところで落ちて面倒だったのも確かです。
debounceTime のテストが書きやすくなる
実際に助かるのは、RxJS の時間系オペレーターを検証するときです。検索ボックスの入力を debounceTime(300) で間引く、というよくある実装を考えます。
export class SearchComponent {
searchControl = new FormControl('');
constructor(private searchService: SearchService) {
this.searchControl.valueChanges
.pipe(debounceTime(300))
.subscribe((keyword) => this.searchService.search(keyword ?? ''));
}
}
「300ミリ秒経つまでは呼ばれない」「300ミリ秒経ったら呼ばれる」という境界を、そのまま書けます。
it('入力から300ms経つまで検索が走らない', fakeAsync(() => {
component.searchControl.setValue('angular');
tick(299);
expect(searchServiceSpy.search).not.toHaveBeenCalled();
tick(1);
expect(searchServiceSpy.search).toHaveBeenCalledWith('angular');
}));
実時間で書こうとすると setTimeout で 299ミリ秒待つようなテストになり、遅いうえにマシンの負荷次第で flaky になります。仮想時間なら 299 と 300 の境界を確実に踏めます。
連続入力で間引かれることも確認できます。
it('連続入力は最後の1回だけ検索される', fakeAsync(() => {
component.searchControl.setValue('a');
tick(100);
component.searchControl.setValue('an');
tick(100);
component.searchControl.setValue('ang');
tick(300);
expect(searchServiceSpy.search).toHaveBeenCalledTimes(1);
expect(searchServiceSpy.search).toHaveBeenCalledWith('ang');
}));
waitForAsync との違い
Angular のテストにはもう一つ waitForAsync という関数があります。名前が似ていて紛らわしいですが、役割は逆方向です。
waitForAsync は実際に非同期処理が終わるのを待つためのものです。テストを非同期テスト用の Zone で実行し、その Zone が追跡できた非同期処理がすべて片付くまでテストの完了を遅らせます。fixture.whenStable() と組み合わせて使う形が典型です。
it('テンプレートが更新される', waitForAsync(() => {
fixture.detectChanges();
fixture.whenStable().then(() => {
fixture.detectChanges();
expect(element.textContent).toContain('読み込み完了');
});
}));
対して fakeAsync は時間を自分で進めるためのものです。どちらを使うかは「時間の経過そのものを検証したいか」で決めるとわかりやすいです。
- 時間の境界を検証したい、タイマーを飛ばして高速に回したい →
fakeAsync - 非同期処理が終わった後の状態を見たいだけ →
waitForAsync(あるいはasync/awaitを使ったテスト)
fakeAsync の中でできないこと
実際の HTTP 通信は扱えない
fakeAsync は zone.js がパッチできるタスクしか制御できません。実際の XMLHttpRequest を伴う通信は仮想時間の対象外なので、tick() を呼んでもレスポンスは返ってきません。
Angular で HTTP をテストする場合は HttpTestingController でリクエストをモックするのが前提になります。これなら実通信が発生しないので fakeAsync と併用できます。
async / await とは相性が悪い
fakeAsync の中でネイティブの async 関数を使うと、期待通りに動かないことがあります。zone.js はグローバルな API を差し替えることでタスクを横取りしていますが、JavaScript エンジンがネイティブに持っている async / await の待機処理には手を出せないためです(コンパイルのターゲット次第では Promise に変換されるので、その場合は追跡されます)。
fakeAsync の中では await を書かず、tick() や flushMicrotasks() で明示的に進めるスタイルに統一しておくのが無難だと感じました。
今から新しく書くなら
ここまで書いておいてなんですが、公式ドキュメントは fakeAsync について「使用はもはや推奨されない」という立場を取っています。代わりに、ネイティブの非同期テストか、テストランナー側が持つ fake timer(Vitest や Jasmine のもの)を使うことが推奨されています。
背景の一つには、Angular が zone.js から離れていく流れがあります。
- 変更検知の zoneless 化が進み、アプリから zone.js を外す方向になっている
- 新規プロジェクトのデフォルトのテストランナーが Vitest になった
-
fakeAsyncは zone.js に依存しているため、素の Vitest 環境では動かない
既存のテストを Vitest に移す場合、zone.js/plugins/vitest-patch をポリフィルに追加すれば fakeAsync や waitForAsync を動かせます。ただしこれは移行のための互換レイヤーという位置づけで、公式は最終的にネイティブの async と Vitest の fake timer へ書き換えることを勧めています。
とはいえ Karma + Jasmine で回っている既存プロジェクトはまだたくさんあるはずで、そこでは fakeAsync が現役です。読めるようにしておく価値は当分あると思います。
まとめ
-
fakeAsyncはテスト本体を専用の Zone で実行し、setTimeoutなどのタスクを「実行せずキューに溜める」状態にする -
tick(ms)で仮想時間を進めると、そのタイミングのタスクが同期的に実行される。実時間を待たないのでテストが速い - テストを抜けるときの後始末は、現在は残ったタイマーを自動で消化する。
{ flush: false }を渡すと、以前のようにstill in the queueで落ちる挙動になる -
debounceTimeのような時間が仕様になっている処理の境界値を、確実に検証できる -
waitForAsyncは「非同期の完了を待つ」側の関数で、目的が違う - 公式は現在
fakeAsyncを推奨しておらず、新規は Vitest の fake timer やネイティブのasyncが案内されている
最初は it(..., fakeAsync(() => { ... })) という入れ子が読みづらく感じましたが、「非同期のテストを、待たずに同期コードのように書くためのお約束」と捉えたら腑に落ちました。推奨されなくなったとはいえ、時間の経過をテストで制御するという発想自体は Vitest の fake timer でも同じなので、ここで理解しておいて損はなさそうです。
参考になったら いいね や ストック をお願いします!
同じような経験をされた方のコメントもお待ちしています。
参考
- fakeAsync - Angular 公式ドキュメント
- Component testing scenarios - Angular 公式ドキュメント
- Migrating to Vitest - Angular 公式ドキュメント
- Zoneless - Angular 公式ドキュメント
関連リンク
技術ブログでも学びや検証内容をまとめています。