Angularの単体テストの書き方試行錯誤したのを残しておく 2 - mat-dialog(Angular Material)のテスト

<H1>はじめに</H1>
[前回の記事](https://qiita.com/eguchiryo_tg/items/f71bda926a2350eff599)でJasmine/Karmaを用いた単体テストの基本的なところについて投稿しましたが、その後ようやくUIフレームワーク、Angular Materialでのテストの書き方がわかったので投稿します。

<H1>前提</H1>
フロントエンドのアプリケーションは以下のようなものです。(前回と同様)
 - Angular 9
 - Jasmine/Karma (Angular CLIでのインストール時のデフォルト)
 - UIフレームワーク: angular material

<H2>テスト対象のソースコード</H2>
ここでは、ベースとなるページコンポーネントと、そこからボタン押下によって表示されるダイアログコンポーネントの２つを持つソースコードで考えます。  

```hoge.component.html
 <button (click)="openDialog()">click</button>
```

```hoge.component.ts
import { Component, OnInit } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
...
export class HogeComponent implements OnInit {

  constructor(public dialog: MatDialog) { }

  ngOnInit(): void {
  }

  openDialog(){
    // ダイアログを開く処理
    const dialogRef = this.dialog.open(dialogComponent);

    // ダイアログを閉じた後の処理
    dialogRef.afterClosed().subscribe(result => {
         // ダイアログから送られてきたパラメータを元に処理を分岐
         if(result) {
           this.ok();
         } else {
           this.cancel();
         }
    });
  }
  ok() {
     console.log(`ok clicked`);
  }
  cancel() {
     console.log(`cancel clicked`);
  }

}
```

```dialog.component.html
<h1 mat-dialog-title>タイトル</h1>
<div mat-dialog-content>
    <span>dialog content</span>
</div>
<div mat-dialog-actions>
  <button (click)="OK()">OK</button>

  <button (click)="close()">cancel</button>
</div>
```

```dialog.component.ts
import { MatDialogRef } from '@angular/material/dialog';
...
export class dialogComponent implements OnInit {
  constructor(
    public dialogRef: MatDialogRef<dialogComponent>
  ) {}

  // OK押下時 パラメータtrueを送ってダイアログを閉じる
  ok(): void {
    this.dialogRef.close(true);
  }

  // close押下時 パラメータを送らずダイアログを閉じる
  close(): void {
    this.dialogRef.close();
  }
```

<H1>各種テストケース</H1>
前回の記事でボタンクリック時イベント自体についてのテスト方法は書いているので、ここでは私が分からず困っていた以下のケースに限定します。
 - hogeコンポーネントのopenDialog()関数がdialogのopen処理を呼び出すこと
 - dialogコンポーネントでOKボタンクリックにより閉じたときに、hogeコンポーネントのok()が実行されること
 - dialogコンポーネントでキャンセルボタンクリックにより閉じたときに、hogeコンポーネントのcancel()が実行されること

<H3>hogeコンポーネント上でのボタンクリックでdialogのopen処理が呼ばれること</H3>
考え方としては、
> open()関数のスパイを作り、呼び出されたことを確認する

ということになります。

<H4>手順1：今まで通りのテスト記述</H4>
前回の記事のようにスパイの使い方に従うと以下のようになります。

```hoge.component.spec.ts
it('openDialog()関数がdialogのopen処理を呼び出すこと', () => {
  const spyObject = spyOn(component.dialog, 'open'); //スパイの作成。dialog(MatDialog)が提供するopen()関数をスパイする

  component.openDialog(); // テスト対象メソッドの実行

  expect(spyObject).toHaveBeenCalled(); // スパイのメソッドが呼ばれた(toHaveBeenCalled)ことの確認
});
```

ですが、これでは以下のようなエラーが出ます
> NullInjectorError: R3InjectorError(DynamicTestModule)[MatDialog -> MatDialog]: 
  NullInjectorError: No provider for MatDialog!

<H4>手順2：Providerのimport</H4>
No provider for MatDialogの文言に従い、beforeEach(async())を修正します。

```hoge.component.spec.ts
import { MatDialog } from '@angular/material/dialog'; // importの追加
...
  beforeEach(async(() => {
    TestBed.configureTestingModule({
      declarations: [HogeComponent],
      providers: [ // Providerの追加
        {
          provide: MatDialog
          useValue: {}
        }
      ]
    }).compileComponents();
  }));
```

これで解消したかと思いきや、
> Error: <spyOn> : open() method does not exist
Usage: spyOn(<object>, <methodName>)

と怒られます。


<H4>手順3 モックダイアログクラスの作成</H4>
`component.dialog`の`open()` が呼び出しできないと言うことなので、 ダミーの`open()処理`を持つモックが必要になります。

```hoge.component.spec.ts
import { MatDialog } from '@angular/material/dialog'; 
...
// 追加: テスト用のモックダイアログ
class MatDialogMock {
  open() {
    return 'hoge'; // open()が呼べればいいので、ここでは単なる文字列を返すだけにする
  }
}

describe('hogeComponent', () => {
 ...
 let dialog: MatDialogMock;   //追加: ダイアログ用の変数定義

  beforeEach(async(() => {
    TestBed.configureTestingModule({
      declarations: [HogeComponent],
      providers: [ // Providerの追加
        {
          provide: MatDialog
          useClass: MatDialogMock // 変更: MatDialogとして作成したモックダイアログを使うようにする
        }
      ]
    }).compileComponents();
  }));
  beforeEach(() => {
    fixture = TestBed.createComponent(HogeComponent);
    component = fixture.componentInstance;
    dialog = TestBed.get(MatDialog);     // 追加: 依存関係の注入
    fixture.detectChanges();
  });  
it('openDialog()関数がdialogのopen処理を呼び出すこと', () => {
  const spyObject = spyOn(dialog, 'open'); //スパイの作成。dialog(MatDialog)が提供するopen()関数をスパイする

  component.openDialog(); // テスト対象メソッドの実行

  expect(spyObject).toHaveBeenCalled(); // スパイのメソッドが呼ばれた(toHaveBeenCalled)ことの確認
});
```

これで、spyのdialogはMatDialogMockを、そしてスパイするメソッドのopen()はそのモックのopen()関数を呼び出すようになりました。
これでテストを回してみると、無事成功するはずです。
![スクリーンショット 2020-10-12 10.11.34.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/562918/006f78b1-c120-8535-f552-69d8f8ad1ebc.png)

<H3>dialogコンポーネントでOKボタンクリックにより閉じたときに、hogeコンポーネントのok()が実行されること</H3>
続いてはダイアログでの操作を行った後の挙動です。 hoge.component.tsの通り、ダイアログを閉じた時のパラメータをsubscribeし、その後の処理が行われていきます。ですので、OKボタンが押されたというのを実現する必要があります。
もちろんこれはコンポーネント単位での単体テストですので、 hoge.component内で完結させなければなりません。

<H4>手順1 afterClosedのダミー関数呼び出し</H4>
mat-dialogではafterClosed()関数でパラメータをsubscribeしているので、そのダミー関数を作ります。

```hoge.component.spec.ts
import { MatDialog } from '@angular/material/dialog'; // importの追加
...
// 追加: テスト用のモックダイアログ
class MatDialogMock {
  open() {
    return { // 変更: open関数呼び出しとともに、afterClosedを実行する
      afterClosed: () => of(true) // OKを押したケースをテストするので固定でtrueを返却する
    };
  }
}
...

```

<H4>手順2 ダミーの呼び出しとテスト</H4>
ダミー関数を追加したので、実際のテストケースの中でそれを使ってみます。


```hoge.component.spec.ts
...
  it('ダイアログをOKで閉じた後、OK関数が呼び出されること', () => {

    //スパイの作成
    // callThroughにすることで実際にafterClosed()まで呼び出される
    const spyObject = spyOn(dialog, 'open').and.callThrough();

    // メソッドのスパイ
    const OKspy = spyOn(component, 'ok');

    component.openDialog(); // テスト対象メソッドの実行
  
    expect(spyObject).toHaveBeenCalled(); // スパイのメソッドが呼ばれた(toHaveBeenCalled
    expect(OKspy).toHaveBeenCalled(); // OK関数が呼ばれた( toHaveBeenCalled
  })  
```

これで、 ダイアログを開く => OKボタンを押して閉じる、の動きがspyによって実現され、テストができるようになります。

<H3>dialogコンポーネントでキャンセルボタンクリックにより閉じたときに、hogeコンポーネントのcancel()が実行されること</H3>
考え方としてはOKボタンクリックの時と同じです。
ただし、先ほどの手順で作ったMatDialogMockの`afterClosed()`関数は固定でtrueを返すので、同じようにopen関数のスパイを呼び出すのでは、OKボタンクリックと同じ動きをしてしまいます。
それを解消するためには、spyで返す値を変更できる `returnValue()`を用います。

```hoge.component.spec.ts
  it('ダイアログをキャンセルで閉じた後、cancel関数が呼び出されること', () => {

    // returnValue()で、afterClosedがnullを返すようにする
    const spyObject = spyOn(dialog, 'open').and.returnValue({
      afterClosed: () => of(null)
    });

    // メソッドのスパイ
    const Cancelspy = spyOn(component, 'cancel');

    component.openDialog(); // テスト対象メソッドの実行
  
    expect(spyObject).toHaveBeenCalled(); // スパイのメソッドが呼ばれた(toHaveBeenCalled
    expect(Cancelspy).toHaveBeenCalled(); // cancel関数が呼ばれた( toHaveBeenCalled
  })   
```

<H1>完成版</H1>

```hoge.component.spec.ts
import { async, ComponentFixture, TestBed } from '@angular/core/testing';

import { HogeComponent } from './hoge.component';
import { MatDialog } from '@angular/material/dialog';
import { of } from 'rxjs';
// テスト用のモックダイアログ
class MatDialogMock {
  open() {
    return {
      afterClosed: () => of(true)
    };
  }
}


describe('hogeComponent', () => {
  let component: HogeComponent;
  let fixture: ComponentFixture<HogeComponent>;
  let dialog: MatDialogMock;  

  beforeEach(async(() => {
    TestBed.configureTestingModule({
      declarations: [HogeComponent],
      providers:[
        {
          provide: MatDialog,
          useClass: MatDialogMock
        }
      ]      
    }).compileComponents();
  }));

  beforeEach(() => {
    fixture = TestBed.createComponent(HogeComponent);
    component = fixture.componentInstance;
    dialog = TestBed.get(MatDialog);    
    fixture.detectChanges();
  });


  it('openDialog()関数がdialogのopen処理を呼び出すこと', () => {
    const spyObject = spyOn(dialog, 'open').and.callThrough(); //スパイの作成。dialog(MatDialog)が提供するopen()関数をスパイする
    component.openDialog(); // テスト対象メソッドの実行
  
    expect(spyObject).toHaveBeenCalled(); // スパイのメソッドが呼ばれた(toHaveBeenCalled
  })  
  it('ダイアログをOKで閉じた後、OK関数が呼び出されること', () => {
    // const spyObject = spyOn(dialog, 'open').and.returnValue({
    //   afterClosed: () => of(true)
    // });
    const spyObject = spyOn(dialog, 'open').and.callThrough();

    // メソッドのスパイ
    const OKspy = spyOn(component, 'ok');

    component.openDialog(); // テスト対象メソッドの実行
  
    expect(spyObject).toHaveBeenCalled(); // スパイのメソッドが呼ばれた(toHaveBeenCalled
    expect(OKspy).toHaveBeenCalled(); // OK関数が呼ばれた( toHaveBeenCalled
  });
  it('ダイアログをキャンセルで閉じた後、cancel関数が呼び出されること', () => {

    const spyObject = spyOn(dialog, 'open').and.returnValue({
      afterClosed: () => of(null)
    });

    // メソッドのスパイ
    const Cancelspy = spyOn(component, 'cancel');

    component.openDialog(); // テスト対象メソッドの実行
  
    expect(spyObject).toHaveBeenCalled(); // スパイのメソッドが呼ばれた(toHaveBeenCalled
    expect(Cancelspy).toHaveBeenCalled(); // cancel関数が呼ばれた( toHaveBeenCalled
  })    
});

```

![スクリーンショット 2020-10-12 10.45.16.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/562918/92d96a1e-3b02-81cd-694f-f574219ccd16.png)


<H1>おわりに</H1>
私が詰まったのは、モックダイアログクラスの作成が必要という部分でした。すでに providersにMatDialogを追加したのだから `component.dialog`の`open`関数をspyできていいものだと思っていたためです。
このあたり、単体テストは本当に依存関係とかが面倒です。


この記事によって、同じように困る人が減ることに期待しています。