angular

Angular コンポーネントのテスト(sample1 / フォームに含まれない単体のHTML要素のテスト)

目次:Angular コンポーネントのテスト


雛形のテストコードについて何となく理解できたところで、コンポーネントのテンプレートにHTML要素を追加しながらテストを書いてみましょう。
テストコードは前章で作成した Sample1Component を使用します。

コンポーネントのプロパティがテンプレートに反映される事を検証

コンポーネントに id というプロパティを追加し、その値を <span> に表示します。

// sample1.component.ts

export class Sample1Component implements OnInit {
+  id: number;
...
}
// sample1.component.html

- <p>
-   sample1 works!
- </p>

+ <span name="id">{{id}}</span>

プロパティ id に数値を与えて要素のテキストが変わる事を検証してみましょう。

// sample1.component.spec.ts

+ import { By } from '@angular/platform-browser';

+ it('span - コンポーネントのプロパティがテンプレートに反映される', () => {
+   component.id = 100;
+   fixture.detectChanges();
+
+   const element = fixture.debugElement.query(By.css('[name=id]')).nativeElement as HTMLSpanElement;
+   expect(element.textContent).toBe('100');
+ });

変数 component はコンポーネントに紐付いた Sample1Component クラスを参照しています。
そのクラスの id プロパティに100という数値を与えます。detectChanges を呼び出すとテンプレートが更新されます。

fixture.debugElement はコンポーネント自体のHTML要素 <sample1></sample1> の参照です。
所有するHTML要素を検索するには、

  • 単体の要素であれば debugElement.query()
  • コレクションであれば debugElement.queryAll()

を使います。

検索結果は DebugElement として返却されます。DebugElement はHTML要素の参照を nativeElement プロパティとして持っているため、このプロパティを通してテキスト、値、属性などを検証できます。

nativeElement の型は any で宣言されていますが、この例では <span name="id">{{id}}</span> を参照するため HTMLSpanElement にダウンキャストできます。キャストしなくても検証は可能ですが、IDEによる補完が効いて便利な事から私は積極的にキャストするようにしています。

最初に与えた数値と HTMLSpanElement のテキストが一致する事を検証して、このテストは完了です。

ngModel を利用したHTML要素の検証

ngModel は FormsModule で定義されたディレクティブのため、モジュールをインポートします。

// app.module.ts

+ import { FormsModule } from '@angular/forms';

@NgModule({
  declarations: [
    AppComponent,
    Sample1Component,
  ],
  imports: [
+    FormsModule,
  ...

コンポーネントに name というプロパティを追加し、 ngModel でHTML要素にバインドしてみましょう。

// sample1.component.ts

export class Sample1Component implements OnInit {
  id: number;
+  name: string;
}
// sample1.component.html

<span name="id">{{id}}</span>
+ <input name="name" type="text" [(ngModel)]="name">

テストを追加します。

// sample1.component.spec.ts

import {
  ...
+  fakeAsync,
+  tick
} from '@angular/core/testing';

+ import { FormsModule } from '@angular/forms';

describe('Sample1Component', () => {
  ...
  beforeEach(async(() => {
    TestBed.configureTestingModule({
+      imports: [ FormsModule ],
      declarations: [ Sample1Component ]
    })
    .compileComponents();
  }));
  ...

+  it('input - コンポーネントのプロパティがテンプレートに反映される', fakeAsync(() => {
+    component.name = 'abc';
+    fixture.detectChanges();
+    tick();

+    const element = fixture.debugElement.query(By.css('[name=name]')).nativeElement as HTMLInputElement;
+    expect(element.value).toBe('abc');
+  }));
  ...

さきほどの <span> の検証とは少し違いますね。

違いその1)FormsModule

アプリケーション側で ngModel が定義されている FormsModule のインポートを追加したため、テスト環境のモジュールにも同じようにインポートします。

違いその2)fakeAsync / tick

it('xxx', fakeAsync(() => {
  ...
  tick();
  ...
}));

ngModel は非同期で処理されるため、非同期処理の終了を待つ必要があります。
Angular 公式ドキュメントでは非同期処理を伴うコンポーネントのテストとして async / whenStable を使う方法と fakeAsync / tick を使う方法の二通りが紹介されています。
この例では後者の fakeAsync / tick を使っています。

公式ドキュメント Component with async service
https://angular.io/guide/testing#component-with-async-service

テンプレートの値を変更してコンポーネントのプロパティが変わる事を検証

今度はテンプレートの <input> にテキストを入力して、コンポーネントのプロパティが変わる事を検証してみましょう。

// sample1.component.spec.ts

+ it('input - テンプレートの値の変更がコンポーネントに反映される', () => {
+   const element = fixture.debugElement.query(By.css('[name=name]')).nativeElement as HTMLInputElement;
+   element.value = 'def';
+   element.dispatchEvent(new Event('input'));
+
+   expect(component.name).toBe('def');
+ });

入力イベントを発生させるために Web APIの dispatchEvent を利用しています。
DebugElement にHTML要素の動作を抽象化するメソッドが用意されているため、イベントのトリガにはこちらを使っても良いでしょう。

公式ドキュメント triggerEventHandler
https://angular.io/guide/testing#triggereventhandler

HTML要素ごとのサンプルコード

ここまで <span> <input> を使ったテストコードを紹介しました。
私が実際にテストを書く時には「あれ? select はどう書くんだっけ?」など迷う事が多いので、HTML要素ごとにテストのサンプルコードをまとめておきます。

span

export class Sample1Component implements OnInit {
  ...
  id: number;
<span name="id">{{id}}</span>
it('span - コンポーネントのプロパティがテンプレートに反映される', () => {
  component.id = 100;
  fixture.detectChanges();

  const element = fixture.debugElement.query(By.css('[name=id]')).nativeElement as HTMLSpanElement;
  expect(element.textContent).toBe('100');
});

input [type=text]

export class Sample1Component implements OnInit {
  ...
  name: string;
<input name="name" type="text" [(ngModel)]="name">
it('input - コンポーネントのプロパティがテンプレートに反映される', fakeAsync(() => {
  component.name = 'abc';
  fixture.detectChanges();
  tick();

  const element = fixture.debugElement.query(By.css('[name=name]')).nativeElement as HTMLInputElement;
  expect(element.value).toBe('abc');
}));

it('input - テンプレートの値の変更がコンポーネントに反映される', () => {
  const element = fixture.debugElement.query(By.css('[name=name]')).nativeElement as HTMLInputElement;
  element.value = 'def';
  element.dispatchEvent(new Event('input'));

  expect(component.name).toBe('def');
});

input [type=radio]

export class Sample1Component implements OnInit {
  ...
  gender: string;
<input type="radio" name="gender" value="male" [(ngModel)]="gender"><input type="radio" name="gender" value="female" [(ngModel)]="gender">
it('radio - コンポーネントのプロパティがテンプレートに反映される', fakeAsync(() => {
  component.gender = 'male';
  fixture.detectChanges();
  tick();

  const radio = fixture.debugElement.query(By.css('[name=gender][value=male]')).nativeElement as HTMLInputElement;
  expect(radio.checked).toBe(true);
}));

it('radio - テンプレートの値の変更がコンポーネントに反映される', () => {
  const radio = fixture.debugElement.query(By.css('[name=gender][value=male]')).nativeElement as HTMLInputElement;
  radio.click();

  expect(component.gender).toBe('male');
});

input [type=checkbox]

export class Sample1Component implements OnInit {
  ...
  agreement: boolean;
<input type="checkbox" name="agreement" [(ngModel)]="agreement">
it('checkbox - コンポーネントのプロパティがテンプレートに反映される', fakeAsync(() => {
  component.agreement = true;
  fixture.detectChanges();
  tick();

  const checkbox = fixture.debugElement.query(By.css('[name=agreement]')).nativeElement as HTMLInputElement;
  expect(checkbox.checked).toBe(true);
}));

it('checkbox - テンプレートの値の変更がコンポーネントに反映される', () => {
  const checkbox = fixture.debugElement.query(By.css('[name=agreement]')).nativeElement as HTMLInputElement;
  checkbox.click();

  expect(component.agreement).toBe(true);
});

select

export class Sample1Component implements OnInit {
  ...
  works = [
    { id: 'employee', label: '会社員' },
    { id: 'student', label: '学生' },
    { id: 'other', label: 'その他' },
  ];
  work: { [key: string]: string };
<select name="work" [(ngModel)]="work">
    <option *ngFor="let item of works" [ngValue]="item">{{item.label}}</option>
</select>
it('select - コンポーネントのプロパティがテンプレートに反映される', fakeAsync(() => {
  component.work = component.works[1];
  fixture.detectChanges();
  tick();

  const option = fixture.debugElement.queryAll(By.css('[name=work] option'))[1].nativeElement as HTMLOptionElement;
  expect(option.selected).toBe(true);
}));

it('select - テンプレートの値の変更がコンポーネントに反映される', () => {
  const select = fixture.debugElement.query(By.css('[name=work]')).nativeElement;
  const option = fixture.debugElement.queryAll(By.css('[name=work] option'))[2].nativeElement;

  select.value = option.value;
  select.dispatchEvent(new Event('change'));
  expect(component.work.label).toBe('その他');
});

textarea

export class Sample1Component implements OnInit {
  ...
  note: string;
<textarea name="note" [(ngModel)]="note"></textarea>
it('textarea - コンポーネントのプロパティがテンプレートに反映される', fakeAsync(() => {
  component.note = 'ABC';
  fixture.detectChanges();
  tick();

  const element = fixture.debugElement.query(By.css('[name=note]')).nativeElement as HTMLTextAreaElement;
  expect(element.value).toBe('ABC');
}));

it('textarea - テンプレートの値の変更がコンポーネントに反映される', () => {
  const element = fixture.debugElement.query(By.css('[name=note]')).nativeElement;
  element.value = 'DEF';
  element.dispatchEvent(new Event('input'));

  expect(component.note).toBe('DEF');
});

Web API

余談ですが、angularJS は jQuery と連携していたため angular.element に対して .find('button:contains("保存")').find('select option:selected') のようなjQueryセレクタによる検索ができてとても便利でした。値も .text().value() で簡単に取得できました。

Angular ではこれらの便利な jQuery の機能が使えなくなり HTMLElement (または DebugElement が提供するプロパティやメソッド)を通して検証を行います。jQuery の登場以来 HTMLElement を直接操作する事がなかったのですが Angular のテストを書くためにMDNのドキュメントを漁っています。

MDN web docs HTMLElement
https://developer.mozilla.org/ja/docs/Web/API/HTMLElement


次の記事:sample2 / Template driven Form を利用したコンポーネントのテスト