テックブログ

Angularテスト実務:Reactive Forms/HTTP/Routerの安定パターン集

Angularテスト実務:Reactive Forms/HTTP/Routerの安定パターン集

AngularでForms、HTTP、Routerのテストが壊れやすいのは、入力変更・バリデーション・非同期通信・画面遷移がそれぞれ別のタイミングで進み、どこを待つべきか、どこまでをモックすべきかがぶれやすいからです。特にReactive Formsは状態更新とDOM更新、HTTPはリクエストとレスポンス注入、Routerは遷移完了とGuard判定が絡むため、書き方が毎回変わるとflakyになりやすくなります。安定させる要点は、待ち方とモック境界を先に固定し、成功・失敗・遷移の型をレシピとして揃えることです。

関連: Angular Testing Library入門:getByRoleとuserEventで壊れにくいテスト

1. レシピの前提:型を決める(安定化ルール)

1-1. 非同期の待ち方ルール(統一)

Angularテストを安定させるには、非同期の待ち方をチームで統一するのが先です。毎回 waitForfakeAsyncwhenStable() を場当たりで混ぜると、たまたま通るけれど壊れやすいテストが増えます。

Forms、HTTP、Routerはそれぞれ待つ対象が違います。DOM反映を待つのか、HTTPレスポンスを注入するのか、タイマーを進めるのかで使う道具が変わります。ここを分類せずに「とりあえず detectChanges() を何回か呼ぶ」といった書き方にすると、ローカルでは通ってもCIで不安定になりやすいです。

実務では、まず「HTTPは HttpTestingController で明示的に flush() する」「タイマーやdebounceは fakeAsync/tick で制御する」「画面表示の確認は findBy または waitFor を役割で使い分ける」と決めるだけでかなり安定します。ルールがないと、テストごとに書き手の癖が出て、保守が急に難しくなります。

1-2. モック境界ルール(統一)

テストが壊れにくくなるもう一つの前提は、どこまでを本物で、どこからをモックするかを統一することです。ここが曖昧だと、同じようなテストでも毎回重さと責務が変わってしまいます。

たとえば、コンポーネントテストで見たいのが画面表示なら、Serviceはモックして画面の振る舞いに集中するほうが自然です。一方、Service自体のHTTP処理を見たいなら、そこでは HttpTestingController を使ってHTTP層をモックし、UIまでは含めません。Routerも同様で、ボタン押下後に navigate が呼ばれることを見たいのか、URL変化まで通したいのかで境界が変わります。

運用ルールとしては、「コンポーネントテストはUI中心」「HTTPはServiceテストで見る」「Routerは遷移呼び出しかURL変化のどちらを見るかを先に決める」と整理すると迷いにくいです。モックが多すぎる状態は、だいたい境界を決めずに足しているのが原因です。

2. Reactive Forms:入力→バリデーション→送信

2-1. FormControl/FormGroupの基本テスト

Reactive Formsの基本テストでは、入力値の変更、バリデーション状態、送信可否の3点を分けて確認すると安定しやすいです。最初からDOM操作と送信処理を全部まとめて見ると、何が壊れたのか切り分けにくくなります。

FormControlやFormGroupは、Angularの画面入力の中心ですが、状態が内部にあるため、DOMの見え方だけでは不十分なことがあります。そこで、まずフォーム自体の値と妥当性を確認し、その後で送信ボタンやエラーメッセージの表示を見る流れにすると整理しやすいです。特に必須入力や最小文字数のような基本バリデーションは、フォーム状態とUI表示を分けて見ると壊れにくくなります。

it('必須入力がないと送信できない', async () => {
  const { fixture } = await render(UserFormComponent);
  const component = fixture.componentInstance;

  component.form.setValue({
    name: '',
    email: ''
  });
  fixture.detectChanges();

  expect(component.form.invalid).toBe(true);
  expect(component.form.get('name')?.hasError('required')).toBe(true);
});

このコードでは、まずフォーム状態を直接確認しています。注意点は、フォームの値を変えたあとにUI側の検証をするなら fixture.detectChanges() を忘れないことです。また、フォーム状態テストと「エラーメッセージが見えるか」のテストを分けると、失敗時の原因が追いやすくなります。

2-2. FormArray/動的バリデーションの型

FormArrayや動的バリデーションは、配列要素の追加・削除と、条件付きルールの切り替えを分けてテストするのが安定パターンです。ひとつのテストで全部を見ると、期待値が増えすぎて壊れやすくなります。

FormArrayは入力欄が動的に増減するため、配列長、各要素の妥当性、送信データの整形を意識して見る必要があります。動的バリデーションでは、「ある条件がtrueのときだけ必須になる」といった切り替えが入りやすく、値変更だけでなく updateValueAndValidity() のような再評価も絡みます。ここをあいまいにすると、ローカルでは通るのに条件変更時だけ壊れるテストが出やすいです。

it('チェックONで電話番号が必須になる', async () => {
  const { fixture } = await render(ProfileFormComponent);
  const component = fixture.componentInstance;

  component.form.get('requiresPhone')?.setValue(true);
  component.form.get('phone')?.updateValueAndValidity();
  fixture.detectChanges();

  expect(component.form.get('phone')?.hasError('required')).toBe(true);
});

このコードでは、条件変更後に対象コントロールの妥当性を再評価しています。注意点は、条件切り替えだけで自動的に全部更新されると決めつけないことです。FormArrayも同じで、「項目数が変わるテスト」と「各項目の妥当性テスト」を分けると読みやすくなります。

3. HTTP:成功/失敗を安定してテストする

3-1. 成功(request→flush)のコピペ

HTTPテストを安定させる基本は、リクエストを捕まえて、期待どおりの内容を確認し、flush() で明示的に成功レスポンスを返すことです。この型を守るだけで、かなり壊れにくくなります。

Angularでは HttpTestingController を使うと、実際に通信せずにリクエスト内容を検証できます。大事なのは、「呼ばれたはず」ではなく、URL・HTTPメソッド・必要なペイロードを見たうえで、テスト側がレスポンスを注入することです。これにより、外部環境に依存せず、通信成功時のロジックだけを正確に確認できます。

it('商品一覧を取得できる', () => {
  const service = TestBed.inject(ItemService);
  const httpMock = TestBed.inject(HttpTestingController);

  service.getItems().subscribe(items => {
    expect(items.length).toBe(1);
    expect(items[0].name).toBe('Angular Book');
  });

  const req = httpMock.expectOne('/api/items');
  expect(req.request.method).toBe('GET');

  req.flush([
    { id: 1, name: 'Angular Book', description: 'Angularの本' }
  ]);
});

このコードでは、リクエストを明示的に受け止めてからレスポンスを返しています。注意点は、テスト終了時に httpMock.verify() を呼んで、未処理リクエストが残っていないことを確認することです。ここを忘れると、別テストへ影響が残って不安定さの原因になります。

3-2. 失敗(error)とリトライの入口

HTTPの失敗テストでは、成功ケースと別に、エラー時の状態変化を明確に見ることが大切です。成功だけ確認していると、本番で一番困るエラー経路が無防備になりやすいです。

失敗ケースでは、flush() にステータスコードとエラー内容を渡して、購読側がどう動くかを確認します。リトライがある場合も、最初は「1回失敗したときにどうなるか」「再試行後に成功したら何が起きるか」を分けて見ると整理しやすいです。全部を1本へ詰め込むと、期待値が増えて読みにくくなります。

it('取得失敗時にエラーメッセージを設定する', () => {
  const service = TestBed.inject(ItemService);
  const httpMock = TestBed.inject(HttpTestingController);

  let errorCalled = false;

  service.getItems().subscribe({
    next: () => fail('should not succeed'),
    error: () => {
      errorCalled = true;
    }
  });

  const req = httpMock.expectOne('/api/items');
  req.flush('server error', {
    status: 500,
    statusText: 'Internal Server Error'
  });

  expect(errorCalled).toBe(true);
});

このコードでは、エラー経路へ入ることだけを明確に見ています。注意点は、リトライ処理がある場合に「何回リクエストが発生したか」も見ないと、たまたま通るテストになりやすいことです。失敗時のテストは、成功テストと同じくらい基本形として持っておくと運用が安定します。

4. Router:遷移とガード周り

4-1. navigateが呼ばれる/URLが変わる

Routerテストでは、navigate が呼ばれたことを見るのか、実際にURLが変わるところまで見るのかを先に決めると安定します。ここを曖昧にすると、必要以上に重いテストになりやすいです。

多くのコンポーネントテストでは、「このボタンを押したら詳細画面へ進もうとしている」ことが確認できれば十分で、その場合は Router.navigate をspyするだけで足ります。一方、ルート設定込みでURL変化まで見たいなら、RouterTestingModuleや提供APIを使って、もう少し統合寄りに組む必要があります。両者は責務が違うので、同じ書き方で無理に通そうとしないほうがよいです。

it('詳細ボタンクリックで遷移する', async () => {
  const navigate = jest.fn();

  await render(ItemListPageComponent, {
    providers: [
      {
        provide: Router,
        useValue: { navigate }
      }
    ]
  });

  await userEvent.click(screen.getByRole('button', { name: '詳細を見る' }));

  expect(navigate).toHaveBeenCalledWith(['/items', 1]);
});

このコードでは、Router自体をモックして「遷移の意図」を見ています。注意点は、URL変化まで見たいテストと混ぜないことです。コンポーネント責務なら navigate 呼び出し確認で十分なことが多く、そこを越えると急に重くなります。

4-2. Guardがある場合の通し方(最小)

Guardが絡むときは、Guard自体を確認するテストと、Guardつきルートへ遷移するテストを分けるのが安定パターンです。ひとつのテストで全部を保証しようとすると、原因が混ざりやすくなります。

Guardには認証状態や権限判定が入ることが多く、Routerの振る舞いと外部依存が結び付きやすいです。そのため、まずGuard単体で「条件に応じてtrue/UrlTreeを返す」ことを見て、ルーティング側では最小の通し方に寄せるほうが壊れにくいです。Guardつきルートの通しテストでは、認証Serviceの戻り値をモックして、許可・拒否の分岐だけを見るのが現実的です。

it('未ログイン時は保護ルートへ進めない', async () => {
  const authServiceMock = {
    isLoggedIn: () => false
  };

  await render(ProtectedPageComponent, {
    providers: [
      { provide: AuthService, useValue: authServiceMock }
    ]
  });

  expect(authServiceMock.isLoggedIn()).toBe(false);
});

この例はかなり最小ですが、ポイントは「Guardの本質である判定」を切り出して考えることです。注意点は、Guard、Router、認証Service、画面表示を全部1本のspecへ押し込まないことです。Guardがあるときほど、責務単位で分けたほうが長期的に保守しやすくなります。

5. 非同期UI:debounce/タイマー

5-1. debounceのテストを壊さない

debounceを含むUIは、fakeAsynctick() で時間を自分で進めるのが安定パターンです。実時間に依存すると、ローカルとCIでぶれやすくなります。

検索入力や自動保存のような機能では、入力のたびにすぐ処理せず、少し待ってから発火することがあります。このとき、ユーザー入力だけ再現して結果をすぐ期待すると、処理前にassertしてしまいます。そこで、タイマー依存をテスト側で制御し、「300ms後に呼ばれる」といった条件を明示すると、flakyを減らしやすいです。

it('debounce後に検索を実行する', fakeAsync(() => {
  const searchSpy = jest.fn();

  component.form.get('keyword')?.setValue('angular');
  fixture.detectChanges();

  tick(300);

  expect(searchSpy).toHaveBeenCalledTimes(1);
}));

このコードでは、300ms経過後の処理だけを狙って確認しています。注意点は、debounce時間をテストへベタ書きしていると、実装変更時に両方直す必要があることです。定数化できるならしておくと、テストと実装のズレを減らしやすくなります。

5-2. タイマー制御の型

タイマーを含むUIでは、「開始 → 時間を進める → 再描画 → 確認」という型を固定すると壊れにくくなります。順番が毎回ぶれると、テストの意味もぶれやすいです。

たとえば、保存後にトーストが3秒表示される、ローディングが一定時間後に消える、といった機能は、見た目は単純でも時間依存があります。ここを実時間待ちにすると遅いし不安定です。そこで、fakeAsynctick()、必要なら fixture.detectChanges() を組み合わせて、状態変化を段階的に見ます。

it('3秒後にトーストを閉じる', fakeAsync(() => {
  component.showToast();
  fixture.detectChanges();

  expect(component.toastVisible).toBe(true);

  tick(3000);
  fixture.detectChanges();

  expect(component.toastVisible).toBe(false);
}));

この型は、タイマー系のテストでかなり使い回せます。注意点は、tick() の前後どちらで detectChanges() が必要かを雑にしないことです。見た目確認が絡むなら再描画まで含めて型にしておくと、後から読み返しても分かりやすいです。

6. よくある落とし穴集

6-1. テスト間汚染、fixture更新漏れ

Angularテストで地味に多いのが、テスト間汚染と fixture.detectChanges() の更新漏れです。どちらも見落としやすいのに、症状は「たまに落ちる」になりやすいです。

テスト間汚染は、前のテストで残ったspy、HTTPリクエスト、localStorage、rootスコープのService状態などが次へ影響する状態です。一方、fixture更新漏れは、コンポーネント内部の状態は変わっているのに、DOMへ反映する前に検証しているパターンです。Angularでは内部状態と画面表示の間に変更検知があるので、ここを忘れると簡単にズレます。

対策としては、afterEach でHTTP verifyやストレージ掃除をする、spyを毎回作り直す、状態変更後に必要なら fixture.detectChanges() を呼ぶ、の3つが基本です。チェック観点としては、「このテストは前提を毎回作っているか」「DOM確認前に更新が流れているか」を見るだけでもかなり違います。

6-2. flakyになったときの切り分け順

flakyになったときは、順番を決めて切り分けるのが一番速いです。実装を疑う前に、待ち方と境界のブレを見たほうが早く直ることが多いです。

おすすめの順序は、まず非同期の待ち方、次にHTTPやRouterのモック境界、その次にタイマー、最後に共有状態を見る流れです。たとえば、たまに落ちるなら findBywaitFor の使い分けを確認し、HTTPが絡むなら未処理リクエストや flush() 漏れを見る、debounceがあるなら fakeAsync/tick を疑う、という形です。ランダムに直そうとすると、別の不安定さを増やしやすいです。

チームで共有するなら、「1. 待ち方 2. モック境界 3. タイマー 4. 汚染」の順に見るチェック表を作ると実用的です。flakyは気合いで直すものではなく、だいたい同じ順で絞れる問題です。順番を決めておくと、経験の浅いメンバーでも原因にたどり着きやすくなります。

7. まとめ:索引(困りごと→レシピ)

7-1. 早見表

  • Reactive Formsの必須チェックが書けない → FormGroupの値と invalid を先に見る
  • 条件付きバリデーションで壊れる → 条件変更と updateValueAndValidity() を分けて確認する
  • HTTP成功ケースが毎回ぶれるexpectOne()flush() の型に固定する
  • HTTP失敗時のUIが確認しづらい → エラー経路だけ別テストで見る
  • 遷移テストが重い → まず navigate 呼び出し確認で十分かを考える
  • Guard込みで複雑になる → Guard判定とRouter通しを分ける
  • debounceでflakyになるfakeAsync/tick で時間を固定する
  • タイマー付きUIが安定しない → 「開始→tick→detectChanges→確認」の型にする
  • たまにだけ落ちる → 待ち方→モック境界→タイマー→汚染の順で切り分ける

この早見表は、「何から見直すか」をすぐ決めたいときの入口として使えます。Forms、HTTP、Routerは論点が多いですが、壊れ方はかなりパターン化できます。

困りごとを“技術名”で考えるより、“どの型へ戻すか”で考えると、実務ではかなり速く対処できます。レシピを個人の頭の中だけで持たず、チームで共通化することが安定化の近道です。

7-2. チームでの共有方法(テンプレ化)

Angularテストの安定パターンは、個人の書き方として覚えるより、テンプレとして共有するほうが効果的です。特に1〜2年目のメンバーが多いチームでは、最初の型がそのまま品質を左右します。

たとえば、「HTTP成功のテンプレ」「Reactive Forms必須チェックのテンプレ」「Router navigate確認のテンプレ」「debounce用 fakeAsync テンプレ」を社内Wikiやテストヘルパとして置いておくと、毎回ゼロから悩まずに済みます。レビュー時も「このケースならあの型で書けるよね」と会話しやすくなります。ルールが暗黙知のままだと、同じ失敗が何度も繰り返されやすいです。

共有のコツは、完璧なテストガイドを作ることではなく、よくある困りごとに対する最小テンプレを数本持つことです。Forms、HTTP、Router、タイマーの4系統だけでも揃えると、チーム全体のテスト速度と安定性がかなり上がりやすくなります。

8. 参考リンク