Angularテストの基礎
Angularにはテスト環境が標準で組み込まれており、React + Jest/Testing Library のセットアップに相当する機能が最初から利用可能です。
1. テスト環境の概要
ng new で生成したプロジェクトには、テストランナー・テストフレームワーク・テスト用ユーティリティ(TestBed)があらかじめ構成されています。ng test を実行するだけでテストが走ります。
| 役割 | 標準構成(従来) | 最近の選択肢 |
|---|---|---|
| テストフレームワーク | Jasmine | Jest / Vitest |
| テストランナー | Karma(実ブラウザで実行) | Jest(jsdom)/ Vitest(jsdom, happy-dom, browser mode) |
| Angular 用ユーティリティ | TestBed, ComponentFixture | 同左(共通) |
| E2E | (Protractor は廃止済み) | Playwright / Cypress |
Karma 非推奨と Jest / Vitest への移行
Karma は 2023 年に非推奨となり(Angular 15 前後)、Angular チームは新しい標準を模索しています。現状の整理は次のとおりです。
- 新規プロジェクトの既定は、バージョンによっては引き続き Jasmine + Karma です。使用中の Angular のバージョンで
ng newが何を生成するかを確認してください。 @angular-devkit/build-angular:jestビルダーは実験的サポートとして提供されています。- Vitest 向けの
@angular/build:unit-testビルダーも実験的サポートとして提供されており、新しい標準の有力候補です。 - 既存の Jest 構成(
jest-preset-angularなど)はコミュニティ製の定番で、今も広く使われています。
TestBed や ComponentFixture は Jasmine / Jest / Vitest のどれでも同じ API で使えるため、テストコードの大半は移行しても変わりません。変わるのは主に spyOn(Jasmine)と jest.fn() / vi.fn() といったスパイ・モックの書き方です。
# 全テストを監視モードで実行
ng test
# CI 向け: 1 回だけ実行(Karma の場合はヘッドレス Chrome)
ng test --no-watch --browsers=ChromeHeadless
# カバレッジ付き
ng test --no-watch --code-coverage
React との比較
| 観点 | Angular | React / Next.js |
|---|---|---|
| 初期セットアップ | CLI が構成済み | Jest / Vitest + Testing Library を自分で導入(Next.js は公式ガイドあり) |
| DI の差し替え | TestBed の providers | jest.mock やプロバイダーのラッパー |
| コンポーネント描画 | TestBed.createComponent | render() |
| 要素の取得 | fixture.debugElement.query / nativeElement.querySelector | screen.getByRole など |
| 変更検知 | fixture.detectChanges() を明示的に呼ぶ | render や act が自動で再描画を待つ |
2. ユニットテスト(サービス)
サービスのテストでは、TestBed.configureTestingModule でテスト用の DI 環境を作り、TestBed.inject でサービスを取り出します。
// counter.service.ts
import { Injectable, signal } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class CounterService {
readonly count = signal(0);
increment(): void {
this.count.update((n) => n + 1);
}
}
// counter.service.spec.ts
import { TestBed } from '@angular/core/testing';
import { CounterService } from './counter.service';
describe('CounterService', () => {
let service: CounterService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(CounterService);
});
it('increment で count が 1 増える', () => {
service.increment();
expect(service.count()).toBe(1);
});
});
TestBed はテストごとにリセットされるため、providedIn: 'root' のサービスでもテスト間で状態が漏れません。依存のない単純なサービスなら、new CounterService() で直接生成するテストも可能です。
DI のモック(useValue / useClass)
依存サービスは providers で差し替えます。これが Angular のテストにおける最大の利点です。
// user-store.service.ts
import { Injectable, inject } from '@angular/core';
import { UserApiService } from './user-api.service';
@Injectable({ providedIn: 'root' })
export class UserStoreService {
private readonly api = inject(UserApiService);
async loadNames(): Promise<string[]> {
const users = await this.api.fetchUsers();
return users.map((u) => u.name);
}
}
// user-store.service.spec.ts
import { TestBed } from '@angular/core/testing';
import { UserStoreService } from './user-store.service';
import { UserApiService } from './user-api.service';
describe('UserStoreService', () => {
it('useValue でスタブを差し込める', async () => {
const apiStub = {
fetchUsers: jasmine
.createSpy('fetchUsers')
.and.resolveTo([{ id: 1, name: 'Alice' }]),
};
TestBed.configureTestingModule({
providers: [{ provide: UserApiService, useValue: apiStub }],
});
const store = TestBed.inject(UserStoreService);
expect(await store.loadNames()).toEqual(['Alice']);
expect(apiStub.fetchUsers).toHaveBeenCalledTimes(1);
});
});
Jest / Vitest の場合は jasmine.createSpy(...).and.resolveTo(x) を jest.fn().mockResolvedValue(x)(Vitest なら vi.fn().mockResolvedValue(x))に置き換えます。
| 指定方法 | 用途 |
|---|---|
useValue | 手書きのスタブオブジェクトやスパイを渡す |
useClass | テスト用のフェイク実装クラスに差し替える |
useFactory | 状況に応じてモックを組み立てる |
class FakeUserApiService implements Pick<UserApiService, 'fetchUsers'> {
async fetchUsers() {
return [{ id: 1, name: 'Fake' }];
}
}
TestBed.configureTestingModule({
providers: [{ provide: UserApiService, useClass: FakeUserApiService }],
});
React では jest.mock('./api') でモジュール単位に差し替えますが、Angular では DI トークン単位で差し替えるため、モジュールのインポート順序や巻き上げ(hoisting)を気にする必要がありません。
3. コンポーネントテスト
コンポーネントのテストでは TestBed.createComponent で ComponentFixture を作り、描画された DOM とコンポーネントのインスタンスの両方を検証します。スタンドアロンコンポーネントは imports に直接指定します。
// greeting.component.ts
import { Component, input, output } from '@angular/core';
@Component({
selector: 'app-greeting',
standalone: true,
template: `
<h2 data-testid="title">こんにちは、{{ name() }} さん</h2>
<button type="button" (click)="clicked.emit(name())">挨拶する</button>
`,
})
export class GreetingComponent {
readonly name = input.required<string>();
readonly clicked = output<string>();
}
// greeting.component.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { GreetingComponent } from './greeting.component';
describe('GreetingComponent', () => {
let fixture: ComponentFixture<GreetingComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [GreetingComponent],
}).compileComponents();
fixture = TestBed.createComponent(GreetingComponent);
});
it('入力された名前を表示する', () => {
fixture.componentRef.setInput('name', 'Alice');
fixture.detectChanges();
const title: HTMLElement = fixture.nativeElement.querySelector('[data-testid="title"]');
expect(title.textContent).toContain('Alice');
});
});
ComponentFixture の主な API
| API | 説明 |
|---|---|
fixture.componentInstance | コンポーネントのインスタンス |
fixture.nativeElement | ルートの DOM 要素 |
fixture.debugElement | Angular のデバッグ用ラッパー(query / queryAll / triggerEventHandler) |
fixture.componentRef.setInput(name, value) | 入力(@Input / input())を設定する |
fixture.detectChanges() | 変更検知を 1 回実行して DOM を更新する |
fixture.whenStable() | 非同期処理の完了を待つ |
重要なのは、テスト内で fixture.detectChanges() を呼ぶまで DOM は更新されない点です(既定の手動モード)。入力を変えたら detectChanges() を呼んでから DOM を検証してください。ComponentFixtureAutoDetect や fixture.autoDetectChanges() で自動化することも可能です。
@Input / @Output のテスト
入力は setInput、出力は subscribe(シグナルベースの output() も OutputEmitterRef.subscribe で購読できます)またはスパイで検証します。DOM 操作は click() や dispatchEvent で行います。
it('ボタンクリックで clicked が name を発行する', () => {
fixture.componentRef.setInput('name', 'Bob');
fixture.detectChanges();
const emitted: string[] = [];
fixture.componentInstance.clicked.subscribe((v) => emitted.push(v));
const button: HTMLButtonElement = fixture.nativeElement.querySelector('button');
button.click();
expect(emitted).toEqual(['Bob']);
});
@Input() デコレータを使う従来のコンポーネントでは、fixture.componentInstance.name = 'Bob' と直接代入しても動きますが、OnPush コンポーネントでは更新が反映されないことがあるため、setInput を使うのが安全です。
新しい制御フローとシグナルのテスト
@if / @for などの組み込み制御フローは、描画結果の DOM を検証すれば通常どおりテストできます。シグナルは値を直接変更し、detectChanges() を呼んで再描画します。
@Component({
standalone: true,
template: `
@for (item of items(); track item) {
<li>{{ item }}</li>
} @empty {
<li class="empty">データなし</li>
}
`,
})
class ListComponent {
items = signal<string[]>([]);
}
it('空のとき @empty を表示する', () => {
const fixture = TestBed.createComponent(ListComponent);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.empty')).not.toBeNull();
fixture.componentInstance.items.set(['a', 'b']);
fixture.detectChanges();
expect(fixture.nativeElement.querySelectorAll('li').length).toBe(2);
});
子コンポーネントのモック
子コンポーネントの詳細に依存したくない場合は、スタブコンポーネントに差し替えます。overrideComponent で imports を置き換えます。
@Component({ selector: 'app-user-card', standalone: true, template: '' })
class UserCardStubComponent {
readonly user = input<unknown>();
}
TestBed.configureTestingModule({ imports: [UserListComponent] });
TestBed.overrideComponent(UserListComponent, {
remove: { imports: [UserCardComponent] },
add: { imports: [UserCardStubComponent] },
});
一方で、Angular のテストでは子コンポーネントも含めて描画する「統合寄り」のテストが推奨されることも多く、モックは重い子コンポーネントや外部依存がある場合に限定するとテストが壊れにくくなります。
React Testing Library との比較
| 操作 | Angular(TestBed) | React Testing Library |
|---|---|---|
| 描画 | TestBed.createComponent(X) | render(<X />) |
| props / 入力 | fixture.componentRef.setInput | render(<X name="a" />) / rerender |
| 要素取得 | fixture.nativeElement.querySelector / debugElement.query(By.css(...)) | screen.getByRole / getByText |
| イベント | button.click() / triggerEventHandler | fireEvent.click / userEvent.click |
| 再描画 | fixture.detectChanges() | 自動(act 内で処理) |
| 非同期待機 | await fixture.whenStable() | await screen.findBy... / waitFor |
Angular でも @testing-library/angular を導入すると、render / screen / fireEvent に近い書き方ができ、ユーザー視点のテストを書きやすくなります。
4. HTTPリクエストのテスト
実際の通信を行わず、リクエスト内容の検証とレスポンスの注入を行うために provideHttpClientTesting を使います。以前の HttpClientTestingModule は非推奨で、現在は関数型プロバイダーを使います。
// user.service.ts
@Injectable({ providedIn: 'root' })
export class UserService {
private readonly http = inject(HttpClient);
getUser(id: number): Observable<User> {
return this.http.get<User>(`/api/users/${id}`);
}
createUser(body: Omit<User, 'id'>): Observable<User> {
return this.http.post<User>('/api/users', body);
}
}
// user.service.spec.ts
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import {
HttpTestingController,
provideHttpClientTesting,
} from '@angular/common/http/testing';
describe('UserService', () => {
let service: UserService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
service = TestBed.inject(UserService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify(); // 未処理のリクエストが残っていないことを確認
});
it('GET /api/users/1 を呼び出して結果を返す', () => {
let result: User | undefined;
service.getUser(1).subscribe((u) => (result = u));
const req = httpMock.expectOne('/api/users/1');
expect(req.request.method).toBe('GET');
expect(result?.name).toBe('Alice');
});
it('POST のボディを検証する', () => {
const req = httpMock.expectOne('/api/users');
expect(req.request.method).toBe('POST');
});
it('HTTP エラーを扱える', () => {
let status: number | undefined;
service.getUser(99).subscribe({ error: (e) => (status = e.status) });
httpMock
.expectOne('/api/users/99')
.flush('Not Found', { status: 404, statusText: 'Not Found' });
expect(status).toBe(404);
});
});
provideHttpClient() と provideHttpClientTesting() はこの順序で指定します。インターセプターを含めて検証したい場合は provideHttpClient(withInterceptors([...])) を指定します。
HttpTestingController のメソッド | 説明 |
|---|---|
expectOne(urlまたは条件) | 条件に合うリクエストがちょうど 1 件あることを検証して取得 |
match(条件) | 条件に合うリクエストをすべて取得 |
expectNone(条件) | 該当リクエストがないことを検証 |
verify() | 未処理のリクエストがないことを検証 |
req.flush(data) でレスポンスを返し、req.error(new ProgressEvent('error')) でネットワークエラーを再現できます。React では MSW(Mock Service Worker)でネットワーク層をモックするのが主流ですが、Angular では HttpTestingController が標準の仕組みとして用意されています。
5. E2Eテスト
ユニット / コンポーネントテストが TestBed 上でコンポーネントを検証するのに対し、E2E テストは実ブラウザでアプリ全体を操作して検証します。
- **Protractor は非推奨(2023 年にサポート終了)**で、新規プロジェクトには含まれません。既存プロジェクトは別のツールへ移行が必要です。
- 現在の主な選択肢は Playwright と Cypress です。どちらも Angular 固有の機能に依存せず、通常の Web アプリと同じように扱えます。
ng e2eは、プロジェクトに E2E ビルダーが未導入の場合、使用するツールを選んで導入するフローになります。
| 観点 | Playwright | Cypress |
|---|---|---|
| ブラウザ | Chromium / Firefox / WebKit | Chromium 系 / Firefox / WebKit(実験的) |
| 並列実行 | 標準で強力 | 有料のクラウドまたは別途構成 |
| 言語 | TypeScript / JavaScript ほか | TypeScript / JavaScript |
| 自動待機 | あり | あり |
// e2e/users.spec.ts (Playwright)
import { test, expect } from '@playwright/test';
test('ユーザー一覧が表示される', async ({ page }) => {
await page.goto('/users');
await expect(page.getByRole('heading', { name: 'ユーザー一覧' })).toBeVisible();
await expect(page.getByRole('listitem')).toHaveCount(3);
});
React / Next.js でも同じ Playwright / Cypress が使われるため、E2E の知識はそのまま流用できます。
6. テストのベストプラクティス
テストしやすいコード設計
- 依存は
inject()またはコンストラクターで受け取る: DI を使っていればprovidersで差し替えるだけでモックできます。new HttpClient()のように自分で生成する実装は避けます。 - ロジックはサービスや純粋関数へ: コンポーネントを薄く保ち、ビジネスロジックはサービスに置くと、
TestBedなしで高速にテストできます。 - 関数型ガード・リゾルバーは
TestBed.runInInjectionContextでテスト:inject()を使う関数は注入コンテキストが必要です。
it('未ログインなら /login へリダイレクトする', () => {
TestBed.configureTestingModule({
providers: [{ provide: AuthService, useValue: { isLoggedIn: () => false } }],
});
const result = TestBed.runInInjectionContext(() =>
authGuard({} as ActivatedRouteSnapshot, {} as RouterStateSnapshot),
);
expect(result.toString()).toBe('/login');
});
- セレクターは実装詳細に依存させない: CSS クラスよりも
data-testidやロール(role)、ラベルを使うと壊れにくくなります。 - 非同期は
fakeAsync/tickまたはawait fixture.whenStable(): 実際の待機時間(setTimeout)に依存するテストは避けます。
スナップショットテスト
Jest を使う場合は toMatchSnapshot による DOM のスナップショットも可能ですが、Angular ではあまり推奨されません。生成される DOM に属性やコメントノードが多く含まれ、差分がノイズになりやすいためです。使うなら、小さく安定した UI 部品や、シリアライズされた出力(設定オブジェクトなど)に限定し、振る舞いは通常のアサーションで検証するのがよいでしょう。
カバレッジの目標設定
ng test --code-coverage でカバレッジレポートが coverage/ に出力されます。
- 数値目標を設けるなら、全体で 70〜80% 程度を目安にし、100% を目的にしないでください。
- 閾値は
karma.conf.jsのcoverageReporter.check(Karma)や Jest のcoverageThresholdで設定でき、CI で下回ったら失敗させられます。 - ビジネスロジック(サービス、ガード、パイプ)は高く、テンプレートだけのコンポーネントは低めでも構いません。
- カバレッジは「テストされていない箇所を見つける」ために使い、品質そのものの指標とは考えないようにします。
テスト構成の目安
| 種類 | 対象 | 速度 | 比率の目安 |
|---|---|---|---|
| ユニット | サービス、パイプ、純粋関数、ガード | 速い | 多い |
| コンポーネント | コンポーネント単体 + テンプレート | 中程度 | 中程度 |
| E2E | 主要なユーザーフロー | 遅い | 少ない |