はまったりひらめいたり…とか…

Angularや.NETやAzureやその他色々。

Angular MaterialのComponentHarnessを使ってみた(Autocomplete編)

はじめに

この投稿内のAngularは下記のバージョンで構成しています。

この記事を参照されるタイミングによってはサンプルコードが動作しない可能性がありますのでご留意くださいませ。

  • Angular CLI: 9.1.6
  • @angular/material: 9.2.3

ComponentHarness

先日、ng-japan OnAir #16 を視聴した際にでてきた

Angular Materialで提供されているComponentHarnessを使ってみました。

自分が作るAngularのアプリケーションはMaterialを多用しているものが多く

Componentのテストが壊れにくくなりそうである。ということが魅力的でした。

ComponentHarness含む「CDK Testing API」については

下記の@lacolacoさんのレポートをご参考いただければと思います。

hackmd.io

AutocompleteのComponentHarness

割とMaterial全般を使っているので、一個ずつComponentを確認しようかな、と思いまして。

まずは「Autocomplete」に手を付けてみました。

(構成の都合上MatInputもちょいちょい入ってますが…)

material.angular.io

超絶雑ですが👇がテストを行うComponentの構成です。

f:id:TakasDev:20200516174532p:plain

Serviceからリストを受け取ってAutocompleteでリスト表示。

チェックボックスでAutocompleteコントロールのDisbaledを制御できる。みたいな感じです。

Harnessの作成

使用するのは初めてなので、そもそもHarnessの作り方、使い方から確認していきます。

Angular Materialのドキュメントと、ng-japan OnAirで実装されていたソースなどを参考に実装しました。

Componentテスト用のHarnessクラスを作成し、そこに諸々準備していきます。

MaterialのComponentHarnessは、各Materialのtesting内に格納されているそうです。 あとはMatInputと組み合わせて使用するので、MatInputのHarnessも必要そうです。

import { MatAutocompleteHarness } from '@angular/material/autocomplete/testing';
import { MatInputHarness } from '@angular/material/input/testing';

で、AutocompleteとMatInputのHarnessをインポートしておきます。

privateでlocatorを準備して、コントロールにアクセスできるようにします。

今回、プレーンなチェックボックスも使用するので、locatorは👇な感じになりました。

  private getCheckBoxLocator = this.locatorFor('[data-testctl="checkbox"]');

  private getIncputLocator = this.locatorFor(
    MatInputHarness.with({
        placeholder: 'Test',
    })
  );

  private getAutoCompleteLocator = this.locatorFor(MatAutocompleteHarness);
  • チェックボックスのコントロールはテスト用の属性を指定して取得
  • MatInputは、MatInputHarnessを介して、placeholderが「Test」になっているコントロールを取得
  • AutocompleteはComponentに一つしかないので「MatAutocompleteHarness」のみで取得

コントロールの動作を実装する

Harnessクラスの中で公開する、コントロールの動作を実装していきます。

例えば、Dsiableを制御するチェックボックスのトグルは👇な感じですね。

  async toggleCheckBox(): Promise<void> {
    const chkbox = await this.getCheckBoxLocator();
    await chkbox.click();
  }

Locatorを介して取得したオブジェクトのClickイベントを叩くだけです。かんたん。

Disable状態を変更する動作をしたあとは、実際のコントロールの状態を確認したいです。

  async inputControlDisabledState(): Promise<boolean> {
    const input = await this.getIncputLocator();
    return input.isDisabled();
  }

こっちはlocatorを介してInputのオブジェクトを取得し、そのオブジェクトのisDisbaledステータスを返却します。

こっちもかんたん。

テストを実装してみる

さて、specファイルでテストを記述していきます。

テストで先程作成したHarnessを使用するためにfixtureを作成したあとに

使用するHarnessをFixtureを関連付けます(fixtureにharness(補助具)を割り当てるという表現になるんですかね?)。

  beforeEach(async () => {
    fixture = TestBed.createComponent(テストするComponent);
    component = fixture.componentInstance;
    harness = await TestbedHarnessEnvironment.harnessForFixture(
      fixture,
      作成したHarnessクラス
    );
    fixture.detectChanges();
  });

これで準備完了です。

行うテストを実装していきます。

まずは「チェックボックスをクリックしたらAutocompleteのInputが入力不可になること」を実装してみます。

  it ('CheckBoxToggle -> Input is Disabled', async () => {
    await harness.toggleCheckBox(); // Harness上で実行されるクリック処理
    const isDisabled = await harness.inputControlDisabledState; // Harness上で取得されるInputのDisabled状態
    expect(isDisabled).toBeTruthy();
  });

なんぞこれ?ですね。めちゃくちゃ簡単になっています。

Harnessなしの状態だと、Specファイル内でやっていた、ControlをElementから取得してー。とか。

そのコントロールから値を読んでー。といった処理が一掃されています。

そういったコードが抹消されたことにより、テストコードの可読性も上がっていますね。

さて、本題です。

AutocompleteのComponentHarness(2)

Autocompleteのコントロールでは下記3点をテストしてみようと思います。

  • Autocompleteのリストの初期状態
  • 対応するInputに入力があった場合に絞り込まれた状態となるか
  • Autocompleteのリストから選択された場合は対応するInputに文字列が入力されるか

まぁ、Materialで担保している内容もありますが、勉強なので…ということで。

さっそくハマる

リストの状態は、下記で取得できるのかいなと思っていたのですが

結果として帰ってきたのは空配列でした。

  async getAutoCompleteList(): Promise<MatOptionHarness[]> {
    const ctrl = await this.getAutoCompleteLocator();
    return await ctrl.getOptions();
  }

実装を確認する

ひとまず、困ったときは実装を確認するのみです。

実装の細かい部分はさておき、getOptions()の動きとしてはMatOptionのオブジェクトを取得してきているようです。

では、表示されるオプションはどうなっているのか確認してみます。

実際に動作させてElementを参照してみるとわかるのですが

AutoCompleteのリストはフォーカスがInputにあたった際に生えてくるもののようです。

f:id:TakasDev:20200516183232p:plain
リストが表示されているとき

f:id:TakasDev:20200516183316p:plain
リストが表示されていないとき

なので、オプションの情報を読むためには、「フォーカスをインプットに当てる」というワンアクションが必要であると予測します。

「フォーカスをインプットに当てる」処理を実装する

うまく行かなかった方法

インプットのlocatorはすでに作成したので、こいつが使えないかと思いました。

実装すると👇な感じですね。

  async inputFocus(): Promise<void> {
    const input = await this.getIncputLocator();
    await input.focus(); // focusを当てる
  }

これを、オプション情報の取得前に実行します。

  async getAutoCompleteList(): Promise<MatOptionHarness[]> {
    const ctrl = await this.getAutoCompleteLocator();
    this.inputFocus();
    return await ctrl.getOptions();
  }

結果、うまく行きませんでした。

f:id:TakasDev:20200516184201p:plain
テスト結果

フォーカスはあたっているが…!って感じですね。Autocompleteのリストが表示されていません。

ただ、デバッグを行っているウィンドウを選択していたらオプションが表示されるのでテストは通るのです。

f:id:TakasDev:20200516184534p:plain
デバッグウィンドウアクティブ時のテスト結果

結果はともかく、方針としては問題ないようです。

先人にならう

そもそも、MaterialのAutocompleteのComponent自体は同じようなテストをしているはずです。

どのようなテストを行っているのか確認してみました。

実装を見てみると、フォーカスをあてる処理としてdispatchFakeEvent(input, 'focusin');なることをしているようです。

ひとまず、dispatchFakeEvent周りの実装(この子この子この子)はそのまま使用させていただこうかと思います。

dispatchFakeEventに食わせるElementの取得

dispatchFakeEventの引数としてHtmlElementを要求されているので、その情報がほしいです。

locatorからElementの情報を取得できるので、それが利用できないか試してみました。

  async getAutoCompleteList(): Promise<MatOptionHarness[]> {
    const ctrl = await this.getAutoCompleteLocator();
    const inputElement: any = await ctrl.host(); // Element取得
    dispatchFakeEvent(inputElement, 'focusin'); // focus当てる
    return await ctrl.getOptions();
  }

しかし、host()の情報を利用するのはだめでした。

Elementの情報は取得できるのですが、HtmlElementそのものではないようで、dispatchEventが参照できずに終了します。

f:id:TakasDev:20200516190250p:plain

ここは本家よろしく、fixtureから直接取得する他なさそうです。

苦肉の策

fixtureから直接取得することにはしましたが、なるたけspecの方に実装したくはありません。

フォーカスを当てるのは、あくまでAutocompleteのリストを表示させてその値を参照したいからであって

そういった値を取得するまでのあれやこれやの処理はHarness側に寄せたいと思ったためです。

なので👇のように実装しました。

  async getAutoCompleteList(fixture: ComponentFixture<AutoCompleteComponent>): Promise<MatOptionHarness[]> {
    const ctrl = await this.getAutoCompleteLocator();
    const inputElement = fixture.debugElement.query(By.css('[data-testctl="autcompinput"]')).nativeElement;
    dispatchFakeEvent(inputElement, 'focusin');
    return await ctrl.getOptions();
  }

fixtureを引数でもらって、そこからElementを取得する力技です。

ベストプラクティスからは遠いかな?とも思いますが、これでうまくいくかどうか試してみます。

f:id:TakasDev:20200516224139p:plain

うまくいきました。

テストの実装はどうなったか

では、テスト本体の実装はどうなったのか見てみます。

テスト実行部分だけ抜粋します。

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

  it ('init option list', async () => {
    const datas = await harness.getAutoCompleteList(fixture);
    expect(datas.length).toEqual(10);
  });

  it ('filter option list', async () => {
    await harness.inputText('0');
    fixture.detectChanges();
    const datas = await harness.getAutoCompleteList(fixture);
    expect(datas.length).toEqual(1);
  });

  it ('option selection', async () => {
    await harness.inputText('');
    fixture.detectChanges();
    await harness.selectOption('0さん', fixture);
    const iputValue = await harness.inputTextValue();
    expect(iputValue).toEqual('0さん');
  });

  it ('CheckBoxToggle -> Input is Disabled', async () => {
    await harness.toggleCheckBox();
    const isDisabled = await harness.inputControlDisabledState;
    expect(isDisabled).toBeTruthy();
  });

ものすごくスッキリしました。

改めて見て「これがAngularのComponentのテストコードってマジ?」とか思いました。

まとめ

Angular MaterialのAutocompleteのComponentHarnessの使い方を見てきました。

実装した感覚、コード自体は普段と比べて書く量はそこまで変わらないような印象ですが

テストコード本体のスッキリ具合や

意図せずともテストの動作を個別に実装することができるようになるので

ソースコードの再利用性や可読性はぐっと上がるのではないかと思いました。

(ひょっとしたら再利用性のほうが恩恵でかいのでは…?と思ってたりもします。)

MaterialのComponentHarnessは最初から色々とコントロールを動かすためのAPIを用意してくれているので積極的に使っていきたいですね!

今回作成した諸々は下記レポジトリに格納しています。

ちょいちょいMatrialのComponentHarnessで実験して、ハマったら記事に上げようかと思います。。

github.com