Angular中級 #7 テスト — TestBed とコンポーネントテスト

読了 11分

前回 は SSR とハイドレーションを扱いつつ、Angular アプリをサーバーであらかじめ描く方法を見ていきました。今回は Angular 中級講座の最後の記事、テストです。Angular は他のフレームワークたちと違って、最初からテストが深く組み込まれています。ng new でプロジェクトを作るとテストランナーと最初の spec ファイルが一緒に降ってきますし、すべての CLI generator が spec ファイルを一緒に作ってくれます。「テストは後で」という文化自体が少ないわけです。

今回の記事では、その道具の位置を見たうえで、Service のユニットテストからコンポーネントテスト、mock の注入、HttpClient テスト、そして ComponentHarness まで一気に整理します。

Angular のテスト道具の位置 #

Angular の基本セットアップは Jasmine + Karma です。Jasmine は describe / it / expect のようなテスト記述構文と spy/mock ユーティリティを提供し、Karma はその spec たちを実際のブラウザで回してくれるランナーの役割をします。ng test を打つと、Karma が Chrome を立ち上げ、Jasmine で書かれたテストたちをその中で実行します。

ただし最近のトレンドは少しずつ変わっています。Angular 16 から JestVitest のような Node ベースのランナーへ移る流れがはっきりあります。Karma 自体は deprecate された状態で、新しいプロジェクトで Jest/Vitest を選ぶ事例が増えています。ただし テストの書き方 (TestBedfixtureinject) はそのまま です。ランナーが何であれ、この記事の内容はそのまま通用します。この記事は基本セットアップである Jasmine 構文で進めます。

Service ユニットテスト — TestBed なしで #

Angular の依存関係なしに動作する純粋なクラスは、ただ new で作って検証すれば 十分です。DI も画面も必要ないなら、もっとも単純な方式がもっとも良いものです。

src/app/cart.service.spec.ts
import { CartService } from './cart.service';

describe('CartService', () => {
  it('アイテムを追加すると合計が累積される', () => {
    const cart = new CartService();
    cart.add({ id: 1, price: 1000 });
    cart.add({ id: 2, price: 2500 });

    expect(cart.items().length).toBe(2);
    expect(cart.total()).toBe(3500);
  });
});

TestBed を引っ張ってこなくてもよいのです。signal もただ関数呼び出しで値を取り出して検証すれば終わりです。DI や他の Angular 機能に依存しない Service であれば、こういったユニットテストがもっとも速く軽量です (CartService 自体は signal + computed で合計を計算する平凡なクラスだと仮定します)。

TestBed 入門 #

他の Service や HttpClient を注入される Service をテストするとなると、話が変わります。new で作りつつ依存関係を直接差し込むこともできますが、Angular の DI システムをそのまま使う ほうがずっと自然です。その通り道がまさに TestBed です。

TestBed はテスト専用のミニ Angular アプリを立ち上げる道具だと考えてください。configureTestingModuleimports (standalone コンポーネントやモジュール) と providers (Service mock など) を登録し、TestBed.inject(...) でインスタンスを取り出して使います。

src/app/logger.service.spec.ts
import { TestBed } from '@angular/core/testing';
import { LoggerService } from './logger.service';

describe('LoggerService (TestBed)', () => {
  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [LoggerService],
    });
  });

  it('注入されるインスタンスはシングルトンである', () => {
    const a = TestBed.inject(LoggerService);
    const b = TestBed.inject(LoggerService);
    expect(a).toBe(b);
  });
});

providers{ provide: SomeService, useValue: fakeService } の形で mock を差し替える ことが、TestBed の最大の武器です。このパターンは少し後のコンポーネントテストで本格的に活用します。

注記
providedIn: 'root' で登録された Service なら、providers に明示しなくても TestBed.inject で取り出して使えます。ただし mock として差し替えたいときにだけ providers に登録する、と考えていただければ構いません。

コンポーネントテスト (1) セットアップ #

コンポーネントは画面を持つ存在なので、ユニットテストだけでは足りません。実際に一度レンダリングしたうえで その結果を検証してこそ意味があります。TestBed がその環境を提供します。

<p data-testid="value">現在の値: {{ count() }}</p>+1 ボタンを持つ簡単な CounterComponent (standalone、signal ベース) を置いて始めましょう。テストは次のような流れで進めます。

src/app/counter.component.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { CounterComponent } from './counter.component';

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

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [CounterComponent], // standalone は imports に
    });
    fixture = TestBed.createComponent(CounterComponent);
    fixture.detectChanges(); // 最初のレンダリング
  });

  it('初期値は 0 である', () => {
    const el: HTMLElement = fixture.nativeElement;
    expect(el.textContent).toContain('現在の値: 0');
  });
});

3 つを覚えておけば構いません。

  1. Standalone コンポーネントは imports 入れます。モジュール時代には declarations に入れていましたが、新しいコードではもうそこは使いません。
  2. TestBed.createComponent(...)ComponentFixture を返してくれます。この fixture が、コンポーネントインスタンス (fixture.componentInstance) と DOM (fixture.nativeElement) に入っていく取っ手です。
  3. fixture.detectChanges() を呼び出さなければ 変更検知が一度回ってテンプレートが描かれません。signal/@Input の変更後にも一度ずつ呼び出してあげなければ、画面が更新されません。

コンポーネントテスト (2) DOM 検証 #

それでは描かれた DOM を検証する番です。もっとも単純な方法は、fixture.nativeElement から querySelector で目的の要素を探し出し、テキスト・属性・クラスを確認することです。

counter.component.spec.ts
it('+1 ボタンを押すと値が 1 になる', () => {
  const el: HTMLElement = fixture.nativeElement;

  const button = el.querySelector('button')!;
  button.click();
  fixture.detectChanges();

  const value = el.querySelector('[data-testid="value"]')!;
  expect(value.textContent).toContain('現在の値: 1');
});

検証時にどのセレクタを使うかは好みですが、data-testid のようなテスト専用属性を置くパターン をお勧めします。CSS クラスやテキストで探すと、デザインが変わるたびにテストが壊れやすくなる一方で、data-testid は意味が変わらない限り安定的です。

コンポーネントテスト (3) ユーザーイベント #

先ほど button.click() を呼んだように、ユーザーイベントは普通に DOM メソッドを直接呼び出すか dispatchEvent で流せば構いません。入力イベントは value を変えた後、input イベントを直接発生させるのがもっとも安全です。<input> に入力した値であいさつ文を変える GreetComponent (name シグナル保有) があると仮定しましょう。

入力イベントのシミュレーション
it('input に入力するとあいさつ文が更新される', () => {
  TestBed.configureTestingModule({ imports: [GreetComponent] });
  const fixture = TestBed.createComponent(GreetComponent);
  fixture.detectChanges();

  const input: HTMLInputElement =
    fixture.nativeElement.querySelector('[data-testid="name"]');

  input.value = 'カーティス';
  input.dispatchEvent(new Event('input'));
  fixture.detectChanges();

  const hello = fixture.nativeElement.querySelector('[data-testid="hello"]');
  expect(hello.textContent).toContain('こんにちは、カーティスさん!');
});

value の代入だけでは Angular が変更を検知できません。dispatchEvent(new Event('input')) で本物の input イベントを流してこそ バインドされたハンドラが呼ばれ、detectChanges() でもう一度描けばあいさつ文が更新されます。

Service 依存関係の mock #

実際のコンポーネントは Service を注入されて動作する場合がほとんどです。その Service を本物で使うとテストが重くなり、外部依存 (HTTP、時間など) が絡んできます。解法は、基礎講座 5 編 でちらっと見たパターン — mock として差し替えること です。

UserListComponentUserService を注入されて userService.users() をレンダリングし、ボタンで userService.refresh() を呼ぶと仮定しましょう。テストでは UserService の本物の実装を偽オブジェクトに差し替えます。

src/app/user-list.component.spec.ts
import { TestBed } from '@angular/core/testing';
import { signal } from '@angular/core';
import { UserListComponent } from './user-list.component';
import { UserService } from './user.service';

describe('UserListComponent', () => {
  it('Service が持っているユーザーをレンダリングする', () => {
    const fakeUserService = {
      users: signal([{ id: 1, name: 'カーティス' }]).asReadonly(),
      refresh: jasmine.createSpy('refresh'),
    };

    TestBed.configureTestingModule({
      imports: [UserListComponent],
      providers: [{ provide: UserService, useValue: fakeUserService }],
    });

    const fixture = TestBed.createComponent(UserListComponent);
    fixture.detectChanges();

    const items =
      fixture.nativeElement.querySelectorAll('[data-testid="user"]');
    expect(items.length).toBe(1);
    expect(items[0].textContent).toContain('カーティス');
  });

  it('リフレッシュボタンは Service.refresh を呼び出す', () => {
    const refresh = jasmine.createSpy('refresh');
    TestBed.configureTestingModule({
      imports: [UserListComponent],
      providers: [
        {
          provide: UserService,
          useValue: { users: signal([]).asReadonly(), refresh },
        },
      ],
    });
    const fixture = TestBed.createComponent(UserListComponent);
    fixture.detectChanges();

    fixture.nativeElement.querySelector('button').click();
    expect(refresh).toHaveBeenCalledTimes(1);
  });
});

単純なデータは自分で作ったオブジェクト で十分で、メソッド呼び出しの検証が必要なときは jasmine.createSpy または、メソッドを一度にまとめてくれる jasmine.createSpyObj<UserService>('UserService', ['refresh', 'add']) で spy を作ります。spy は呼び出しの可否、回数、引数まで検証できて強力です。

HttpClient テスト #

Service が HttpClient を使っているならどうテストするでしょう? 本物のネットワークを通らせるとテストが遅くなり不安定になります。Angular はこのために HttpTestingController を提供します — 実際のネットワークは通らずに、リクエストを横取りして偽の応答を流してあげられます。

provideHttpClient の代わりに、テストモジュールでは provideHttpClientTesting を一緒に登録します (Angular 18+ 基準)。

src/app/user-api.service.spec.ts
import { TestBed } from '@angular/core/testing';
import {
  provideHttpClient,
  HttpClient,
} from '@angular/common/http';
import {
  provideHttpClientTesting,
  HttpTestingController,
} from '@angular/common/http/testing';
import { UserApiService } from './user-api.service';

describe('UserApiService', () => {
  let service: UserApiService;
  let httpMock: HttpTestingController;

  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [
        UserApiService,
        provideHttpClient(),
        provideHttpClientTesting(),
      ],
    });
    service = TestBed.inject(UserApiService);
    httpMock = TestBed.inject(HttpTestingController);
  });

  afterEach(() => {
    httpMock.verify(); // 処理されないリクエストが残っていれば失敗
  });

  it('GET /users リクエストを送り、応答をそのまま流してくれる', () => {
    const fake = [{ id: 1, name: 'カーティス' }];

    service.getUsers().subscribe((users) => {
      expect(users).toEqual(fake);
    });

    const req = httpMock.expectOne('/api/users');
    expect(req.request.method).toBe('GET');
    req.flush(fake); // 偽の応答を流す
  });
});

流れはこうです。

  1. Service メソッドを呼び出して結果を subscribe します (まだ応答がないのでコールバックは待機中)。
  2. httpMock.expectOne(url) でそのリクエストが実際に出たかを検証します。
  3. req.flush(偽データ) で偽の応答を流せば、subscribe のコールバックがそこで初めて実行され、データを検証します。
  4. afterEachhttpMock.verify() を呼び、処理されないリクエストがないかを確認します。

エラー応答も同じようにシミュレーションできます — req.flush('boom', { status: 500, statusText: 'Server Error' }) で流せば、基礎講座 7 編 で見た catchError + of([]) のフォールバックパターンが実際に動作するかを検証できます。

ComponentHarness — Material を使うなら #

複雑な UI コンポーネント (Material の mat-selectmat-dialog のように内部 DOM が大きい場合) を querySelector で扱い始めると、テストコードがあっという間に散らかります。Angular はこのために Component Test Harness という抽象レイヤーを提供します。

harness 使用例
const fixture = TestBed.createComponent(SaveFormComponent);
const loader = TestbedHarnessEnvironment.loader(fixture);

const saveButton = await loader.getHarness(
  MatButtonHarness.with({ text: '保存' })
);
await saveButton.click();

expect(fixture.componentInstance.onSave).toHaveBeenCalled();

Harness は「このコンポーネントのユーザーができること」だけを公開する小さな API レイヤーです。内部の DOM 構造が変わっても harness の API だけそのままならテストが壊れません。Angular Material を使うプロジェクトでは事実上の標準 であり、自作のデザインシステムコンポーネントにも同じパターンを適用できます。

ヒント
最初は querySelector で素早く始めて、同じコンポーネントの同じ動作をテストごとに繰り返してセレクトしているなら、その時に harness の導入を検討してください。道具が価値を持つ時点はたいてい「これ 3 回目書いてるな」と感じるときです。

まとめ — 中級講座を終えて #

今回の記事では、Angular のテスト道具を一気に見ていきました。

  • Jasmine + Karma が基本、Jest/Vitest へ移っていく流れはあるが書き方は同じ
  • Angular の依存関係がない Service はそのまま new で作って検証
  • TestBed で DI コンテキストを立て、mock を providers で差し替える
  • コンポーネントテスト: createComponentdetectChangesnativeElement の検証、イベントは dispatchEvent
  • HttpTestingController で実際のネットワークなしに HTTP の流れを検証
  • Material/CDK 環境 では ComponentHarness で一段堅牢なテスト

ここまでが Angular 中級講座の最後の記事 です。#1 で Reactive Forms から始まり、RxJS の深堀り、Lifecycle と Change Detection、HTTP Interceptor、Lazy Loading と Route Guards、SSR とハイドレーション、そして今日のテストまで — 基礎講座が「道具の使い方」だったとすれば、中級は「その道具で実際の製品を作るパターン」の領域でした。これで皆さんは Angular で小さなデモではなく 実務で動くアプリを 1 サイクル回してみる基本 を備えたわけです。

次の段階は 「Angular 上級講座」 です。中級では意図的に触れなかった、もう一段深いテーマを少しずつほどいていく予定です。

  • Change Detection 深掘り — Zone.js の正体、OnPush 戦略の動作原理、Zoneless Angular へ向かう流れ
  • Signals 深掘りeffectcomputed の細かな動作、signal ベースのコンポーネント入力 (input()model())、Resource API
  • Standalone API 深掘りprovide* 関数たちの設計、環境インジェクタ、DI ツリーを直接扱う
  • RxJS 深掘り — Higher-order operator の違い (switchMap vs mergeMap vs concatMap vs exhaustMap)、カスタム演算子、マルチキャストと backpressure
  • NgRx と状態管理 — Store、Effects、Entity、Component Store、Signal Store まで — 大きなアプリで状態をどう扱うか
  • SSR の深掘りとハイドレーション戦略 — 部分ハイドレーション、deferrable views との結合、パフォーマンスチューニング
  • パフォーマンスとバンドル最適化 — ビルド分析、deferrable views の実戦活用、Core Web Vitals の整え方

基礎・中級を一歩ずつついてきてくださった皆さん、本当にお疲れ様でした。どんな講座でも最後まで行くこと自体がもっとも難しいことなのに、その難しいことを成し遂げられたのです。上級講座でより深い話で再びお会いしましょう。それまでに、自分で小さな Angular アプリを 1 つ最初から最後まで作ってみることを強くお勧めします — 講座 7 編をすべて読むより、自分で作った 200 行のアプリ 1 つのほうがずっと大きな資産になります。

X