noxi雑記

.NET、Angularまわりの小ネタブログ

Angular CDK Component Harnessを導入する

遂に Angular 9 がリリースされました。リリーススケジュールが4月・10月くらいが目処だったことを考えると大分難産でしたね(筆者の感想です)。
ところで Angular CDK にテスト用の機能として Component Harness が追加されました。使い方やテストへの導入を試してみます。

material.angular.io



環境

Angular CLI: 9.0.1
Node: 12.14.1
OS: win32 x64

Angular: 9.0.0
... animations, cdk, common, compiler, compiler-cli, core, forms
... language-service, material, platform-browser
... platform-browser-dynamic, router
Ivy Workspace: Yes

Package                           Version
-----------------------------------------------------------
@angular-devkit/architect         0.900.1
@angular-devkit/build-angular     0.900.1
@angular-devkit/build-optimizer   0.900.1
@angular-devkit/build-webpack     0.900.1
@angular-devkit/core              9.0.1
@angular-devkit/schematics        9.0.1
@angular/cli                      9.0.1
@ngtools/webpack                  9.0.1
@schematics/angular               9.0.1
@schematics/update                0.900.1
rxjs                              6.5.4
typescript                        3.7.5
webpack                           4.41.2

Component Harness とは

公式ページを Google 翻訳にかけてみます

A component harness is a class that lets a test interact with a component via a supported API. Each harness's API interacts with a component the same way a user would. By using the harness API, a test insulates itself against updates to the internals of a component, such as changing its DOM structure. The idea for component harnesses comes from the PageObject pattern commonly used for integration testing.

コンポーネントハーネスは、サポートされているAPIを介してテストがコンポーネントと対話できるようにするクラスです。 各ハーネスのAPIは、ユーザーと同じようにコンポーネントと対話します。 ハーネスAPIを使用することにより、テストは、DOM構造の変更など、コンポーネントの内部の更新から自身を隔離します。 コンポーネントハーネスのアイデアは、統合テストで一般的に使用されるPageObjectパターンに基づいています。

コンポーネントを PageObject パターンのクラスでカプセル化してテストしやすさが上がるものの様です。ユニットテスト、 E2E テストの両方に対して同じ実装が使えるのが魅力です。

Component Harness を使用する

まずは Component Harness が実装されている Angular Material をアプリに組み込んでテストを作ってみます。

対象のコンポーネント作成

まずは適当にプロジェクトを作成します。

ng new test-harness-app
cd test-harness-app
ng add @angular/material

そして AppComponent を書き換え Angular Material のボタンが2つあるコンポーネントに変更します。

<!-- app.component.html -->
<button mat-button color="primary" (click)="onClick()">{{ buttonName }}</button>
<button mat-flat-button color="accent" disabled (click)="onClick()">{{ buttonName }}</button>
// app.component.ts
import { Component } from '@angular/core';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss']
})
export class AppComponent {
  buttonName = 'button';

  onClick() {
    this.buttonName = 'clicked!';
  }
}
// app.component.spec.ts
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { MatButtonModule } from '@angular/material/button';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { AppComponent } from './app.component';

describe('AppComponent', () => {

  let fixture: ComponentFixture<AppComponent>;
  let component: AppComponent;

  beforeEach(async(() => {
    TestBed
      .configureTestingModule({
        imports: [
          NoopAnimationsModule,
          MatButtonModule,
        ],
        declarations: [
          AppComponent
        ],
      })
      .compileComponents();

    fixture = TestBed.createComponent(AppComponent);
    component = fixture.componentInstance;
  }));

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

});

Component Harness の生成

Component Harness を生成する方法は2つあります。1つは ComponentFixture から生成する方法、もう1つが HarnessLoarder から生成する方法です。

fixture = TestBed.createComponent(AppComponent);

// HarnessLoaderから生成
const loader = TestbedHarnessEnvironment.loader(fixture);
const harness1 = loader.getHarness(MatButtonHarness);

// ComponentFixtureから生成
const harness2 = await TestbedHarnessEnvironment.harnessForFixture(fixture, MatButtonHarness);

今回はユニットテスト環境向けのため TestbedHarnessEnvironment を使用していますが、 E2E テスト環境では ProtractorHarnessEnvironment を使用します。ちなみに ProtractorHarnessEnvironment には HarnessLoader を取得する静的メソッドしかありません。

Component Harness の操作

Component Harness が取得できたら次にテスト操作を行います。 Component Harness が DOM 操作を隠蔽してくれるため、スッキリとテストを書くことができます。

// app.component.spec.ts
import { HarnessLoader } from '@angular/cdk/testing';
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { MatButtonModule } from '@angular/material/button';
import { MatButtonHarness } from '@angular/material/button/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { AppComponent } from './app.component';

describe('AppComponent', () => {

  let fixture: ComponentFixture<AppComponent>;
  let component: AppComponent;
  let loader: HarnessLoader;

  beforeEach(async(async () => {
    TestBed
      .configureTestingModule({
        imports: [
          NoopAnimationsModule,
          MatButtonModule,
        ],
        declarations: [
          AppComponent
        ],
      })
      .compileComponents();

    fixture = TestBed.createComponent(AppComponent);
    component = fixture.componentInstance;
    loader = TestbedHarnessEnvironment.loader(fixture);
  }));

  it('生成できること。', () => {
    expect(component).toBeTruthy();
  });

  it('2つボタンが含まれること。', async () => {
    const buttons = await loader.getAllHarnesses(MatButtonHarness);
    expect(buttons.length).toBe(2);
    expect(await buttons[0].getText()).toBe('button');
    expect(await buttons[0].isDisabled()).toBe(false);
    expect(await buttons[1].getText()).toBe('button');
    expect(await buttons[1].isDisabled()).toBe(true);
  });

  it('1つ目のボタンをクリックするとボタンの文字列が変更されること。', async () => {
    const buttons = await loader.getAllHarnesses(MatButtonHarness);

    buttons[0].click();
    fixture.detectChanges(true);

    expect(await buttons[0].getText()).toBe('clicked!');
    expect(await buttons[1].getText()).toBe('clicked!');
  });

  it('2つ目のボタンをクリックしてもボタンの文字列が変更されないこと。', async () => {
    const buttons = await loader.getAllHarnesses(MatButtonHarness);

    buttons[1].click();
    fixture.detectChanges(true);

    expect(await buttons[0].getText()).toBe('button');
    expect(await buttons[1].getText()).toBe('button');
  });

});

終わりに

Angular CDK 9 で追加された Component Harness が用意された環境ではコンポーネント単位で DOM 操作を隠蔽し、テストの書きやすさや保守性が向上します。全てのコンポーネントに対して Component Harness を用意するのはちょっと億劫ですが、 UI コンポーネントから少しずつカバー範囲を増やしていく所存です。