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

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

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

https://kakehashi-dev.hatenablog.com/entry/2022/07/06/090000

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

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

# プロジェクトの作成

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

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

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

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

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

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

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

```bash
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()` を追加しています。

```typescript: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]。

```typescript: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を使用しています。

https://jsonplaceholder.typicode.com/

```typescript: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()` を用いてサービスクラスを呼び出す場合が生まれてくるのですが、それぞれ書けるテストコードの差があるようです。

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

```typescript: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]。

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

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

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

```typescript: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ファイル側で購読（サブスクライブ）する場合のテストコードに変更が必要かどうかも追って検証したいところ。