0
0

とにかくAngular x Jest でユニットテストを書くための覚書

Posted at

Angularのユニットテストの書きはじめとして、コンポーネント表示時に特定のサービスのDIをして、非同期処理(API通信など)を得た結果、意図した表示になっているかを確認できるようなテストコードを書けるようになっておきたかったので方法を調べてみました。

なお、今回はテストランナーとしてJestを採用しています。
Jestの導入方法は以下を参考にセットアップしています。

現在Angularチームの方でもテストランナーとしてJestを使えるような取り組みを進行中のようです。
未確認ではありますが、以下に記載している方法でも同様の結果が得られるかと思います。

プロジェクトの作成

ng new している際に --standalone オプションを付けて、今後作成されるコンポーネント全てに対してスタンドアローンコンポーネントになるような指定をしています1

bash
npm install -g @angular/cli@v16-lts
ng new angular-jest --standalone

ng new 後のJestセットアップ方法に関しては前述のリンクを参考にセットアップをする必要があります。

テスト対象のコンポーネント及びサービスの作成

ListComponent 及び CommonService クラスを作成します。

bash
npm run ng -- g component list
npm run ng -- g service service/common

ディレクトリとしては以下の通りとなります。

src/app
├── app.component.html
├── app.component.scss
├── app.component.spec.ts
├── app.component.ts
├── app.config.ts
├── app.routes.ts
├── list # 作成したコンポーネント
│   ├── list.component.html
│   ├── list.component.scss
│   ├── list.component.spec.ts
│   └── list.component.ts
└── service # 作成したサービス
    ├── common.service.spec.ts
    └── common.service.ts

検証用のコード作成

HttpClientの有効化

非同期処理のテストの検証として HttpClient を使用したいので @angular/common/httpprovideHttpClient() を追加しています。

app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';

import { routes } from './app.routes';

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes),
    provideHttpClient()
  ]
};

ルーティングの設定変更

Angularのアプリケーションを開いたら ListComponent の内容が表示されるようにします2

app.routes.ts
import { Routes } from '@angular/router';
import { ListComponent } from './list/list.component';

export const routes: Routes = [
  {
    path: '',
    children: [
      {
        path: '',
        component: ListComponent,
      },
    ]
  }
];

サービスクラスのお試し実装

JSの組み込みの Promise を返すパターンと Observable を返すパターンを検証したかったので CommonService に2つ関数を定義しています。

API通信の検証としてJSONPlaceholderを使用しています。

common.service.ts
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';

export type Post = {
  userId: number;
  id: number;
  title: string;
  body: string;
};

@Injectable({
  providedIn: 'root'
})
export class CommonService {
  private httpClient = inject(HttpClient);

  async fetchList(): Promise<Post[]> {
    return fetch('https://jsonplaceholder.typicode.com/posts')
      .then(({ ok, json, status, statusText, body }) => {
        if (ok) return json();
        throw new Error(`${status}:${statusText}:${body}`);
      });
  }

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

コンポーネントクラスのお試し実装

特筆する点として、予備知識として今回作成しているコンポーネントはスタンドアローンコンポーネントのため、コンストラクタでデータバインディングが可能です。

それによってコンストラクタでサービスクラスを呼び出す場合と、従来の ngOnInit() を用いてサービスクラスを呼び出す場合が生まれてくるのですが、それぞれ書けるテストコードの差があるようです。

そのため、追ってそれを検証するために一時的にコメントアウトをしています。

list.component.html.ts
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { CommonService, Post } from '../service/common.service';
import { Observable, of } from 'rxjs';

@Component({
  selector: 'app-list',
  standalone: true,
  imports: [CommonModule],
  templateUrl: './list.component.html',
  styleUrls: ['./list.component.scss']
})
export class ListComponent implements OnInit {
  private commonService = inject(CommonService);
  posts$: Observable<Post[]> = of([]);
  // posts$: Promise<Post[]> = Promise.resolve([]);

  constructor() {
    this.posts$ = this.commonService.list();
    // this.posts$ = this.commonService.fetchList();
  }

  ngOnInit(): void {
    // this.posts$ = this.commonService.list();
    // this.posts$ = this.commonService.fetchList();
  }
}

コンポーネントクラスのテンプレート実装

紹介するまでもないですが、箇条書きリストとして *ngFor を用いてループを回しているだけです。

補足として posts$ の購読(サブスクライブ)を async パイプを用いて実施しています3

list.component.html
<p>list works!</p>
<ul>
  <li *ngFor="let post of posts$ | async">
    {{ post.title }}
  </li>
</ul>

ユニットテストコードの作成

上記で作成した ListCOmponent をテストしていくのですが、その際に依存している CommonService

list.component.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';

import { ListComponent } from './list.component';
import { CommonService } from '../service/common.service';
import { of } from 'rxjs';

describe('ListComponent', () => {
  let component: ListComponent;
  let fixture: ComponentFixture<ListComponent>;

  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [
        {
          provide: CommonService,
          useValue: {
            list: jest.fn().mockReturnValue(of([
              { userId: 1, id: 1, title: 'Test title', body: 'Test body' }
            ])),
          },
        }
      ],
    }).compileComponents();
    fixture = TestBed.createComponent(ListComponent);
    component = fixture.componentInstance;
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });

  it('should fetch and display posts', () => {
    fixture.detectChanges();
    const debugElement = fixture.nativeElement as HTMLElement;
    const listItems = debugElement.querySelectorAll('li');
    expect(listItems.length).toBe(1);
  });
});
  1. 既にAngular v18がリリースされていますが、個人的なプロジェクトの兼ね合いでAngular v16を用いた検証としています。EOLになるまでにはアップデートします。

  2. AppComponent 側でインポートして app.component.html から直接表示する形にしてしまっても良かったかも。

  3. TSファイル側で購読(サブスクライブ)する場合のテストコードに変更が必要かどうかも追って検証したいところ。

0
0
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
0
0