- Angular
Angular HTTPテスト|HttpTestingControllerの使い方
Angular の HTTP 通信のテストは、provideHttpClientTesting() で通信を「テスト用の受け口」に差し替え、HttpTestingController で出たリクエストを捕まえて、flush() で応答を返して書きます。 本物のサーバーには何も送りません。HttpClient 自体をモックにする必要が無く、サービスのコードはそのまま試せます。
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()], // この順番
});
const httpTesting = TestBed.inject(HttpTestingController);
const result = firstValueFrom(service.list(2, 'tanaka')); // ① 呼ぶ(リクエストが出る)
const req = httpTesting.expectOne('/api/users?page=2&q=tanaka'); // ② 捕まえる
req.flush([{ id: 1, name: '田中' }]); // ③ 応答を返す
expect(await result).toEqual([{ id: 1, name: '田中' }]); // ④ 結果を確かめる
httpTesting.verify(); // ⑤ 残りが無いか
| やりたいこと | 書き方 |
|---|---|
| リクエストが1件だけ出たことを確かめて取る | expectOne(url)・expectOne({ method, url })・expectOne(r => …) |
| 当てはまるリクエストを全部取る | match(…)(配列が返る) |
| リクエストが出ていないことを確かめる | expectNone(…) |
| 成功の応答を返す | req.flush(body)・req.flush(body, { status: 201, statusText: 'Created' }) |
| エラーの応答(404・500 など)を返す | req.flush(body, { status: 404, statusText: 'Not Found' }) |
| 通信そのものの失敗(status 0)を返す | req.error(new ProgressEvent('error')) |
| 処理していないリクエストが残っていないか | httpTesting.verify()(afterEach に書く) |
確認環境:Angular 22.2.2(@angular/core・@angular/common・@angular/cli)、TypeScript 6.0.3、Node.js 24.21.0(Linux)。ng new のプロジェクトで、この記事のテストを ng test(Vitest 5.0.4、jsdom)で実行し、16件すべてが通ることを確かめました。エラー文は、わざと間違えたテストを実行して出たものです。HttpTestingController の動きは @angular/common 22.2.2 のソース(http-testing.mjs)と angular.dev のガイドで確かめました(確認日 2026年10月13日)。
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Angular HTTPテスト|HttpTestingControllerの使い方」
しくみ:何が差し替わるのか
HttpClient は、リクエストを最後に HttpBackend(実際に送る部品。ブラウザーでは fetch や XHR)に渡します。provideHttpClientTesting() は、この HttpBackend をテスト用の HttpClientTestingBackend に入れ替えます。@angular/common 22.2.2 のソースでは、中身は次の4つです。
| 提供するもの | 役目 |
|---|---|
HttpClientTestingBackend |
出たリクエストをためておく受け口 |
HttpBackend → 上の受け口 |
本物の送信の代わりに、受け口へ流す |
HttpTestingController → 上の受け口 |
テストからためたリクエストを取り出す窓口 |
REQUESTS_CONTRIBUTE_TO_STABILITY を false |
HTTP の待ちを「安定待ち」に数えない |
ポイントは2つです。
- インターセプターなどは本物のまま動きます。 差し替わるのは一番奥の送信部分だけです。
withInterceptors()で付けた処理は、テストでもそのまま通ります。 - 応答は
flush()を呼ぶまで返りません。 リクエストは受け口にたまったままになり、テストのコードが好きなタイミングで応答(成功・エラー)を返します。
旧来の HttpClientTestingModule は、22.2.2 の型定義で @deprecated Add provideHttpClientTesting() to your providers instead. と書かれています。新しく書くテストは provideHttpClientTesting() を使います。
完成コード:テストするサービス
一覧(クエリパラメータ付き)・1件取得(エラーを日本語の文に変える)・登録・削除の4つを持つサービスです。
// user.service.ts
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpErrorResponse, HttpParams } from '@angular/common/http';
import { Observable, catchError, throwError } from 'rxjs';
export interface User { id: number; name: string; }
@Injectable({ providedIn: 'root' })
export class UserService {
private http = inject(HttpClient);
private base = '/api/users';
list(page = 1, q = ''): Observable<User[]> {
let params = new HttpParams().set('page', page);
if (q) params = params.set('q', q);
return this.http.get<User[]>(this.base, { params });
}
get(id: number): Observable<User> {
return this.http.get<User>(`${this.base}/${id}`).pipe(
catchError((e: HttpErrorResponse) => throwError(() => new Error(toMessage(e)))),
);
}
create(name: string): Observable<User> {
return this.http.post<User>(this.base, { name });
}
remove(id: number): Observable<void> {
return this.http.delete<void>(`${this.base}/${id}`);
}
}
/** エラーを画面に出す文に変える */
export function toMessage(e: HttpErrorResponse): string {
if (e.status === 0) return '通信できませんでした';
if (e.status === 404) return 'ユーザーが見つかりません';
return `サーバーエラー(${e.status})`;
}
HttpClient の使い方そのものは、関連記事「Angular HttpClientの使い方|GET・POSTとエラー処理」で扱っています。
完成コード:サービスのテスト(9件)
ng new したプロジェクトなら、src/app/user.service.spec.ts に置いて ng test で動きます。describe・it・expect は Vitest のグローバルです(v22 の tsconfig.spec.json は "types": ["vitest/globals"])。
// user.service.spec.ts
import { TestBed } from '@angular/core/testing';
import { HttpErrorResponse, provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { firstValueFrom } from 'rxjs';
import { UserService } from './user.service';
describe('UserService', () => {
let service: UserService;
let httpTesting: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
service = TestBed.inject(UserService);
httpTesting = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpTesting.verify(); // 処理していないリクエストが残っていたら失敗
});
it('一覧を GET し、クエリパラメータを付ける', async () => {
const result = firstValueFrom(service.list(2, 'tanaka'));
const req = httpTesting.expectOne('/api/users?page=2&q=tanaka');
expect(req.request.method).toBe('GET');
expect(req.request.params.get('page')).toBe('2');
req.flush([{ id: 1, name: '田中' }]);
expect(await result).toEqual([{ id: 1, name: '田中' }]);
});
it('URL だけで照合したいときは関数で探す', async () => {
const result = firstValueFrom(service.list(3));
const req = httpTesting.expectOne(r => r.url === '/api/users');
expect(req.request.urlWithParams).toBe('/api/users?page=3');
req.flush([]);
expect(await result).toEqual([]);
});
it('POST の本文を確かめる', async () => {
const result = firstValueFrom(service.create('佐藤'));
const req = httpTesting.expectOne({ method: 'POST', url: '/api/users' });
expect(req.request.body).toEqual({ name: '佐藤' });
req.flush({ id: 10, name: '佐藤' }, { status: 201, statusText: 'Created' });
expect(await result).toEqual({ id: 10, name: '佐藤' });
});
it('本文の無い応答(204)', async () => {
const result = firstValueFrom(service.remove(5));
const req = httpTesting.expectOne({ method: 'DELETE', url: '/api/users/5' });
req.flush(null);
expect(await result).toBeNull();
});
it('404 はエラーの文に変わる', async () => {
const result = firstValueFrom(service.get(99));
httpTesting.expectOne('/api/users/99')
.flush({ message: 'not found' }, { status: 404, statusText: 'Not Found' });
await expect(result).rejects.toThrow('ユーザーが見つかりません');
});
it('500 のエラー本文を受け取る', async () => {
const result = firstValueFrom(service.list());
httpTesting.expectOne('/api/users?page=1')
.flush({ code: 'E500' }, { status: 500, statusText: 'Internal Server Error' });
const e = await result.catch(err => err) as HttpErrorResponse;
expect(e).toBeInstanceOf(HttpErrorResponse);
expect(e.status).toBe(500);
expect(e.error).toEqual({ code: 'E500' });
console.log('500:', e.message);
});
it('ネットワークエラー(status 0)', async () => {
const result = firstValueFrom(service.get(1));
httpTesting.expectOne('/api/users/1').error(new ProgressEvent('error'));
await expect(result).rejects.toThrow('通信できませんでした');
});
it('同時に出た複数のリクエストは match でまとめて取る', async () => {
const a = firstValueFrom(service.get(1));
const b = firstValueFrom(service.get(2));
const reqs = httpTesting.match(r => r.url.startsWith('/api/users/'));
expect(reqs.length).toBe(2);
reqs[0].flush({ id: 1, name: 'A' });
reqs[1].flush({ id: 2, name: 'B' });
expect((await a).name).toBe('A');
expect((await b).name).toBe('B');
});
it('呼んでいなければ expectNone が通る', () => {
service.list(1).subscribe();
httpTesting.expectNone({ method: 'POST' });
httpTesting.expectOne('/api/users?page=1').flush([]);
});
});
ng test --watch=false --reporters verbose の結果です(インターセプター・httpResource・retry のテストも同じ実行に入っています)。
stdout | src/app/t100/user.service.spec.ts > UserService > 500 のエラー本文を受け取る
500: Http failure response for /api/users?page=1: 500 Internal Server Error
✓ ng src/app/t100/user.service.spec.ts > UserService > 一覧を GET し、クエリパラメータを付ける 4ms
✓ ng src/app/t100/user.service.spec.ts > UserService > URL だけで照合したいときは関数で探す 2ms
✓ ng src/app/t100/user.service.spec.ts > UserService > POST の本文を確かめる 10ms
✓ ng src/app/t100/user.service.spec.ts > UserService > 本文の無い応答(204) 3ms
✓ ng src/app/t100/user.service.spec.ts > UserService > 404 はエラーの文に変わる 4ms
✓ ng src/app/t100/user.service.spec.ts > UserService > 500 のエラー本文を受け取る 4ms
✓ ng src/app/t100/user.service.spec.ts > UserService > ネットワークエラー(status 0) 4ms
✓ ng src/app/t100/user.service.spec.ts > UserService > 同時に出た複数のリクエストは match でまとめて取る 2ms
✓ ng src/app/t100/user.service.spec.ts > UserService > 呼んでいなければ expectNone が通る 2ms
✓ ng src/app/t100/auth.interceptor.spec.ts > authInterceptor > /api/ にはトークンを付ける 2ms
✓ ng src/app/t100/auth.interceptor.spec.ts > authInterceptor > 外部の URL には付けない 2ms
✓ ng src/app/t100/auth.interceptor.spec.ts > authInterceptor > 401 ならログアウトし、エラーはそのまま流す 2ms
✓ ng src/app/t100/retry.spec.ts > retry のテスト > 1秒あけて2回まで再試行する 6ms
Test Files 4 passed (4)
Tests 16 passed (16)
書き方の決まりごと
① 先に呼ぶ、② 捕まえる、③ 応答を返す、④ 結果を確かめる、の順です。 HttpClient の Observable は、subscribe されるまでリクエストを出しません。完成コードでは firstValueFrom()(Observable を Promise に変える RxJS の関数)で subscribe し、await は flush() のあとに書いています。flush() の前に await すると、応答が来ないまま止まります。
subscribe() で書く場合は、次の形になります。
let got: User[] | undefined;
service.list(1).subscribe(v => (got = v));
httpTesting.expectOne('/api/users?page=1').flush([{ id: 1, name: 'A' }]);
expect(got).toEqual([{ id: 1, name: 'A' }]);
expectOne・match・expectNone の照合ルール
3つとも、同じ「照合のしかた」を受け取ります。@angular/common 22.2.2 のソースで照合の中身を確かめた結果です。
| 渡すもの | 照合のしかた | 失敗したときの説明文 |
|---|---|---|
文字列 '/api/users?page=1' |
urlWithParams(クエリ込みの URL)と完全一致 |
Match URL: /api/users?page=1 |
{ method: 'POST', url: '/api/users' } |
どちらも省略可。method は大文字にして比べる | Match method: POST, URL: /api/users |
関数 r => r.url === '/api/users' |
HttpRequest を受け取り、true を返したもの |
Match by function: (関数名) |
- 文字列はクエリまで含めて一致させます。
HttpParamsで付けたクエリはurlWithParamsに入るので、expectOne('/api/users')は/api/users?page=1に当たりません(後述のエラー E1)。パスだけで照合したいときは、関数でr.urlを比べます。r.urlはクエリを含まない URL です。 - クエリの順番も一致させます。
HttpParamsはsetした順に並びます。完成コードではpage→qの順でした。 expectOneは「ちょうど1件」。0件でも2件以上でも失敗します。- 取り出したリクエストは受け口から消えます。
expectOne・matchで取ったものは、あとの照合やverify()の対象になりません。取ったあとでflush()を忘れてもverify()では見つからない点に注意します。 - 第2引数で説明文を渡せます(
expectOne(r => …, 'ユーザー一覧の GET'))。関数で照合すると、説明文は関数名になります。上のテストのような無名の関数ではMatch by function:の後ろが空になるため、説明文を渡すとエラーが読みやすくなります。
flush と error:応答の返し方
| 返したい応答 | 書き方 | 受け取る側 |
|---|---|---|
| 200 と本文 | flush(body) |
値が流れる |
| 201・202 など | flush(body, { status: 201, statusText: 'Created' }) |
値が流れる |
| 本文なし(204) | flush(null) |
null が流れる。status は 204 |
| 404・500 など | flush(errBody, { status: 404, statusText: 'Not Found' }) |
HttpErrorResponse。error に本文 |
| 通信の失敗 | error(new ProgressEvent('error')) |
HttpErrorResponse。status は 0 |
| ヘッダー付き | flush(body, { headers: { 'X-Total': '42' } }) |
observe: 'response' で読める |
| 進捗などのイベント | event(…) |
reportProgress 用 |
ソースで確かめた細かい決まりです。
statusを省くと、本文があれば 200 OK、nullなら 204 No Content になります。statusを書いたらstatusTextも書きます。 書かないとstatusText is required when setting a custom status.で落ちます(エラー E6)。- 200〜299 なら成功、それ以外は
HttpErrorResponseとしてエラーで流れます。500 のときのmessageはHttp failure response for /api/users?page=1: 500 Internal Server Errorでした。 error()の status は、省くと 0 です。ErrorEventを渡す書き方は、型定義で@deprecated Http requests never emit an ErrorEvent. Please specify a ProgressEvent.とされています。- 応答の本文は、リクエストの
responseTypeに合わせて変換されます。textのリクエストにオブジェクトを渡すと JSON の文字列になり、blobのリクエストに文字列を渡すとAutomatic conversion to Blob is not supported for response type.で落ちます。
verify:残ったリクエストを見つける
verify() は、受け口に残っているリクエストがあると失敗します。afterEach に書いておくと、次のような漏れが見つかります。
- サービスが想定より多く(2回など)リクエストを出している
- 画面の初期表示で、テストしていない API も呼んでいる
expectOneを書き忘れた
途中で取り消したリクエスト(unsubscribe()・switchMap の切り替え・takeUntil など)も、既定では残りとして数えます。取り消し分を無視するときは verify({ ignoreCancelled: true }) です。実行すると、ignoreCancelled 付きは通り、付けない verify() は Expected no open requests, found 1: GET /api/users/1 で失敗しました。
インターセプターのテスト
トークンを付け、401 ならログアウトする関数型インターセプターです。
// auth.interceptor.ts
import { HttpInterceptorFn } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { catchError, throwError } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class AuthService {
token: string | null = 'abc123';
logoutCount = 0;
logout() { this.token = null; this.logoutCount++; }
}
/** API にだけトークンを付け、401 ならログアウトする */
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const auth = inject(AuthService);
const authReq = req.url.startsWith('/api/') && auth.token
? req.clone({ setHeaders: { Authorization: `Bearer ${auth.token}` } })
: req;
return next(authReq).pipe(
catchError((e) => {
if (e.status === 401) auth.logout();
return throwError(() => e);
}),
);
};
テストでは、provideHttpClient(withInterceptors([…])) を provideHttpClientTesting() より先に書きます。HttpClient を直接呼び、捕まえたリクエストのヘッダーを見ます。
// auth.interceptor.spec.ts
import { TestBed } from '@angular/core/testing';
import { HttpClient, provideHttpClient, withInterceptors } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { firstValueFrom } from 'rxjs';
import { AuthService, authInterceptor } from './auth.interceptor';
describe('authInterceptor', () => {
let http: HttpClient;
let httpTesting: HttpTestingController;
let auth: AuthService;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(withInterceptors([authInterceptor])), // 先に書く
provideHttpClientTesting(),
],
});
http = TestBed.inject(HttpClient);
httpTesting = TestBed.inject(HttpTestingController);
auth = TestBed.inject(AuthService);
});
afterEach(() => httpTesting.verify());
it('/api/ にはトークンを付ける', () => {
http.get('/api/me').subscribe();
const req = httpTesting.expectOne('/api/me');
expect(req.request.headers.get('Authorization')).toBe('Bearer abc123');
req.flush({});
});
it('外部の URL には付けない', () => {
http.get('https://example.com/data.json').subscribe();
const req = httpTesting.expectOne('https://example.com/data.json');
expect(req.request.headers.has('Authorization')).toBe(false);
req.flush({});
});
it('401 ならログアウトし、エラーはそのまま流す', async () => {
const result = firstValueFrom(http.get('/api/me'));
httpTesting.expectOne('/api/me').flush(null, { status: 401, statusText: 'Unauthorized' });
await expect(result).rejects.toMatchObject({ status: 401 });
expect(auth.logoutCount).toBe(1);
expect(auth.token).toBeNull();
});
});
順番を逆にすると、インターセプターのテストは失敗しました。 [provideHttpClientTesting(), provideHttpClient(withInterceptors([authInterceptor]))] と書いて実行すると、expectOne('/api/me') が Expected one matching request for criteria "Match URL: /api/me", found none. になりました。後に書いた provideHttpClient() が HttpBackend を本物に戻し、リクエストがテスト用の受け口に届かなかったためです。angular.dev のガイドも、provideHttpClient() を先に書くように説明しています。
クラス型のインターセプター(HttpInterceptor を実装したもの)は、angular.dev の例では provideHttpClient(withInterceptorsFromDi()) と { provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true } で登録します。テストの書き方は同じです。インターセプター自体の作り方は、関連記事「Angularインターセプターの使い方|トークン付与とエラー共通処理」で扱っています。
httpResource のテスト
httpResource は中で HttpClient を使うので、テストの道具は同じです。違いは、リクエストが effect(Signal の変化に反応して動く処理)から出る点です。effect を動かすために TestBed.tick() を呼んでから expectOne します。
テストする部品です。
// user-detail.ts
import { Component, input } from '@angular/core';
import { httpResource } from '@angular/common/http';
import { User } from './user.service';
@Component({
selector: 'app-user-detail',
template: `
@if (user.isLoading()) { <p class="loading">読み込み中…</p> }
@else if (user.error()) { <p class="error">読み込めませんでした</p> }
@else if (user.hasValue()) { <h2>{{ user.value().name }}</h2> }
`,
})
export class UserDetail {
id = input.required<number>();
user = httpResource<User>(() => `/api/users/${this.id()}`);
}
// user-detail.spec.ts
import { ApplicationRef, Injector, signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { httpResource, provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { UserDetail } from './user-detail';
import { User } from './user.service';
describe('httpResource のテスト', () => {
let httpTesting: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
httpTesting = TestBed.inject(HttpTestingController);
});
afterEach(() => httpTesting.verify());
it('httpResource 単体:id が変わると取り直す', async () => {
const id = signal(1);
const user = httpResource<User>(() => `/api/users/${id()}`, {
injector: TestBed.inject(Injector),
});
TestBed.tick(); // effect を動かしてリクエストを出す
httpTesting.expectOne('/api/users/1').flush({ id: 1, name: '田中' });
await TestBed.inject(ApplicationRef).whenStable();
expect(user.value()).toEqual({ id: 1, name: '田中' });
id.set(2);
TestBed.tick();
httpTesting.expectOne('/api/users/2').flush({ id: 2, name: '佐藤' });
await TestBed.inject(ApplicationRef).whenStable();
expect(user.value()?.name).toBe('佐藤');
});
it('コンポーネント:読み込み中 → 名前を表示', async () => {
const fixture = TestBed.createComponent(UserDetail);
fixture.componentRef.setInput('id', 7);
fixture.detectChanges(); // whenStable() は応答前に待つと終わらない
const el: HTMLElement = fixture.nativeElement;
console.log('before flush:', el.textContent?.trim());
expect(el.querySelector('.loading')).not.toBeNull();
httpTesting.expectOne('/api/users/7').flush({ id: 7, name: '鈴木' });
await fixture.whenStable();
console.log('after flush:', el.textContent?.trim());
expect(el.querySelector('h2')?.textContent).toBe('鈴木');
});
it('コンポーネント:エラーの表示', async () => {
const fixture = TestBed.createComponent(UserDetail);
fixture.componentRef.setInput('id', 8);
fixture.detectChanges();
httpTesting.expectOne('/api/users/8')
.flush(null, { status: 500, statusText: 'Server Error' });
await fixture.whenStable();
expect(fixture.nativeElement.querySelector('.error')?.textContent).toBe('読み込めませんでした');
});
});
実行時の出力です。
stdout | src/app/t100/user-detail.spec.ts > httpResource のテスト > コンポーネント:読み込み中 → 名前を表示
before flush: 読み込み中…
after flush: 鈴木
✓ ng src/app/t100/user-detail.spec.ts > httpResource のテスト > httpResource 単体:id が変わると取り直す 35ms
✓ ng src/app/t100/user-detail.spec.ts > httpResource のテスト > コンポーネント:読み込み中 → 名前を表示 30ms
✓ ng src/app/t100/user-detail.spec.ts > httpResource のテスト > コンポーネント:エラーの表示 8ms
応答を返す前に await fixture.whenStable() を書くと、テストが終わりませんでした。 最初の版では setInput の直後に whenStable() を待っていて、Error: Test timed out in 5000ms. になりました。読み込み中の resource は「まだ安定していない」状態として数えられるため、flush() するまで待ちが終わらないからです。flush() の前は detectChanges()(または TestBed.tick())、flush() の後に whenStable() の順にすると通りました。
httpResource の使い方は、関連記事「Angular httpResource使い方|v22安定版の実例とテスト」で扱っています。
retry(再試行)のテスト
retry({ count, delay }) のように時間を待つ処理は、Vitest の偽のタイマー(vi.useFakeTimers())で時間を進めます。
// retry.spec.ts
import { TestBed } from '@angular/core/testing';
import { HttpClient, provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { firstValueFrom, retry } from 'rxjs';
describe('retry のテスト', () => {
afterEach(() => vi.useRealTimers());
it('1秒あけて2回まで再試行する', async () => {
vi.useFakeTimers();
TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] });
const http = TestBed.inject(HttpClient);
const httpTesting = TestBed.inject(HttpTestingController);
const result = firstValueFrom(http.get<string[]>('/api/items').pipe(retry({ count: 2, delay: 1000 })));
httpTesting.expectOne('/api/items').flush(null, { status: 503, statusText: 'Unavailable' });
httpTesting.expectNone('/api/items'); // 1秒たつまでは再送しない
await vi.advanceTimersByTimeAsync(1000);
httpTesting.expectOne('/api/items').flush(null, { status: 503, statusText: 'Unavailable' });
await vi.advanceTimersByTimeAsync(1000);
httpTesting.expectOne('/api/items').flush(['ok']);
expect(await result).toEqual(['ok']);
httpTesting.verify();
});
});
再試行のたびに新しいリクエストが出るので、1回ごとに expectOne で取り、応答を返します。この形で、「1秒たつまでは再送しない」ことも expectNone で確かめられます。
よくあるエラーと原因(実際に出た原文)
わざと間違えたテストを実行して出たエラー文です。
| No. | エラー文(原文) | 原因 |
|---|---|---|
| E1 | Expected one matching request for criteria "Match URL: /api/users", found none. Requests received are: GET /api/users?page=1. |
クエリ付きのリクエストを、パスだけの文字列で探した |
| E2 | Expected one matching request for criteria "Match URL: /api/users/1", found 2 requests. |
同じリクエストが2回出ている(二重の subscribe など) |
| E3 | Expected one matching request for criteria "Match URL: /api/users/1", found none. |
Observable を subscribe していない(リクエストが出ていない) |
| E4 | Expected no open requests, found 2: GET /api/users/1, POST /api/users |
verify() の時点で、取り出していないリクエストが残った |
| E5 | Expected zero matching requests for criteria "Match method: POST, URL: (any)", found 1. |
expectNone の条件に当たるリクエストが出ていた |
| E6 | statusText is required when setting a custom status. |
flush で status だけ書いて statusText を省いた |
| E8 | NG0201: No provider found for `HttpTestingController`. |
provideHttpClientTesting() を書いていない |
| E9 | Expected one matching request for criteria "Match URL: /api/me", found none. |
provideHttpClientTesting() を provideHttpClient() より先に書いた |
| E10 | Cannot flush a cancelled request. |
unsubscribe() などで取り消したリクエストに flush した |
| E12 | Expected one matching request for criteria "Match by function: ", found none. Requests received are: GET /api/users/1. |
関数の条件に当たらない(無名関数なので説明が空) |
| ― | Error: Test timed out in 5000ms. |
flush() の前に whenStable() を待った(httpResource など) |
読み方のこつです。
found none.の後ろにRequests received are:があれば、実際に出たリクエストが並びます。 E1 ではGET /api/users?page=1が出ていたので、クエリの付け忘れと分かります。Requests received are:が無ければ、リクエストが1件も出ていません(E3)。subscribe・TestBed.tick()・detectChanges()の呼び忘れを疑います。E9 のように、送信先がテスト用の受け口でない場合も同じ表示になります。provideHttpClient()を省いてprovideHttpClientTesting()だけを書いた場合(E7)は、22.2.2 ではエラーにならず、テストは通りました。インターセプターやwithFetch()などの設定を使うときはprovideHttpClient(…)が要るので、2つ並べて書く形にそろえておくと迷いません。
1つの失敗が他のファイルに広がる
最初に httpResource のテストがタイムアウトしたとき、同じ実行の他の2ファイルも、全テストが次のエラーで失敗しました。
Error: Cannot configure the test module when the test module has already been instantiated. Make sure you are not using `inject` before `TestBed.configureTestingModule`.
ng test の --isolate は、ヘルプに「Defaults to false to align with the Karma/Jasmine experience.」とあり、既定ではテストファイルを同じ環境で続けて動かします。タイムアウトしたテストの後片付けが済まず、TestBed が作られたままになったためと考えられます。このエラーが大量に出たら、最初に失敗したテストを先に直します。 タイムアウトを直すと、他のファイルのエラーも消えました。
Jasmine(Karma)からの書き換え
テストの中身(HttpTestingController の使い方)は、Jasmine でも Vitest でも同じです。違うのはモックとタイマーの書き方です。
| Jasmine | Vitest |
|---|---|
jasmine.createSpyObj('S', ['get']) |
{ get: vi.fn() } |
spy.and.returnValue(of(x)) |
vi.fn().mockReturnValue(of(x)) |
fakeAsync + tick(1000) |
vi.useFakeTimers() + await vi.advanceTimersByTimeAsync(1000) |
HttpClientTestingModule |
provideHttpClientTesting() |
ng test の Vitest への移行は、関連記事「Angular テスト Vitest 入門|v22の書き方・移行・実行結果つき」で扱っています。
よくある質問
Q. HttpClient を vi.fn() でモックにするのと、何が違いますか。
A. モックにすると、URL・クエリ・ヘッダー・本文の組み立ては試せません。HttpTestingController なら、HttpParams で付けたクエリや、インターセプターが足したヘッダーまで、実際のリクエストとして確かめられます。コンポーネントのテストでサービスを丸ごと差し替えたいときは、サービスをモックにする方が短く書けます。
Q. コンポーネントのテストで、URL を固定したくありません。
A. expectOne(() => true) のように関数で照合すると、URL に依らず「1件出たこと」だけを確かめられます。ただし失敗したときの説明は空になるので、第2引数に説明文を渡します。
Q. fakeAsync は使えますか。
A. fakeAsync は zone.js が要ります。v22 の ng new は zone.js を使わない(zoneless)構成が既定なので、この記事では Vitest の vi.useFakeTimers() を使いました。
Q. firstValueFrom と lastValueFrom のどちらを使いますか。
A. HttpClient の Observable は1回値を出して終わるので、どちらでも同じ結果です。この記事では firstValueFrom にそろえました。
Q. 本物のサーバーにつないだテストはできますか。 A. HttpTestingController はサーバーにつながない単体テスト用です。画面からサーバーまで通すテストは、Playwright などの E2E テストで行います。
まとめ
providers: [provideHttpClient(), provideHttpClientTesting()]の順に書く。逆にするとリクエストがテスト用の受け口に届かない- 流れは 呼ぶ →
expectOneで捕まえる →flushで返す → 結果を確かめる →verify()。awaitはflushの後 - 文字列の照合は クエリ込みの URL と完全一致。パスだけなら関数で
r.urlを比べる - エラー応答は
flush(body, { status, statusText })、statusTextを省くと落ちる。通信の失敗はerror(new ProgressEvent('error'))で status 0 - インターセプターは
withInterceptors([…])を付けて、捕まえたリクエストのヘッダーを見る - httpResource は
TestBed.tick()かdetectChanges()でリクエストを出す。flushの前にwhenStable()を待つとタイムアウトする - エラー文の
Requests received are:に、実際に出たリクエストが並ぶ
参考・出典
確認日はいずれも 2026年10月13日です。
- Angular「Test requests」:https://angular.dev/guide/http/testing
- Angular API「HttpTestingController」:https://angular.dev/api/common/http/testing/HttpTestingController
- Angular API「provideHttpClientTesting」:https://angular.dev/api/common/http/testing/provideHttpClientTesting
- Angular「Reactive data fetching with httpResource」:https://angular.dev/guide/http/http-resource
- Angular「Interceptors」:https://angular.dev/guide/http/interceptors
- Angular「Testing」:https://angular.dev/guide/testing
- Vitest「Fake Timers(vi.useFakeTimers)」:https://vitest.dev/api/vi.html#vi-usefaketimers
- angular/angular(packages/common/http/testing):https://github.com/angular/angular/tree/main/packages/common/http/testing
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Angular HTTPテスト|HttpTestingControllerの使い方」
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。