Chapter 10

3-1.コンポーネントのテスト

lacolaco
lacolaco
2023.04.08に更新

ユニットテストは、アプリケーションの品質を高めるために重要な役割を果たします。アプリケーションに設計パターンを取り入れたり、設計原則に基づいてリファクタリングしたりするのは、とにかくテストを書きやすくするためであると言っても過言ではありません。

この章では、ユニットテストの中でも特にコンポーネントのテストについて実装パターンを学びます。なぜなら、Angular アプリケーション構成するのもっとも根幹の要素がコンポーネントだからです。理解を深めるために、以下の関連する公式ドキュメントもあわせて読みながら学習することをおすすめします。

テスト実行環境について

この章では Angular CLI で作成されたプロジェクトを前提にしているため、テストフレームワークは Jasmine を使用し、テストランナーとして Karma を使用しています。

ただし、一部の Jasmine 固有の API を除いて、基本的には特定のテストランナーやフレームワークには依存せず適用できる内容です。Jest など他のツールを使っている場合は適宜読み替えてください。

なぜコンポーネントのテストが重要なのか

Angular におけるコンポーネントは単に UI を構成する部品ではありません。アプリケーションの起動は AppComponent (ルートコンポーネント)をブートストラップすることからはじまり、すべてのコンポーネントはルートコンポーネントを頂点としたコンポーネントツリーの一部です。また、サーバーサイドとの通信やデータの管理などを行うための作られるサービスも、それらコンポーネントの依存オブジェクトとして注入され、呼び出されることではじめて機能します。

つまり、Angular におけるコンポーネントとはアプリケーションそのものであり、アプリケーションを構成するすべての要素がコンポーネントを起点にしています。ディレクティブやサービスとその他の概念は、もともとコンポーネントが持っていた責任を分割して移譲したものに過ぎません。

したがって、Angular アプリケーションが開発者の期待どおりに動作するかどうかをテストしたいのであれば、コンポーネントのテストが重要なのは当然です。しかし、コンポーネントが多くの責任を含むということは、それだけコンポーネントはテストしにくいものであることも意味します。コンポーネントのテストは重要であり、そして難しいのです。そのため、アプリケーションが多くのことを行うようになるにつれて、リファクタリングによるコンポーネントをテストしやすく保つための責任の分割が重要になってくるのです。

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

ここからコンポーネントのテストの書き方を学んでいきましょう。まずは次のような最小のコンポーネント TitleComponent を想定します。このコンポーネントは単純な<h1>タグを表示するだけです。

@Component({
  selector: 'app-title',
  template: `<h1>My Applciation</h1>`,
  standalone: true,
})
export class TitleComponent {}

コンポーネントのテストは大きく 2 種類に分けられます。ひとつはクラスとしての振る舞いのテスト、もうひとつはDOM を構築するビューとしての振る舞いのテストです。
TitleComponentはクラスとしての振る舞いを左右するプロパティやメソッドを持たないため、ここでテストするのは DOM を構築するビューとしての振る舞いだけです。

ところで、コンポーネントはそれ自体では DOM を構築できません。コンポーネントがビューとして機能するのは、それがAngular アプリケーションの一部としてレンダリングされるからです。つまり、コンポーネントのビューとしての振る舞いをテストするためにはAngular アプリケーションが必要だということです。そして、テストする対象のコンポーネントを組み込んだアプリケーションを動的に構成するためのユーティリティが TestBed API です。

テスティングユーティリティ API - TestBed クラスの概要

それでは早速「TitleComponent<h1>タグでアプリケーションのタイトルを描画すること」を確認する次のテストコードを見てみましょう。

describe('TitleComponent', () => {
  it('should render application title as <h1>', async () => {
    // テストアプリケーションのセットアップ
    await TestBed.configureTestingModule({
      imports: [TitleComponent],
    }).compileComponents();
    // コンポーネントインスタンスの生成
    const fixture = TestBed.createComponent(TitleComponent);
    const element = fixture.nativeElement as HTMLElement;
    // DOMのアサーション
    expect(element.querySelector('h1')?.textContent).toContain('My Applciation');
  });
});

たった 10 行程度のコードですが、コンポーネントのテストを理解する上の重要なポイントがあります。it関数を上から順番に見ていきましょう。

テストアプリケーションのセットアップ

最初に書かれているのは、テストアプリケーションを構成するためのセットアップコードです。TestBed.configureTestingModule() メソッドの引数に渡されるモジュール定義は、いわばテスト用アプリケーションのルートコンポーネントです。テストアプリケーションに組み込みたい TitleComponentimports 配列に追加しています。

TestBed.compileComponents() について

TestBed.compileComponents() メソッドは configureTestingModule() メソッドで組み込まれたコンポーネントが templateUrlstyleUrls を使用しているときに、外部ファイルを読み込んで処理します。今回の TitleComponent には不要ですが、定型文として書かれていることがほとんどです。
また、このメソッドは Promise を返す非同期 API であるため、awaitによって処理の完了を待つことを忘れないようにしましょう。

await TestBed.configureTestingModule({
  imports: [TitleComponent],
}).compileComponents();

コンポーネントインスタンスの生成

次に、アプリケーションにコンポーネントをレンダリングさせ、コンポーネントをテストする準備を整えます。 TestBed.createComponent() メソッドを呼び出すと、テストアプリケーションの中で対象のコンポーネントが動的に生成されます。メソッドが返す ComponentFixture は、生成されたコンポーネントのインスタンスとテスト用のユーティリティ API がひとまとまりになったオブジェクトです。ほとんどの場合、コンポーネントのテストはこの ComponentFixture を対象にすることになるため、Angular のテストを理解する上で欠かせない API です。

テスティングユーティリティ API - ComponentFixture

ComponentFixture から取得できる nativeElement プロパティは、生成されたコンポーネントに対応するルート要素です。コンポーネントのテンプレートで構築された DOM 要素はこのルート要素の内部に存在します。この要素を使って次のステップで DOM の状態をテストします。

const fixture = TestBed.createComponent(TitleComponent);
const element = fixture.nativeElement as HTMLElement;

DOM のアサーション

最後に、コンポーネントによって構築された DOM ツリーを対象にしたアサーションを行います。今回は element.querySelector() メソッドで h1 要素を探索し、その内容がテンプレートに書かれた文字列と一致することをテストしています。ここでは toContain() matcher を使っていますが、同等のことが検証できるのであればどんなアサーションでもかまいません。

expect(element.querySelector('h1')?.textContent).toContain('My Applciation');

Arrange/Act/Assert (AAA) パターン

上述の 3 ステップは、可読性の高いユニットテストの書き方としてよく知られている AAA パターン における Arrange (準備) / Act (実行) / Assert (検証) に対応しています。

https://xp123.com/articles/3a-arrange-act-assert/

テストする対象のコンポーネントが複雑になっても、テストアプリケーションを準備し、コンポーネントを生成し、結果を検証するという構造は変わりません。この基本構造を意識することで、複雑になりやすいコンポーネントのテストの保守性を高めることができるでしょう。

コンポーネントクラスのテスト

続いて、コンポーネントのクラスとしての振る舞いをテストしてみましょう。例として、次のようなコンポーネント MessageComponent を想定します。このコンポーネントはボタンをクリックすることで表示するメッセージを英語から日本語に切り替えることができます。

@Component({
  selector: 'app-message',
  template: `
    <button (click)="toggleLanguage()">Toggle Language</button>
    <p>{{ message }}</p>
  `,
  standalone: true,
})
export class MessageComponent {
  private language: 'en' | 'ja' = 'en';

  get message() {
    return this.language === 'en' ? 'Hello' : 'こんにちは';
  }

  toggleLanguage() {
    this.language = this.language === 'en' ? 'ja' : 'en';
  }
}

このコンポーネントのクラスとしての振る舞いを要約すると、「toggleLanguage() メソッドを呼び出すと、message getter メソッドが返す値が変化する」でしょう。そのことを確かめるためのテストコードは次のように書くことができます。

describe('MessageComponent', () => {
  it('.message should be "Hello"', async () => {
    await TestBed.configureTestingModule({
      imports: [MessageComponent],
    }).compileComponents();

    const fixture = TestBed.createComponent(MessageComponent);
    const component = fixture.componentInstance;

    expect(component.message).toBe('Hello');
  });
  it('.message should be "こんにちは" after toggleLanguage()', async () => {
    await TestBed.configureTestingModule({
      imports: [MessageComponent],
    }).compileComponents();

    const fixture = TestBed.createComponent(MessageComponent);
    const component = fixture.componentInstance;
    component.toggleLanguage();

    expect(component.message).toBe('こんにちは');
  });
});

DOM のテストと異なり、ここでのアサーションの対象はコンポーネントのクラスインスタンスの状態です。ComponentFixturecomponentInstance プロパティによって MessageComponent クラスのインスタンスを取得できます。クラスとしての振る舞いをテストする場合のほとんどは、Act ステップでクラスのメソッドを呼び出し、Assert ステップでクラスのプロパティの状態を検証することになるでしょう。

クラスと DOM のどちらでテストすべきか?

このテストコードでは MessageComponent の振る舞いをクラスインスタンスを使ってテストしましたが、同じ振る舞いを DOM によってテストすることもできます。たとえば、次のように「ボタンをクリックすると表示されるメッセージが"こんにちは"に変化する」テストとして書いてもよいでしょう。

it('should render "こんにちは" after toggle language button click', async () => {
  await TestBed.configureTestingModule({
    imports: [MessageComponent],
  }).compileComponents();

  const fixture = TestBed.createComponent(MessageComponent);
  const element = fixture.nativeElement as HTMLElement;
  // トグルボタンをクリックする
  const button = element.querySelector('button') as HTMLButtonElement;
  button.click();
  // ビューにコンポーネントの変更を反映させる
  fixture.detectChanges();

  expect(element.textContent).toContain('こんにちは');
});

このように書かれた DOM のテストはクラスとしての振る舞いと同等以上の意味を持つでしょう。なぜなら、このコンポーネントが実際にユーザーのもとで動作するときには、ユーザーは toggleLanguage() メソッドを呼び出すのではなく、画面上に表示されたボタンをクリックするはずだからです。

テストが意味を持つのは、アプリケーションがユーザーのもとで期待どおりに振る舞うことを事前に確かめられるからです。したがって、実際の使われ方により近いテストによって振る舞いが確かめられるほど、そのテストは大きな意味を持つはずです。逆に、MessageComponentmessage プロパティの値が変化していても、それがユーザーに見えていなければ意味がありませんし、ボタンをクリックしても toggleLanguage() メソッドが呼び出されていなければ意味がありません。

このことを踏まえると、DOM に影響を与えることを責任とするコンポーネントは DOM のテストを優先すべきだと言えるでしょう。ただし、冒頭に述べたように、コンポーネントが持ちうる責任は DOM の構築だけではありません。何をテストするかによって、選ぶべきテストの方法も変わることに留意しておきましょう。

Angular Testing Library で DOM のテストを簡単にする

さて、ここまで TestBed を使ったコンポーネントのテストの基本を学びましたが、コンポーネントの単純さに対してテストコードが冗長だと感じたのではないでしょうか。

TestBed は幅広いニーズに応えるために、その API は複雑なものになっています。汎用的かつ柔軟ではありますが覚えなければならないことも多く、これから Angular のテストを書きはじめる初心者の味方ではありません。また、TestBed.configureTestingModule()TestBed.createComponent() のような定型文がテストコードの中で何度も登場するため、本質的な部分を読み取りにくくするノイズとなることもあります。

本書ではこのような背景から、入門書としては本来避けるべきだと思われますが、Angular のテスト初心者にこそサードパーティのテストユーティリティライブラリである Angular Testing Library を推奨します。

https://github.com/testing-library/angular-testing-library

Testing Library は、UI コンポーネントをテストする上での優れたプラクティスをユーティリティライブラリとして提供します。ライブラリのコアはフレームワークを問わずに適用できるようになっており、Angular Testing Library はそのコアと Angular のテストを橋渡しする役割のライブラリです。

https://testing-library.com/

しかし、Angular のテストをこれから学ぼうというのに、さらにユーティリティライブラリの使い方まで覚えなければならないのかと思われるかもしれません。その心配はもっともですが、それでもあえて入門者向けに Testing Library を薦めるのは、そのプラクティスが優れたものであると同時に、ほぼ完全に TestBed を開発者から隠蔽してくれるからです。

Angular Testing Library を使うと、UI コンポーネントのテストのほとんどのケースにおいて、TestBedを使わずにテストを書くことができます。特殊なケースでは必要になるかもしれませんが、その頃には Angular のテストにも慣れて、TestBed を本格的に学習する準備ができていることでしょう。本書では、テストを書くことに慣れていない段階で TestBed の理解に多くの時間を費やすよりも、まずは TestBed のことを忘れ、誰にでも使いやすく設計された Angular Testing Library を使ってすらすらとテストを書けるようになることを優先します。

引き続き TestBed によるテストの書き方を学びたい場合は、公式ドキュメントを参考にしてください。

Angular Testing Library の導入

Angular Testing Library は単なるユーティリティライブラリであるため、新しく作られたばかりのアプリケーションであっても、すでに長く開発されているアプリケーションであっても、いつでも同じ手順で導入できます。また、Jasmine や Jest のようなテストフレームワークにも依存していませんので、ほとんどのアプリケーションで採用できるでしょう。

まずは @testing-library/angular パッケージを npm からインストールしましょう。

# npm
npm install -D @testing-library/angular
# yarn
yarn add -D @testing-library/angular

必要な準備はこれだけです。それでは Angular Testing Library を使ってテストを書いてみましょう。先ほどの MessageComponent のテストは、次のように書くことができます。

import { screen, render, fireEvent } from '@testing-library/angular';

it('should render "こんにちは" after toggle language button click', async () => {
  // MessageComponentをレンダリングする
  await render(MessageComponent);
  // 画面上のボタンにクリックイベントを発火させる
  fireEvent.click(screen.getByRole('button'));
  // 画面上に"こんにちは"というテキストコンテンツが存在することを検証する
  expect(screen.getByText('こんにちは')).toBeDefined();
});

TestBed を使ったものと比べて、かなり簡潔なテストコードではないでしょうか。Testing Library の API である renderscreenfireEvent は、どのようなコンポーネントのテストにも使う基本的な API です。この 3 つを覚えておけば一般的なテストを書くのに困ることはありません。では、それぞれの役割の概要を見ていきましょう。

render

render 関数は、これまで TestBed を使って書いていた、テストアプリケーションのセットアップやコンポーネントをレンダリングする一連の処理をひとまとめにした API です。

https://testing-library.com/docs/angular-testing-library/api#render

次のように第 1 引数にコンポーネントを指定してレンダリングすることができます。renderTestBed.compileComponents() を内包しているため、Promise を返す非同期 API です。awaitを使ってレンダリングが完了するまで待ちましょう。

await render(MessageComponent);

render の第 2 引数には TestBed.configureTestingModule() と同じようにテストアプリケーションを構成するためのオプションを指定できますが、具体的な使い方はこの後の内容で触れるため、ここでは割愛します。今のところは TestBed を使った Arrange ステップを簡略化できる API として捉えておくとよいでしょう。

screen

screen は Testing Library の クエリ API にアクセスするためオブジェクトです。Testing Library は HTML ドキュメントの中から特定の DOM 要素を見つける方法としてクエリ API を提供します。

クエリ API にはいくつかの種類があり、DOM 要素を見つけるための条件によって使い分けます。screen.getByText クエリは特定の文字列をテキストコンテンツとして持つ要素を取得することができるクエリです。また、screen.getByRole('button') のように要素のロール[1]によって取得することもできます。その他のクエリについては Testing Library の公式ドキュメントを参照してください。

Types of Queries | Testing Library

fireEvent

fireEvent は Testing Library を通して DOM イベントを発火させるための API です。

今回利用した fireEvent.click メソッドは、引数に渡した要素をターゲットにしてクリックイベントを発火させます。イベントハンドリングが終わった後には自動的にコンポーネントの変更検知が行われるため、detectChanges()を呼び出す必要がありません。

Testing Library を使ったテストの流れ

Testing Library を使うと、ほとんどのコンポーネントのテストは次の 3 ステップに整理されます。

  1. render 関数でコンポーネントをレンダリングする(Arrange)
  2. fireEvent で DOM イベントを発火させる(Act)
  3. screen で DOM 要素を取得して検証する(Assert)

ここからは、さらに振る舞いが増えたコンポーネントのテストの書き方を学んでいきます。サンプルコードはすべて Testing Library を使って書かれていますが、この基本的な構造を覚えておけば、初めて見る Testing Library のサンプルコードでもすんなりと理解できるでしょう。

インプット・アウトプットを持つコンポーネントのテスト

これまでの TitleComponentMessageComponent はそれ単体で完結したコンポーネントでした。しかし、実際のアプリケーション開発でそのようなコンポーネントを作成することは多くありません。ほとんどの場合、コンポーネントは外部から渡された何らかのデータをもとにして振る舞いを変えたり、逆にコンポーネントで発火したイベントを外部に伝えたりして、コンポーネント内外の相互作用によって一連の振る舞いを成り立たせています。

コンポーネント内外の相互作用を作り出す代表的な機能は インプットアウトプット です。これらはテンプレートバインディングを通して、親子関係にあるコンポーネントの間でデータの受け渡しを可能にします。では、インプットやアウトプットを備えたコンポーネントはどのようにテストすればよいのか、その例を見ていきましょう。

コンポーネントのインプットのテスト

次のサンプルコードでは、最初に登場したアプリケーションのタイトルを表示する TitleComponent を、表示する文字列をインプットで受け取るようにした改訂版のコンポーネントを実装しています。

@Component({
  selector: 'app-title',
  template: ` <h1>{{ appName }}</h1> `,
  standalone: true,
})
export class TitleComponent {
  @Input() appName = '';
}

この新しい TitleComponent はもはやそれ単体では動作せず、親コンポーネントから appName プロパティを渡される必要があります。「親から渡された appName<h1> タグで表示する」という振る舞いをテストするためには、データを渡す親コンポーネントの動きも含めてエミュレートしなければなりません。次のテストコードを見てみましょう。

it('should render application title', async () => {
  await render(TitleComponent, {
    // appNameプロパティに値をセットする
    componentProperties: { appName: 'My Application' },
  });

  expect(screen.getByRole('heading').textContent).toContain('My Application');
});

render関数は、第 2 引数のcomponentPropertiesプロパティによって、レンダリングされるコンポーネントのプロパティに値をセットできます。この機能によって、インプットを持つコンポーネントの振る舞いをテストできます。また、この例ではプロパティに最初から値が渡されている場合の振る舞いをテストしていますが、コンポーネントの初期化後に値が変わった場合の振る舞いはテストできていません。親コンポーネントから渡される値が動的に変化することをテストするには、次のように render 関数の戻り値から取得できる change 関数を使用します。

it('should render changed application title', async () => {
  // change関数を取り出す
  const { change } = await render(TitleComponent, {
    componentProperties: { appName: 'My Application' },
  });
  // プロパティの値を更新する
  change({ appName: 'My Application v2' });

  expect(screen.getByRole('heading').textContent).toContain('My Application v2');
});

テスト用テンプレートを使ったテスト

ところで、実際に動作するアプリケーションコードでは、コンポーネントのインプットに値が渡されるのは親コンポーネントのテンプレート中です。しかし先ほどのテストコードではテンプレートを介さずにプロパティに値を渡してテストしているため、インプットの実際の使われ方でテストできているとは言えないかもしれません。

そこで、render 関数のもうひとつの機能を使いましょう。次の例のように第 1 引数にテンプレート HTMLを渡すと、そのテンプレートをもとに動的に生成されたコンポーネントがレンダリングされます。そのテンプレートの中でテストしたいコンポーネントを、実際のアプリケーション中と同じく <app-title> のようにタグで呼び出し、インプットを [appName] のようにデータバインディングで渡すようにします。

it('should render application title', async () => {
  await render(`<app-title [appName]="'My Application'"></app-title>`, {
    imports: [TitleComponent],
  });

  expect(screen.getByRole('heading').textContent).toContain('My Application');
});

it('should render changed application title', async () => {
  const { change } = await render(`<app-title [appName]="appName"></app-title>`, {
    imports: [TitleComponent],
    componentProperties: { appName: 'My Application' },
  });
  // 親コンポーネントのプロパティを更新すると、データバインディングによりInputも更新される
  change({ appName: 'My Application v2' });

  expect(screen.getByRole('heading').textContent).toContain('My Application v2');
});

render 関数はコンポーネントクラスを指定する方法とテンプレートを指定する方法の両方を提供していますが、本書ではテンプレートを指定する方法を推奨します。最大の理由は、テンプレートを指定する方法はコンポーネントだけでなくディレクティブをテストする場合にもまったく同じようにテストが書けるからです。また、ほとんどのコンポーネントはテンプレート中でタグとして呼び出されて動作するのですから、やはり実際の使われ方でテストするという基本方針に従うならば、テンプレートでテストすることが望ましいでしょう。

コンポーネントのアウトプットのテスト

続いて、アウトプットを持つコンポーネントのテストを書いてみましょう。次の例では、インプットで受け取ったメッセージを表示しつつ、"Close"ボタンをクリックするとアウトプットとして closed イベントを発火するコンポーネント ToastComponent を定義しています。

@Component({
  selector: 'app-toast',
  template: `
    <div>
      <p>{{ message }}</p>
      <button (click)="close()">Close</button>
    </div>
  `,
  standalone: true,
})
export class ToastComponent {
  @Input() message = '';
  @Output() closed = new EventEmitter<void>();

  close() {
    this.closed.emit();
  }
}

このコンポーネントのテストコードは次のようになるでしょう。ひとつめはインプットとして受け取ったmessageを表示する振る舞いについてのテストで、ふたつめはアウトプットとして closed イベントを発火する振る舞いについてのテストです。

describe('ToastComponent', () => {
  it('should render passed message', async () => {
    await render(`<app-toast [message]="message"></app-toast>`, {
      imports: [ToastComponent],
      componentProperties: { message: 'Test Message' },
    });

    expect(screen.getByText('Test Message')).toBeDefined();
  });

  it('should emit (closed) on "Close" button click', async () => {
    const onClosed = jasmine.createSpy();
    await render(`<app-toast [message]="message" (closed)="onClosed()"></app-toast>`, {
      imports: [ToastComponent],
      componentProperties: { message: 'Test Message', onClosed },
    });

    fireEvent.click(screen.getByRole('button', { name: 'Close' }));

    expect(onClosed).toHaveBeenCalled();
  });
});

jasmine.createSpy()は Jasmine が提供するスパイ関数を作成するための API です。スパイ関数はその関数の呼び出しを監視し、記録することができます。このテストでは toHaveBeenCalled() matcher を使って、イベントリスナーとして渡した関数が呼び出されたかどうかを検証しています。

コンポーネントのアウトプットのテストは、スパイ関数をイベントリスナーにバインディングすることによって、イベントが期待どおりに発火することを検証することができます。もちろん、テンプレートを介さずにコンポーネントの closed プロパティを直接スパイすることもできますが、何度も繰り返しているように、それはアプリケーション中でのコンポーネントの実際の使われ方ではありません。アプリケーションで使われるようにテストを書くことで、コンポーネントの振る舞いをより強く信頼できるでしょう。

サービスに依存するコンポーネントのテスト

コンポーネント間の相互作用を作り出すもうひとつの代表的な仕組みは、サービスによるデータの共有です。サービスはデータの共有だけでなく、サーバーサイド API の呼び出しやクライアントサイドの状態の管理など、さまざまな責任のために作られます。

コンポーネントがサービスを呼び出すためには依存性の注入が必要です。次の例では、"Sign in" ボタンを備えたヘッダーコンポーネント HeaderComponent がサインイン処理の詳細を担う AuthService に依存しています。 "Sign in" ボタンがクリックされると、 AuthService.signIn() メソッドが呼び出される振る舞いが期待されます。

@Component({
  selector: 'app-header',
  template: `
    <header>
      <h1>My Application</h1>
      <button (click)="signIn()">Sign in</button>
    </header>
  `,
  standalone: true,
})
export class HeaderComponent {
  constructor(private authService: AuthService) {}
  signIn() {
    this.authService.signIn();
  }
}

このように依存するサービスを持つコンポーネントはどのようにテストするとよいでしょうか。まずは責任と関心の範囲の点で、 HeaderComponent のテストに求められるのは「AuthServicesignIn()メソッドを呼び出すこと」の検証です。逆に、signIn() メソッドがその内部で行うサーバーサイドとの通信などは、このコンポーネントでテストすることではありません。それは AuthService のテストで保証されているべきことです。

したがって、HeaderComponentのテストは次のように書けるでしょう。

describe('HeaderComponent', () => {
  it('should call AuthService.signIn() on "Sign in" button click', async () => {
    const { debugElement } = await render(`<app-header></app-header>`, {
      imports: [HeaderComponent],
    });
    const authService = debugElement.injector.get(AuthService);
    spyOn(authService, 'signIn');

    fireEvent.click(screen.getByRole('button', { name: 'Sign in' }));

    expect(authService.signIn).toHaveBeenCalled();
  });
});

ここで新しく登場したのは debugElement.injector です。render 関数の戻り値から取得できる debugElement は、レンダリングされたコンポーネントの詳細な情報にアクセスするためのユーティリティ API のひとつです。

テスティングユーティリティ API - DebugElement

debugElement.injector プロパティは対象のコンポーネントにおけるインジェクターです。ここでは debugElement.injector.get(AuthService) によって、HeaderComponent と同じインジェクターから AuthService のインスタンスを取得しています。

AuthServiceのインスタンスを取得した後、Jasmine の spyOn 関数を使って、signInメソッドをスパイしています。これにより、"Sign in"ボタンがクリックされたあとにメソッドが呼び出されたことを toHaveBeenCalled() matcher で検証することができます。

サービスのモックによるテスト

コンポーネントが依存するサービスの中には、その実際の処理がテスト中に実行されては困るケースもあります。たとえば、外部 API の呼び出しはテスト環境からアクセスできなかったり、API から取得するデータが変わることでテスト結果に影響してしまうことなどがあるでしょう。

そのような場合には、コンポーネントに注入されるサービスのインスタンスをテスト用のモックオブジェクトに置き換えることができます。次の例では、render関数の第 2 引数のprovidersプロパティを使って、AuthService のインスタンスとして注入されるオブジェクトを置き換えています。

it('should call AuthService.signIn() on "Sign in" button click', async () => {
  const signIn = jasmine.createSpy();
  await render(`<app-header></app-header>`, {
    imports: [HeaderComponent],
    providers: [
      {
        provide: AuthService,
        useValue: { signIn },
      },
    ],
  });

  fireEvent.click(screen.getByRole('button', { name: 'Sign in' }));

  expect(signIn).toHaveBeenCalled();
});

モックオブジェクトを使うことで、AuthServiceの実装の詳細に左右されず、HeaderComponentに期待される振る舞いだけをテストすることができます。これは AuthService がさらに他のサービスへの依存関係を持つ場合には特に有用です。

ただし、モックオブジェクトを使ったテストは、この章で繰り返し述べてきた「実際の使われ方でテストする」原則に反しています。HeaderComponentがモックされたサービスの signIn() メソッドを呼び出せていたとしても、それだけではアプリケーションとして動作したときに期待どおりのサインイン処理が実行されるかどうかはわかりません。つまり、モックにより単純化されたテストは、AuthService のテストが書かれていることを前提にしています。

もし、依存先のサービスのテストが十分に書かれていない場合は、まずはモックを使わないテストからはじめるのがよいでしょう。責任の範囲からは外れますが、コンポーネントをテストすることで依存先も同時にテストできます。それが難しい場合は、先にサービスのテストを書いてからコンポーネントに戻ってきましょう。

モックはテストを簡単にしますが、モックに頼りすぎるとテストとアプリケーションの振る舞いが乖離していきます。モックを使うのはテストがどうしようもなく難しい場合の最終手段であると考えておき、基本的には本物のインスタンスを使ってテストすることをおすすめします。

より複雑なコンポーネントのテストへ

この章ではもっとも単純なコンポーネントから少し複雑さを増したコンポーネントまで、それぞれの振る舞いを確かめるテストの書き方を学びました。しかし、これはあくまでも入門です。実際の開発現場で作られるコンポーネントはもっと複雑で、簡単にテストできるようになっていないことがほとんどでしょう。そのため、この章で学んだ書き方がそのまま適用できるのは、まだ複雑になっていない簡素なコンポーネントか、十分にリファクタリングされて責任が限定されたコンポーネントだけです。

しかし、どんなに複雑なコンポーネントでも、その基本的な構造は変わりません。まずは単純なコンポーネントのテストに書き慣れることで、複雑なコンポーネントのテストに挑めるようになるでしょう。また、この章で学んだようなテストができるようにコンポーネントをリファクタリングすることが、Angular アプリケーション開発の持続可能性を高める上での目標になるでしょう。

この章のまとめ

  • TestBed API を使って Angular のコンポーネントをテストする基本的な方法を学びました。
  • Angular Testing Library を使って、コンポーネントのテストをより簡単に書く方法を学びました。
  • サービスに依存するコンポーネントをテストする方法を学びました。
脚注
  1. ARIAで定義される、各要素のアクセシビリティツリー上のロール ↩︎