Angularのユニットテストの書きはじめとして、コンポーネント表示時に特定のサービスのDIをして、非同期処理(API通信など)を得た結果、意図した表示になっているかを確認できるようなテストコードを書けるようになっておきたかったので方法を調べてみました。
なお、今回はテストランナーとしてJestを採用しています。
Jestの導入方法は以下を参考にセットアップしています。
現在Angularチームの方でもテストランナーとしてJestを使えるような取り組みを進行中のようです。
未確認ではありますが、以下に記載している方法でも同様の結果が得られるかと思います。
プロジェクトの作成
ng new
している際に --standalone
オプションを付けて、今後作成されるコンポーネント全てに対してスタンドアローンコンポーネントになるような指定をしています1。
npm install -g @angular/cli@v16-lts
ng new angular-jest --standalone
ng new
後のJestセットアップ方法に関しては前述のリンクを参考にセットアップをする必要があります。
テスト対象のコンポーネント及びサービスの作成
ListComponent
及び CommonService
クラスを作成します。
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/http
の provideHttpClient()
を追加しています。
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。
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を使用しています。
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()
を用いてサービスクラスを呼び出す場合が生まれてくるのですが、それぞれ書けるテストコードの差があるようです。
そのため、追ってそれを検証するために一時的にコメントアウトをしています。
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。
<p>list works!</p>
<ul>
<li *ngFor="let post of posts$ | async">
{{ post.title }}
</li>
</ul>
ユニットテストコードの作成
上記で作成した ListCOmponent
をテストしていくのですが、その際に依存している CommonService
の
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);
});
});