Angularのテストが壊れやすい理由は、個別のバグというより「非同期の待ち方が揃っていない」「モックの境界が曖昧」「状態の後始末が弱い」といった構造的な問題にあります。特にAngularは変更検知、Zone.js、HTTP、タイマー、DIが絡むため、テストコードが“たまたま通る”状態のままだと、flakyやCI落ちに発展しやすくなります。つまり、壊れる理由はランダムではなく、かなりパターン化できます。症状ごとに原因を切り分け、待ち方・モック位置・掃除ルールを揃えることが、Angularテストを安定させる一番の近道です。

関連:Angularテスト入門:TestBedでDI差し替えとHTTPモック
まず結論:壊れる原因はパターン化できる
1-1. flakyの典型(待ち不足、タイマー、HTTP)
Angularテストのflakyは、ほとんどの場合「非同期処理の終わりを正しく待てていない」ことが原因です。ランダムに見えても、実際にはDOM更新、タイマー、HTTPのどれかを取りこぼしているケースが大半です。
Angularでは、コンポーネントの変更検知、Promiseベースの処理、Observable、タイマー、HTTPモックが重なります。そのため、ローカルではたまたま速く終わって通るのに、CIでは少し遅れて失敗する、というズレが起きやすいです。特にfixture.detectChanges()のタイミングや、whenStable()を待つべき箇所を誤ると、「表示が更新される前にexpectしている」状態になります。
見分け方としては、「たまに落ちる」「CIだけ落ちる」「リランすると通る」が典型です。こういう症状が出たら、まず実装を疑うより先に、待ち方が統一されているかを確認してください。Angularテストでflakyが出たときは、真っ先に“非同期の取り扱い”を見るのが定石です。
1-2. モック地獄の典型(境界が曖昧)
モック地獄は、どこまでを本物で、どこからを差し替えるかの境界が曖昧なときに起きます。テストを速くしたい気持ちだけで何でもモックすると、逆に壊れやすくて読みにくいテストになります。
たとえば、コンポーネントテストなのにServiceの中身まで細かく模倣し、さらにHTTPレスポンスも独自に再現していると、「このテストは何を保証しているのか」がぼやけます。逆に、何もモックせず全部本物でつなぐと、HTTPや外部依存に引っ張られて遅くなります。つまり、モック地獄は“モックが多いこと”そのものではなく、“責務の境界を決めずに増やしていること”が本質です。
対策は、テスト対象を先に言葉で決めることです。「このテストはコンポーネントの表示を見たい」「このテストはServiceのHTTP変換を見たい」と決めれば、モックすべき境界も自然に決まります。モックが増えすぎて読めないと感じたら、境界設計が崩れているサインです。
非同期の待ち方がブレている
2-1. “待つ場所”の見極め(DOM更新、HTTP、Timer)
Angularテストでは、何を待つべきかを先に切り分けることが大切です。待つ対象がDOM更新なのか、HTTP応答なのか、タイマーなのかで、使う手段が変わります。
たとえば、クリック後に画面が切り替わるだけなら、変更検知と安定化待ちで十分なことがあります。一方、Service経由でHTTPを叩くなら、HTTPモックに対してflush()する必要があります。さらに、debounceやsetTimeoutが入るなら、タイマーを進めない限り結果は出ません。ここを全部「なんとなくdetectChanges()を何回か呼ぶ」で済ませると、ローカルでは通ってもCIで壊れやすくなります。
まず確認すべきは、「この期待値が成立する前に、何が終わっている必要があるか」です。DOMだけなら描画の完了、HTTPならレスポンス注入、Timerなら仮想時間の進行、というふうに分けて考えると、待ち方の選択がかなり安定します。テストが壊れるチームほど、“待つ対象の分類”を言語化していません。
2-2. waitFor / fakeAsync の使い分けルール
Angularテストの待ち方は、「自然に終わるのを待つ」なら async/await 系、「時間を自分で進める」なら fakeAsyncで分けると整理しやすいです。両方を場当たり的に混ぜるのが一番危険です。
Promiseやfixtureの安定化を待つなら、await fixture.whenStable() や async/await が素直です。反対に、setTimeout、debounce、intervalなど、時間依存の処理があるなら fakeAsync と tick() の方が安定します。ここで問題になるのは、「なんとなく通ったから」で waitFor系と fakeAsync系を混在させることです。Zone.jsの管理対象がずれて、かえって不安定になります。
チームでルール化するなら、「HTTPだけならasync/await優先」「明示的なTimerがあるときだけfakeAsync」「1テスト内で両方を混ぜない」と決めるとかなり安定します。テストの待ち方は個人の好みでなく、運用ルールとして揃えたほうがflakyを減らせます。
タイマー・debounce・intervalが混ざる
3-1. タイマー起因flakyの見分け方
タイマー起因のflakyは、「たまに1回遅れる」「一見すると非同期っぽくない処理で落ちる」のが特徴です。特にdebounceや遅延表示が入っているUIでは、原因が見えにくくなります。
たとえば検索入力でdebounceを使っていたり、保存後メッセージを数秒表示したり、定期更新にintervalを使っていると、テスト側がその時間を進めない限り期待した状態になりません。それなのに、実行速度の速いローカルでは偶然通って、CIでは間に合わず失敗する、という差が起きます。これが「同じコードなのに通ったり落ちたりする」典型例です。
見分け方としては、対象コードにsetTimeout、RxJSのdebounceTime、timer、intervalがないかを確認してください。あれば、まずタイマーを制御していないことを疑います。Angularテストでflakyが出たとき、“時間依存の処理がないか”を grep で洗うだけでも前進します。
3-2. テスト側で制御する型(タイマー固定)
タイマーがあるテストでは、時間の進み方をテスト側で固定するのが基本です。実時間に依存すると、マシン速度やCI負荷で結果がぶれます。
そのため、fakeAsync と tick() を使い、必要な時間だけ明示的に進めるのが安定パターンです。これにより、タイマー終了後の状態を即座に確認できます。また、複数のタイマーが入っているなら、どのタイミングで何が起きるかをテスト名に含めると読みやすくなります。「3秒後に保存メッセージが消える」なら、その3秒をコード上で明示すべきです。
it('should hide the toast after 3000ms', fakeAsync(() => {
component.showToast();
fixture.detectChanges();
expect(component.toastVisible).toBeTrue();
tick(3000);
fixture.detectChanges();
expect(component.toastVisible).toBeFalse();
}));
このコードでは、実時間を待たずに3000ms経過後の状態を検証しています。注意点は、tick()の前後で必要ならdetectChanges()を入れることと、同じテスト内でwaitFor系と混ぜないことです。タイマーを使う処理は“時間を固定するテスト”に寄せたほうが読みやすく、壊れにくくなります。
HTTP/外部I/Oの境界が曖昧
4-1. Service層でモックするか、HTTP層でモックするか
HTTPを含むテストが不安定になる理由の多くは、どの層でモックするかが決まっていないことです。コンポーネントテストとServiceテストで、同じやり方を使う必要はありません。
コンポーネントテストなら、通常はServiceをモックして、UIがどう変わるかに集中するほうが安定します。逆にService自体のHTTP処理を検証したいなら、そこではHttpTestingControllerを使ってHTTP層をモックするべきです。この境界が曖昧なままだと、コンポーネントテストでHTTPまで面倒を見たり、Serviceテストで余計なDOM描画を含めたりして、テストが遅く読みにくくなります。
判断に迷ったら、「このテストで確認したい責務はUIか、通信変換か」を先に決めてください。UIならServiceモック、通信変換ならHTTPモック、という切り分けにするだけで、かなり整理できます。Angularのテストがモック地獄になるのは、技術の問題より“責務単位の切り方”の問題です。
4-2. 失敗/再試行のテストを安定させる
HTTPテストで壊れやすいのは、成功ケースだけ見て、失敗や再試行をあいまいにしているときです。実務では通信失敗や一時エラーは普通に起きるので、ここを明示的にテストしないと、後で不安定になります。
失敗ケースでは、flush()でエラーステータスを返し、UIやServiceがどう反応するかを確認します。再試行がある場合は、「何回まで再試行するのか」「最後にどんなエラー状態になるのか」を分けて確認するのが大切です。ここを“なんとなくリトライされるはず”で放置すると、タイミング依存のflakyになりやすいです。
テストを安定させるコツは、成功・失敗・再試行を同じテストに詰め込まないことです。1つのテストで1つの期待値に絞り、「1回失敗したらメッセージを出す」「2回目で成功したら一覧を更新する」のように分けたほうが、落ちたときの原因も追いやすくなります。
共有状態(グローバル/シングルトン)が残る
5-1. テスト間汚染の典型例
Angularテストで見落としやすいのが、前のテストの状態が次のテストへ残る問題です。いわゆるテスト間汚染で、単体では通るのにまとめて実行すると落ちる原因になります。
典型例としては、rootスコープのServiceが前テストで状態を書き換えたまま残る、spyがリセットされていない、localStorageやsessionStorageの値が消えていない、グローバルなイベントリスナーが付けっぱなし、などがあります。AngularはDIが強力なぶん、アプリ全体共有の依存も作りやすく、そこが掃除不足だとテストに影響が残ります。
見分け方は、「単体実行では通るが、describe全体やCI全件だと落ちる」パターンです。こういうときは、実装より先に“前テストの副作用”を疑うべきです。特にシングルトン的なServiceやグローバル状態を触るテストは、汚染源になりやすいので要注意です。
5-2. beforeEach/afterEachの掃除ルール
共有状態を防ぐには、beforeEachで初期化し、afterEachで掃除するルールを徹底するのが基本です。テストの独立性は、テストフレームワークが自動で守ってくれるものではありません。
たとえば、spyは毎回作り直す、HttpTestingControllerはverify()で未処理リクエストがないか確認する、ストレージはclearする、タイマーや購読が残らないようにする、といった掃除が必要です。beforeEachは「前提を作る場所」、afterEachは「影響を残さない場所」と役割を固定すると、コードの見通しがよくなります。
チーム運用では、afterEachに最低限入れる共通ルールを決めておくと効果的です。たとえば「HTTP verifyは必須」「localStorageを触るテストではclear必須」などです。テスト間汚染はレビューで見逃しやすいので、運用ルールで下支えするのが現実的です。
速度が出ない(CIで遅い)
6-1. 重いテストの特徴(過剰セットアップ等)
CIでAngularテストが遅いときは、TestBedのセットアップが重すぎることがよくあります。全部入りの設定を使い回していると、1件ごとのテストが必要以上に高コストになります。
たとえば、本当は1コンポーネントだけ見たいのに、ルーティング、HTTP、複数の子コンポーネント、共通モジュール、重いプロバイダを全部読み込んでいると、初期化だけで時間がかかります。しかも、その構成が複雑なほど変更の影響を受けやすく、CIで不安定になりがちです。Angularテストが遅い原因は、ロジックの重さより「不要なAngular環境を作りすぎている」ことが多いです。
見直すときは、「このテストに本当に必要なimports/providersは何か」を削るところから始めると効果が出やすいです。特にStandaloneコンポーネントの時代は、必要な依存を狭くしやすいので、全部入りのテスト構成を見直す価値があります。
6-2. 並列/分割/キャッシュの現実解
CIの遅さはテストコードだけでなく、実行戦略の問題でもあります。全部を1ジョブで順番に流すだけだと、スケールしにくくなります。
現実的な対策としては、テスト種別の分割、CIジョブの並列化、依存インストールやビルドのキャッシュ活用があります。たとえば、軽いユニットテストと重い統合寄りテストを分けるだけでも、失敗の切り分けと速度改善の両方に効きます。また、Angular CLIの実行時間だけでなく、Node依存のインストール時間やブラウザ起動時間もボトルネックになりやすいので、そこも含めて最適化対象にすべきです。
ただし、並列化だけで根本問題は消えません。flakyや過剰セットアップを放置したまま並列数だけ増やすと、CIが速くなる代わりに不安定さが増すこともあります。まずテストを軽くし、その上で分割・並列・キャッシュを使う、という順番のほうが失敗しにくいです。
まとめ:TOP10チェックリスト(コピペ用)
7-1. 症状→原因→対策の一覧
- たまに落ちる → 非同期の待ち不足 → 待つ対象をDOM/HTTP/Timerで分類する
- CIだけ落ちる → 実行速度差でタイミング依存 → fakeAsync/tickやwhenStableを統一する
- クリック後の表示確認が不安定 → detectChanges不足 → イベント後に変更検知を明示する
- タイマー処理が不安定 → 実時間依存 → fakeAsyncで時間を固定する
- HTTPテストが読みにくい → モック境界が曖昧 → UIはServiceモック、通信はHttpTestingControllerで分ける
- 失敗時だけ壊れる → 成功ケースしか見ていない → エラーと再試行を別テストで確認する
- 単体では通るが全体で落ちる → テスト間汚染 → beforeEach/afterEachで状態を初期化・掃除する
- モックが多すぎて読めない → 責務境界が曖昧 → テスト対象の外側だけをモックする
- TestBedが遅い → imports/providers過多 → 必要最小限のセットアップに絞る
- CIが遅い → 実行戦略不足 → テスト分割・並列化・キャッシュを導入する
この一覧は、Angularテストが壊れたときの初動チェックとして使えます。実装バグを疑う前に、「待ち方」「モック境界」「共有状態」「速度」のどこに属する問題かを当てはめるだけで、かなり切り分けが早くなります。
特にチーム開発では、問題ごとに場当たり対応すると同じflakyが何度も再発します。症状→原因→対策の型で共有しておくと、レビューや障害調査の会話がかなり短くなります。
7-2. チーム運用ルール(レビュー観点)
Angularテストの品質を上げるには、個人の経験則ではなく、チームで守るルールにすることが重要です。レビュー観点が揃っていないと、書いた人しか分からないテストになりやすいです。
最低限のレビュー観点としては、「待ち方は統一されているか」「モック境界は適切か」「afterEachの掃除があるか」「このTestBedは重すぎないか」を見るだけでも十分効果があります。特にAngularでは、テストコードが“通る”だけでは品質保証にならず、“壊れにくく保守しやすいか”まで見ないとCIで痛みが出ます。
おすすめは、PRテンプレやレビュー観点に「非同期の待ち方」「DI差し替えの境界」「HTTPモックの位置」「共有状態の掃除」の項目を持たせることです。Angularテストはパターン化できるぶん、運用ルールに落とし込みやすい領域です。つまり、品質を個人技にしなくて済むのが強みでもあります。
参考リンク
- Angular 公式:Testing
https://angular.dev/guide/testing - Angular 公式:Component testing basics
https://angular.dev/guide/testing/components-basics - Angular 公式:HTTP testing
https://angular.dev/guide/http/testing - Angular 公式:Utility APIs for testing(fakeAsync など)
https://angular.dev/guide/testing/utility-apis - zone.js リポジトリ
https://github.com/angular/angular/tree/main/packages/zone.js