テックブログ

Angularテスト入門:TestBedでDI差し替えとHTTPモック

Angularテスト入門:TestBedでDI差し替えとHTTPモック

TestBedは、AngularのコンポーネントやServiceをテストするために、依存関係やテンプレート、変更検知を含んだ「テスト用のAngular実行環境」を組み立てる仕組みです。単なる関数テストでは確認しにくい、DIによる依存注入、テンプレート描画、イベント処理、HTTP通信の振る舞いまでを、実際のAngularアプリに近い形で検証できます。特に providers による差し替えや HttpTestingController によるHTTPモックを理解すると、実務で必要なコンポーネントテストの土台が作れます。

関連:Angular実践:Standaloneで最小アプリ(Input/Output+Service分離)

1. TestBedとは何か

1-1. “テスト用のAngular実行環境”を作るもの

TestBedは、テスト用にAngularの実行環境を組み立てるための仕組みです。単にクラスを new して呼び出すのではなく、AngularらしいDI、テンプレート、変更検知を含んだ状態で対象を動かせます。

Angularのコンポーネントは、テンプレートや依存Service、パイプ、ディレクティブなどと一緒に動いています。そのため、素のTypeScriptクラスとして扱うだけでは、実際の画面で起きる振る舞いを十分に確認できません。TestBedは、この「Angularが面倒を見ている部分」をテスト用に再現してくれるので、実際のアプリに近い前提で検証できます。

特に実務で重要なのは、Serviceの差し替えやHTTPモックを「AngularのDIに沿って」行えることです。これにより、テスト対象以外の依存をうまく切り離しながら、コンポーネントやServiceの責務をきれいに検証できます。TestBedは“Angularらしい動き”を保ったまま最小の検証環境を作る道具だと考えると、役割がつかみやすいです。

1-2. コンポーネントテストの基本の流れ

Angularのコンポーネントテストは、TestBedを設定して、コンポーネントを作り、変更検知を流して、DOMを確認するのが基本の流れです。最初はこの型だけ覚えておくと、ほとんどのテストを読み書きしやすくなります。

流れとしては、まず TestBed.configureTestingModule() でテストに必要なimportsやprovidersを定義します。次に createComponent() でコンポーネントの実体とfixtureを作り、detectChanges() でAngularの変更検知を1回流します。ここまで進めると、テンプレートが描画され、DOMに対して表示確認やイベント発火のテストができるようになります。

最初のうちは「なぜ detectChanges が必要なのか」で混乱しやすいです。これは、fixtureを作っただけではテンプレートがまだ十分に反映されていないからです。TestBedのテストは「設定 → 作成 → 変更検知 → 検証」の順で読む癖をつけると、かなり理解しやすいです。

2. 最小のコンポーネントテスト

2-1. TestBedcreateComponentdetectChanges

最小のコンポーネントテストでは、TestBedを組み立てて、コンポーネントを作って、detectChangesで描画する、この3段階が中心です。この型が分かると、Angularのコンポーネントテストの読み方がかなり安定します。

TestBed.configureTestingModule() では、Standaloneコンポーネントなら対象コンポーネント自体を imports に入れることが多いです。作成したfixtureは、コンポーネントの実体(componentInstance)と、描画結果にアクセスするための土台を持っています。そして fixture.detectChanges() を呼ぶことで、Angularがバインディングを評価し、DOMへ反映します。

import { TestBed, ComponentFixture } from '@angular/core/testing';
import { ItemDetailComponent } from './item-detail.component';

describe('ItemDetailComponent', () => {
  let fixture: ComponentFixture<ItemDetailComponent>;
  let component: ItemDetailComponent;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [ItemDetailComponent]
    }).compileComponents();

    fixture = TestBed.createComponent(ItemDetailComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });
});

このコードでは、Standaloneコンポーネントを imports に入れてTestBedを構成し、createComponent()detectChanges() で実際に描画できる状態を作っています。落とし穴は、fixtureを作っただけでDOMが更新されたと思い込むことです。表示内容を検証したいときは、まず「detectChangesが呼ばれているか」を確認するのが基本です。

2-2. DOM検証(表示/非表示)とイベント発火

コンポーネントテストでは、画面に何が表示されるか、イベントで何が起きるかをDOMベースで確認するのが基本です。内部状態だけを見るよりも、ユーザーに見える結果を確認するほうが、壊れ方を捉えやすくなります。

表示/非表示のテストでは、fixtureのネイティブDOMを取得して、テキスト内容や要素の有無を見ます。イベントのテストでは、ボタンなどの要素を取得して click() を呼び、その後にもう一度 detectChanges() して結果を確認する流れが定番です。Angularでは、コンポーネントのメソッドを直接呼ぶより、「実際のDOMイベントでどう動くか」を見るほうが現実に近い検証になります。

it('should show empty message when item is null', () => {
  component.item = null;
  fixture.detectChanges();

  const element: HTMLElement = fixture.nativeElement;
  expect(element.textContent).toContain('商品を選択してください');
});

it('should show item detail when item is set', () => {
  component.item = {
    id: 1,
    name: 'Angular Book',
    description: 'Angularの入門書です。'
  };
  fixture.detectChanges();

  const element: HTMLElement = fixture.nativeElement;
  expect(element.textContent).toContain('Angular Book');
  expect(element.textContent).toContain('Angularの入門書です。');
});

このコードでは、@Input の値を変えたあとに detectChanges() を呼び、DOMの見え方がどう変わるかを検証しています。イベント発火でも考え方は同じで、クリックなどの入力を与えたあと、変更検知を流して最終状態を確認します。内部実装よりも「ユーザーに見える結果」を先に見る意識が大事です。

3. DI差し替え(Serviceのモック)

3-1. providersで差し替える基本パターン

Angularテストで最も実務的なのが、providersで依存Serviceを差し替えるやり方です。これができると、コンポーネントが本物のHTTPや重いロジックに依存せず、必要な振る舞いだけを確認できます。

差し替えの基本は、TestBedの providers にモックやスタブを登録することです。たとえば { provide: ItemService, useValue: mockItemService } のように書くと、コンポーネントがDIで受け取る ItemService をテスト用オブジェクトに置き換えられます。これにより、戻り値を固定したり、呼ばれた回数を確認したりできるようになります。

import { TestBed, ComponentFixture } from '@angular/core/testing';
import { AppComponent } from './app.component';
import { ItemService } from './services/item.service';

describe('AppComponent', () => {
  let fixture: ComponentFixture<AppComponent>;
  let component: AppComponent;

  const mockItemService = {
    getItems: jasmine.createSpy().and.returnValue([
      { id: 1, name: 'Mock Item', description: 'モックの商品です。' }
    ]),
    addItem: jasmine.createSpy()
  };

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [AppComponent],
      providers: [
        { provide: ItemService, useValue: mockItemService }
      ]
    }).compileComponents();

    fixture = TestBed.createComponent(AppComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should load items from mocked service', () => {
    expect(mockItemService.getItems).toHaveBeenCalled();
    expect(component.items.length).toBe(1);
    expect(component.items[0].name).toBe('Mock Item');
  });
});

このコードでは、実際の ItemService を使わず、テスト用のモックで差し替えています。ポイントは「テストしたい対象以外の責務を切り離す」ことです。コンポーネントテストでは、Service自体の正しさではなく、Serviceの結果をどう使うかを見るためにモックが役立ちます。

3-2. “モックしすぎ”を避ける境界

DI差し替えは便利ですが、何でもモックすればよいわけではありません。モックしすぎると、実装にべったり依存した「壊れやすいテスト」になりやすいです。

基本の考え方は、「テスト対象の外側にある責務」をモックすることです。コンポーネントテストなら、外部API呼び出しや複雑なビジネスロジックを持つServiceはモックしやすいです。一方で、テンプレート表示やイベント処理など、コンポーネント自身の責務までモックで置き換えてしまうと、何をテストしているのかが曖昧になります。

実務では、「このテストはどの層の責務を確認したいのか」を最初に決めると、モックしすぎを防ぎやすいです。コンポーネントの振る舞いを見たいなら、その外側だけをモックする。Serviceのロジックを見たいなら、Serviceは本物のままにして、さらに外側のHTTPや外部依存だけをモックする。この境界を意識すると、テストの粒度が安定します。

4. 非同期の扱い(壊れやすい所)

4-1. async/awaitで待つ基本

Angularテストで非同期が入ると壊れやすくなりますが、まずはasync/awaitできちんと待つところから始めるのが安全です。最初から複雑な時間制御に飛び込むより、非同期処理が終わるのを素直に待つほうが理解しやすいです。

beforeEach(async () => { ... }) のように書くのは、TestBedのコンパイルや非同期初期化を待つためです。テスト本体でも、Promiseを返す処理や、fixtureの安定化を待つ必要があるときは await fixture.whenStable() のように待機できます。重要なのは、「非同期処理が終わる前にexpectしていないか」を常に意識することです。

it('should update view after async action', async () => {
  component.newItemName = 'New Item';
  fixture.detectChanges();

  component.addItem();
  fixture.detectChanges();

  await fixture.whenStable();
  fixture.detectChanges();

  const element: HTMLElement = fixture.nativeElement;
  expect(element.textContent).toContain('New Item');
});

このコードでは、変更後の状態が画面へ安定して反映されるまで待ってから検証しています。落とし穴は、detectChanges()whenStable() のどちらかだけで十分だと思い込むことです。非同期処理が絡むときは、「処理完了を待つ → 必要なら再描画する」の順で考えると整理しやすいです。

4-2. fakeAsync/tickはいつ使うか(最小)

fakeAsynctick() は、時間が進むことをテスト側でコントロールしたいときに使う道具です。常に必要なわけではなく、タイマーや遅延処理があるケースで最小限使うのが実務的です。

たとえば、setTimeout や一定時間後のUI更新、Debounceのような処理があると、普通のasync/awaitだけでは「何ミリ秒待てばよいか」が曖昧になりがちです。fakeAsync を使うと、仮想時間の中でテストを実行し、tick(1000) のように時間を進めて結果を確認できます。これにより、実時間を本当に待たずに安定したテストが書けます。

import { fakeAsync, tick } from '@angular/core/testing';

it('should hide message after timeout', fakeAsync(() => {
  component.showSavedMessage();
  fixture.detectChanges();

  expect(component.isSaved).toBeTrue();

  tick(3000);
  fixture.detectChanges();

  expect(component.isSaved).toBeFalse();
}));

このコードでは、3秒後にメッセージが消えるような処理を、実際に3秒待たずに検証しています。注意点は、HTTPモックやPromiseの扱いと混ざると読みづらくなりやすいことです。まずは「時間依存があるときだけ使う」と覚えておくと、過剰に複雑なテストを避けやすいです。

5. HTTPモック(HttpTestingController)

5-1. 成功ケースの最小(request→flush)

AngularでHTTP通信をテストするときは、HttpTestingControllerでリクエストを捕まえて、flushでレスポンスを返すのが基本です。これにより、本物のサーバへ通信せずに、Serviceやコンポーネントの振る舞いを確認できます。

テストでは、provideHttpClient()provideHttpClientTesting() を使ってHTTPクライアントをテスト用に差し替えます。そのうえで、HttpTestingController を注入し、期待するURLへのリクエストが出たかを expectOne() で確認します。最後に flush() でダミーレスポンスを返すと、通常のHTTP成功時と同じ流れで処理を進められます。

import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import {
  provideHttpClientTesting,
  HttpTestingController
} from '@angular/common/http/testing';
import { ItemApiService } from './item-api.service';

describe('ItemApiService', () => {
  let service: ItemApiService;
  let httpTestingController: HttpTestingController;

  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [
        ItemApiService,
        provideHttpClient(),
        provideHttpClientTesting()
      ]
    });

    service = TestBed.inject(ItemApiService);
    httpTestingController = TestBed.inject(HttpTestingController);
  });

  afterEach(() => {
    httpTestingController.verify();
  });

  it('should fetch items', () => {
    const mockItems = [
      { id: 1, name: 'Angular Book', description: 'Angularの入門書です。' }
    ];

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

    const req = httpTestingController.expectOne('/api/items');
    expect(req.request.method).toBe('GET');
    req.flush(mockItems);
  });
});

このコードでは、HTTPリクエストが出たことを検証し、その場でモックレスポンスを返しています。最後に verify() を呼ぶのは、未処理のリクエストが残っていないか確認するためです。HTTPテストではこの後片付けが意外と重要で、書き忘れると別テストに影響しやすくなります。

5-2. 失敗ケースとリトライの入口

HTTPモックでは、成功だけでなく失敗時の扱いも確認することが大切です。実務では通信失敗や500系エラーは普通に起きるので、最低限「失敗したときに何を表示するか」「エラー状態をどう持つか」はテストで押さえておきたいです。

失敗ケースでは、flush() にステータス情報を渡してエラーを再現できます。コンポーネント側でエラーメッセージを出す構造になっているなら、その表示まで確認すると実務的です。また、リトライ処理がある場合は、ここで「何回リクエストが出たか」をテストする入口になりますが、最初は“失敗時に適切に止まるか”から確認するのが現実的です。

it('should handle http error', () => {
  let errorMessage = '';

  service.getItems().subscribe({
    next: () => fail('should not succeed'),
    error: () => {
      errorMessage = '取得に失敗しました';
    }
  });

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

  expect(errorMessage).toBe('取得に失敗しました');
});

このコードでは、500エラーを返したときに購読側のエラー処理へ入ることを確認しています。リトライを導入するときは、この基礎形の上に「何回まで再試行するか」「最終的に失敗したら何を返すか」を追加で検証します。まずは成功と失敗をきちんと分けて扱えることが重要です。

6. まとめ

TestBedは、AngularのコンポーネントやServiceを「Angularらしい前提のまま」検証するための土台です。単にクラスを new して試すのではなく、DI、テンプレート、変更検知、HTTPなどを含んだ実行環境を作れるからこそ、実務で使えるテストを書けます。

この記事で押さえたポイントは、まず「TestBed → createComponent → detectChanges」の基本の流れです。その上で、providersによるService差し替えで依存をコントロールし、必要に応じて async/await や fakeAsync/tick で非同期を安定して扱います。さらに、HTTPは HttpTestingController で request→flush の形を押さえると、実務で困りにくいラインに到達できます。

一番大切なのは、「何をテストしたいのか」に応じて境界を決めることです。コンポーネントの表示やイベントを見たいのか、Serviceのロジックを見たいのか、HTTP通信の扱いを見たいのかで、モックする範囲も変わります。TestBedを“何となく動かす道具”ではなく、“責務の境界を保ったまま検証するための環境”として扱えるようになると、Angularテストはかなり読みやすくなります。

7. 参考リンク