4
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

AngularAdvent Calendar 2024

Day 12

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

Last updated at Posted at 2024-12-11

これはAngular Advent Calendar 2024の12日目の記事です。
昨日は @da1chi さんの ドキュメントの修正から始めるAngularへの貢献 でした。

はじめに

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

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

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

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

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

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

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

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

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

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

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

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

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

余談終わり。

実装

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

コンポーネントクラス

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

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;
      }
    });
  }
}

テンプレート

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

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のビルトインオブジェクトの fetchXMLHttpRequest を使用せず、AngularのHttpClientを注入(DI)しているのがポイントです。

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

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()) をプロバイダーに渡しています。

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 を作るか、そもそもどのディレクトリに置くか延々と答えを見つけられないんだヮ(誰か教えて……)。

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;
  }
}

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

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を使わない

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

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

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

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

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

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

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

終わりに

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

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

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

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

  1. Angular v19が最近リリースされましたが、なかなか実験的機能も外れないですし watch オプションも実装されない様子を見るにわりと後回しにされている印象だけどどんなんでしょうか。

  2. Viteの正式採用はAngular v17から https://blog.angular.dev/introducing-angular-v17-4d7033312e4b#879d

  3. 🤖Angularの触りはじめの時点で https://angular.jp/guide/testing のページを読んでたらもう少し苦労せずに済んだだろうに。

4
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?