AngularでHTTP通信をするコンポーネントのユニットテストを書いて色々掴んだ話

これはAngular Advent Calendar 2024の12日目の記事です。  
昨日は @da1chi さんの [ドキュメントの修正から始めるAngularへの貢献](https://zenn.dev/da1chi/articles/43c4a9538a177f) でした。

https://qiita.com/advent-calendar/2024/angular

# はじめに

エンジニアとして働き始めて約7年弱、今まで当方は知識不足も相まってテストコードとは無縁の開発をしてきたしがないエンジニアだったのですが、転職して1年、ついにテストを書く機運が訪れたため、学び始めました。

その際、まずはHTTP通信を行うコンポーネントのテストをしたいと思い、色々と調べて掴んだ感覚をこの記事に残しておこうと思います。

# AngularのテストツールをJestに切り替えてみる

この記事を作成するにあたり、前提としてテストツールはJestを採用しました。

Angularの標準はJasmine + Karmaの組み合わせが現在の標準ですが、Angular v16からJestサポートが実験的にサポートされた模様[^1]なので、以下記事を参考に導入。

https://zenn.dev/lacolaco/articles/angular-16-jest

ちなみにJestに置き換えた後、VSCodeの `実行とデバッグ` の `ng test` を実行するとデバッガも動かないしVSCode拡張機能のJestによるテストも正常に動作せずしないんだけどまだ実験的段階だから？なにもわからん……

## 蛇足 なぜAngular公式の選択はJestだったのか？

Angular公式がサポートしているテストツールとしてJasmine + Karmaが挙げられますが、公式のブログにも記されている通りCIシステム統合の難しさやテストの速度等の開発体験の問題に挙げています。

https://blog.angular.dev/moving-angular-cli-to-jest-and-web-test-runner-ef85ef69ceca

そのためJasmine + Karmaを脱する試みは自然な流れかとは思うのですが、その時に白羽の矢が立ったのはJSアプリケーションのテストツールとしてメジャーなJestだっただけとも言えそうですが、Angular v16の変更点にビルドツールViteの試験的対応[^2]がありました。

https://blog.angular.dev/angular-v16-is-here-4d7a28ec680d#9bf5

それを考えるとJestとの高い互換性を持っていて、Viteの開発元が開発しているテストツールVitestを採用するのも良かったのではとも思った開発者も多いのでは？（少なくとも自分は思いました）。

もう少しJestを採用した背景とかコメントないかなと探ったところ、以下のIssueのコメントを見つけました。

https://github.com/angular/angular-cli/issues/25217#issuecomment-1581581005

要するに、利用規模やコミュニティの支持、その他諸々の成熟度合いを加味してJestを採用したということだと思います。

また、Vitestの実績を加味して今後サポート追加を検討する可能性はあるようですね。

余談終わり。

# 実装

今回は **JSONPlaceholder** に対してHTTP通信するようなコンポーネントを作成したうえ、ユニットテストではそのHTTP通信のレスポンスのスタブを用意して意図した挙動となるかをチェックするようなユニットテストのコードを書いてみようと思います。

https://jsonplaceholder.typicode.com/ 

## コンポーネントクラス

コンポーネントマウント時に `PostsService#getAll` 経由でHTTP通信が行われ、その結果を画面に表示するという簡素な実装です。

```ts:src/app/page/posts/posts.component.ts
import { Component, inject } from '@angular/core';
import { PostsService } from '../../service/posts.service';
import { Post } from '../../model/post.model';
import { RouterModule } from '@angular/router';

@Component({
  selector: 'app-posts',
  imports: [RouterModule],
  templateUrl: './posts.component.html',
  styleUrl: './posts.component.scss'
})
export class PostsComponent {
  posts: Post[] = [];
  loading = true;

  postsService = inject(PostsService);

  constructor() {
    this.postsService.getAll().subscribe({
      next: (posts) => {
        this.posts = posts;
      },
      error: (err) => {
        console.error(err);
      },
      complete: () => {
        this.loading = false;
      }
    });
  }
}
```

## テンプレート

こちらも箇条書きリストで表示するだけのシンプルな実装としていますが、データが無い際の表示や読み込み中の場合のテストも実装するため、そこだけ少し作り込んでいます。

```html:src/app/page/posts/posts.component.html
@if (!loading) {
<ul>
  @for (post of posts; track $index) {
  <li>
    <a routerLink="/posts/{{ post.id }}">
      {{ post.id }}: {{ post.title }}
    </a>
  </li>
  } @empty {
  <li>データがありません</li>
  }
</ul>
} @else {
<ul>
  <li>読込中……</li>
</ul>
}
```

## サービスクラス

HTTP通信の処理をJSのビルトインオブジェクトの `fetch` や `XMLHttpRequest` を使用せず、AngularのHttpClientを注入（DI）しているのがポイントです。

それ以外はよくあるHTTPリクエストで `Observable` を `subscribe` したらレスポンスをそのまま返却するような `PostsService#getAll` を実装しています。

```ts:src/app/service/posts.service.ts
import { HttpClient } from '@angular/common/http';
import { inject, Injectable } from '@angular/core';
import { Post } from '../model/post.model';

@Injectable({
  providedIn: 'root'
})
export class PostsService {
  http = inject(HttpClient);

  getAll() {
    return this.http.get<Post[]>('https://jsonplaceholder.typicode.com/posts');
  }
}
```

ちなみに `HttpClient` を使用するために `app.config.ts` にて `provideHttpClient(withFetch())` をプロバイダーに渡しています。

```ts:src/app/app.config.ts
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter, withComponentInputBinding } from '@angular/router';

import { routes } from './app.routes';
import { provideHttpClient, withFetch } from '@angular/common/http';

export const appConfig: ApplicationConfig = {
  providers: [
    provideZoneChangeDetection({ eventCoalescing: true }),
    provideRouter(routes, withComponentInputBinding()),
    provideHttpClient(withFetch()),
  ]
};
```

## モデルクラス

レスポンスの型をジェネリクスで渡すためだけに作ったモデルクラスです。

おれ赤ちゃんだからこういうとき `type` で作るか `interface` で作るか `class` を作るか、そもそもどのディレクトリに置くか延々と答えを見つけられないんだヮ（誰か教えて……）。

```ts:src/app/model/post.model.ts
export class Post {
  userId: number;
  id: number;
  title: string;
  body: string;

  constructor(
    userId: number,
    id: number,
    title: string,
    body: string
  ) {
    this.userId = userId;
    this.id = id;
    this.title = title;
    this.body = body;
  }
}
```

## コンポーネントのテストコード

```ts:src/app/page/posts/posts.component.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing';

import { PostsComponent } from './posts.component';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { Post } from '../../model/post.model';
import { provideRouter } from '@angular/router';

describe('PostsComponent', () => {
  let component: PostsComponent;
  let fixture: ComponentFixture<PostsComponent>;
  let httpTestingController: HttpTestingController;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [PostsComponent],
      providers: [
        provideHttpClient(),
        provideHttpClientTesting(),
        provideRouter([])
      ]
    })
      .compileComponents();

    httpTestingController = TestBed.inject(HttpTestingController);
    fixture = TestBed.createComponent(PostsComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('コンポーネント作成できるか', () => {
    expect(component).toBeTruthy();
  });

  it('初期段階で 読込中…… が表示されるか', () => {
    const { nativeElement }: { nativeElement: HTMLElement } = fixture;
    expect(nativeElement.textContent).toContain('読込中……');
  });

  it('PostsService で取得した配列データが空の際の表示考慮ができているか', () => {
    const posts: Post[] = [];

    // Httpリクエストの期待値を設定
    const request = httpTestingController.expectOne({ method: 'GET', url: 'https://jsonplaceholder.typicode.com/posts' })
    request.flush(posts);
    fixture.detectChanges();

    const { nativeElement }: { nativeElement: HTMLElement } = fixture;
    expect(nativeElement.querySelector('li')?.textContent).toEqual('データがありません');

    httpTestingController.verify();
  });

  it('PostsService で取得した値を表示できているか', () => {
    const posts: Post[] = [
      {
        id: 1,
        userId: 1,
        title: 'タイトル',
        body: '本文',
      },
    ];

    // Httpリクエストの期待値を設定
    const request = httpTestingController.expectOne({ method: 'GET', url: 'https://jsonplaceholder.typicode.com/posts' });
    request.flush(posts);
    fixture.detectChanges();

    const { nativeElement }: { nativeElement: HTMLElement } = fixture;
    expect(nativeElement.textContent).toContain('1: タイトル');
    expect(nativeElement.querySelector('a')?.getAttribute('href')).toEqual('/posts/1');

    httpTestingController.verify();
  });
});
```

まだ不安な部分も多いのですが、色々記事やドキュメント漁って、ひとまずコンポーネントマウント時にHTTP通信が走った際に想定している画面の表示になるかなという観点のチェックは、だいたいこのようなコードで良いのではないかと思い始めています。

ポイントや意識している点を少し書いてみます。

### そもそもHTTP通信をfetchやXMLHttpRequestを使わない

`fetch` や `XMLHttpRequest` 等のビルトインオブジェクトを何も考えずにそのまま使ってしまうと、ユニットテストがし辛くなってしまうので、何かしらのライブラリを噛ますなりDIする形にするなり色々方法は考えられると思いますが、ベンダーロックインとかの懸念点とかは横に置いておき、オレオレ実装するくらいならまだAngular様が用意してくださっているHttpClientを使うのがまぁ無難かなぁという結論に至りました、個人的にはですが。

HttpClientも用意してくださってますし、ユニットテスト時に差し替えるモジュールも用意してくださってます、ありがたい……

- [provideHttpClientTesting • Angular 日本語版](https://angular.jp/api/common/http/testing/provideHttpClientTesting)
- [HttpTestingController • Angular 日本語版](https://angular.jp/api/common/http/testing/HttpTestingController)

### HttpTestingControllerによるHTTP通信リクエストの検証

`HttpTestingController` を利用することによって実際にサーバーへリクエストを飛ばさずにレスポンスを手動でコントロールすることができます。

上記のテストでは2XX系の正常系テストしか考慮できてないですが、4XX系や5XX系のエラーハンドリング等の異常系テストも簡単にできてしまうのが見て取れるかと思います。

数ヶ月前にこの存在を知ったときすげ～と思っていたのですが、今思えば別に他のHTTPClientでも同等の仕組みは用意されているよなと思い直したり。

とはいえ、Angularがここまでパッケージングしてくれて提供してくれる点においては技術選定や設計等のコストが減るので助かるなと。

# 終わりに

エンジニア職7年目にしてようやくではありますが、Angularをキッカケにユニットテストを書くコツや意義、モックの仕方、DIの意義をまんべんなく掴むことができたので、Angular様様です。

今までReact (Next), Preact, Solid, Vue (Nuxt)等々触ってきましたが、現状Angularが個人的に一番気に入っているので、来年からはAngular関連で何かしらコントリビュートする側になれたらなとは思っています。

年末年始休暇中はAngularのドキュメントをもっと読んでみようかなと！（まだ詳細ガイドを全く見られていないので[^3]）。

ということで、12日目でした。  
明日は @rysiva さんです。

https://qiita.com/advent-calendar/2024/angular

[^1]: Angular v19が最近リリースされましたが、なかなか実験的機能も外れないですし `watch` オプションも実装されない様子を見るにわりと後回しにされている印象だけどどんなんでしょうか。
[^2]: Viteの正式採用はAngular v17から https://blog.angular.dev/introducing-angular-v17-4d7033312e4b#879d
[^3]: 🤖Angularの触りはじめの時点で https://angular.jp/guide/testing のページを読んでたらもう少し苦労せずに済んだだろうに。