とんかつ時々あんどーなつ

〜たとえ低空でも飛行していられるように〜

standalone な Angular の page component をどうテストするか

はじめに

この記事で触れる page component は routed component のこと指している。 component の分類については下記でも少し記載しているので、リンクしておく。

Angular アプリケーションとレイヤードアーキテクチャとディレクトリ構成 - とんかつ時々あんどーなつ

今回は Tour of Heroes のようなアプリケーションをイメージしながら DashboardPageComponent を page component として用意する。

// src/app/routes.ts
import { Routes } from '@angular/router';
import { DashboardPageComponent } from './features/dashboard/pages/dashboard/dashboard.component';

export const routes: Routes = [
  { path: '', redirectTo: '/dashboard', pathMatch: 'full' },
  { path: 'dashboard', component: DashboardPageComponent },
];


// src/app/features/dashboard/pages/dashboard/dashboard.component.ts
import { Component } from '@angular/core';
import { DashboardComponent } from '../../containers/dashboard/dashboard.component';

@Component({
  standalone: true,
  imports: [DashboardComponent],
  template: `<h2>Top Heroes</h2>
    <app-dashboard></app-dashboard>`,
})
export class DashboardPageComponent {}

template で表示している app-dashboard(DashboardComponent) は container の役割をしており service(DashboardService) → gateway(HeroGateway) → HttpClient へと依存しているものとする。

standalone になって変わったこと

standalone component は NgModule を使わず、直接依存関係を指定できるようになっている。 つまりテストをする際にも TestBed.configureTestingModule()imports: [DashboardPageComponent] をすることですべての依存が指定されたことになる。 テストコードは以下のようになる。

// src/app/features/dashboard/pages/dashboard/dashboard.component.spec.ts
import { Component } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { RouterTestingHarness } from '@angular/router/testing';
import { routes } from '../../../../routes';
import { DashboardPageComponent } from './dashboard.component';

describe('DashboardPageComponent', () => {
  let harness: RouterTestingHarness;
  let component: DashboardPageComponent;

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

    harness = await RouterTestingHarness.create();
    component = await harness.navigateByUrl('', DashboardPageComponent);

    harness.detectChanges();
  });

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

routed component は RouterTestingHarness を使うとテストしやすいため、RouterTestingHarness ベースで記述している。 今回の本流ではないため、詳細な説明は ActivatedRouteStub に代わる RouterTestingHarness について - とんかつ時々あんどーなつ に譲る。

これでテストの準備はできた。しかし、テストを実行すると以下のエラーが発生する。

Chrome 112.0.0.0 (Mac OS 10.15.7) DashboardPageComponent should create FAILED
    NullInjectorError: R3InjectorError(Standalone[DashboardPageComponent])[HeroGateway -> HeroGateway -> HttpClient -> HttpClient]:
      NullInjectorError: No provider for HttpClient!
    error properties: Object({ ngTempTokenPath: null, ngTokenPath: [ 'HeroGateway', 'HeroGateway', 'HttpClient', 'HttpClient' ] })
        at NullInjector.get (node_modules/@angular/core/fesm2020/core.mjs:7509:27)
        at R3Injector.get (node_modules/@angular/core/fesm2020/core.mjs:7930:33)
        at R3Injector.get (node_modules/@angular/core/fesm2020/core.mjs:7930:33)
        at injectInjectorOnly (node_modules/@angular/core/fesm2020/core.mjs:633:33)
        at ɵɵinject (node_modules/@angular/core/fesm2020/core.mjs:637:60)
        at Object.factory (ng:///HeroApi/ɵfac.js:4:35)
        at R3Injector.hydrate (node_modules/@angular/core/fesm2020/core.mjs:8031:35)
        at R3Injector.get (node_modules/@angular/core/fesm2020/core.mjs:7919:33)
        at R3Injector.get (node_modules/@angular/core/fesm2020/core.mjs:7930:33)
        at ChainedInjector.get (node_modules/@angular/core/fesm2020/core.mjs:12100:36)

HttpClient を使うための provider が登録されておらず、エラーになっている。

ここで一度 NgModule ベースの時を振り返る。NgModule ベースのときは以下のようなコードでテストの準備をすることが多かった。

...
beforeEach(async () => {
  await TestBed.configureTestingModule({
    declarations: [DashboardPageComponent],
    schemas: [CUSTOM_ELEMENTS_SCHEMA],
  }).compileComponents();
});
...

これは DashboardPageComponent をテストする際にテスト環境が認識できているものが declarations に設定されている DashboardPageComponent しかない。 DashboardPageComponent の template にある app-dashboard はこのテストケースでは未知の要素だが CUSTOM_ELEMENTS_SCHEMA で無視している。 つまり、テストをしているのは DashboardPageComponent のみであるため、app-dashboard から先の依存が問題にならない。

一方で standalone では、 app-dashboard(DashboardComponent) → service(DashboardService) → gateway(HeroGateway) までをテスト環境は認識しており、HttpClient を使うための provider だけがない状態になっている。

解決方法

provideHttpClient() を追加する

一番簡単な方法は providers に provideHttpClient() を追加することである。これにより、実際のアプリケーションと同じ設定になるので page component からすべてのレイヤーを動かすことができるようになる。

しかし、アーキテクチャによっては page component がインフラストラクチャ層まで意識したくないと考えるかもしれない。 その場合は次に示すような shallow test を検討する。

shallow test

page component の責務を routed component であることに限定するのであれば、app-dashboard 以降を mock することでテストがしやすくなる。 次のように app-dashboard を使うための class を置き換えることで shallow test が可能になる。

@Component({ selector: 'app-dashboard', standalone: true, template: '' })
class MockDashboardComponent {}
...
await TestBed.configureTestingModule({
  imports: [DashboardPageComponent],
  providers: [provideRouter(routes)],
})
  .overrideComponent(DashboardPageComponent, { remove: { imports: [DashboardComponent] }, add: { imports: [MockDashboardComponent] } })
  .compileComponents();
...

上記のように selector が 'app-dashboard' の MockDashboardComponent を用意し、DashboardComponent と入れ替える形である。 これで page component のみを薄くテストできるようになる。

まとめ

page component の責務が明確で薄くテストしたのであれば有用だろう。一方ですべてを知った上でアプリケーションと同じ状態でテストしたい場合もあると思うので、自分たちがテストしたいものは何かを確認しながら適切な選択肢が取れるとよさそうである。

参考リンク

Testing Angular Standalone Components - ANGULARarchitects