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期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?