Chapter 03

コンポーネントのテスト入門

lacolaco
lacolaco
2022.07.18に更新

この章では、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>`,
})
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({
      declarations: [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() メソッドの引数に渡されるモジュール定義は、いわばテストアプリケーション用の AppModule です。アプリケーション用の NgModule に設定するのと同じように、テストアプリケーションに組み込みたい TitleComponentdeclarations 配列に追加しています。

TestBed.compileComponents() について

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

await TestBed.configureTestingModule({ 
    declarations: [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 (検証) に対応しています。

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

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

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

@Component({
  selector: 'app-message',
  template: `
    <button (click)="toggleLanguage()">Toggle Language</button>
    <p>{{ message }}</p>
  `,
})
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 ゲッターが返す値が変化する」でしょう。そのことを確かめるためのテストコードは次のように書くことができます。

describe('MessageComponent', () => {
  it('.message should be "Hello"', async () => {
    await TestBed.configureTestingModule({
      declarations: [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({
      declarations: [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({
        declarations: [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 を推奨します。

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

しかし、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です。

次のように第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 のサンプルコードでもすんなりと理解できるでしょう。

Input/Output を持つコンポーネントのテスト

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

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

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

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

@Component({
  selector: 'app-title',
  template: ` <h1>{{ appName }}</h1> `,
})
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プロパティによって、レンダリングされるコンポーネントのプロパティに値をセットできます。この機能によって、Inputを持つコンポーネントの振る舞いをテストできます。また、この例ではプロパティに最初から値が渡されている場合の振る舞いをテストしていますが、コンポーネントの初期化後に値が変わった場合の振る舞いはテストできていません。親コンポーネントから渡される値が動的に変化することをテストするには、次のように 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');
});

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

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

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

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

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

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

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

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

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

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

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

describe('ToastComponent', () => {
  it('should render passed message', async () => {
    await render(`<app-toast [message]="message"></app-toast>`, {
      declarations: [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>`, {
      declarations: [ToastComponent],
      componentProperties: { message: 'Test Message', onClosed },
    });

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

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

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

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

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

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

コンポーネントがサービスを呼び出すためには Dependency Injection (依存オブジェクト注入) が必要です。次の例では、"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>
  `,
})
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>`, {
      declarations: [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>`, {
    declarations: [HeaderComponent],
    providers: [
      {
        provide: AuthService,
        useValue: { signIn },
      },
    ],
  });

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

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

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

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

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

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

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

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

しかし、どんなに複雑なコンポーネントでも、その基本的な構造は変わりません。コンポーネントをテストする方法も、Arrange/Act/Assertの基本は変わりません。まずは単純なコンポーネントのテストに書き慣れることで、複雑なコンポーネントのテストに挑めるようになるでしょう。

また、この章で学んだようなテストができるようにコンポーネントをリファクタリングすることが、Angularアプリケーション開発の持続可能性を高める上での目標になるでしょう。この先の章では、より複雑なコンポーネントのテストや、ディレクティブやサービスなどコンポーネント以外の要素をテストする方法を学びながら、そのようなテストが書けるようにリファクタリングしていく道筋を描いていきます。

脚注
  1. ARIAで定義される、各要素のアクセシビリティツリー上のロール ↩︎