【Angular】MatDialogRef.afterClosed.subscribe()内のテストコードを書きたい

## はじめに
例えばMatDialogを閉じたあとの処理を実装するとき、

```typescript:example.component.ts
public onDialogOpen() {
	const dialogRef = this.dialog.open(ExampleDialogComponent);
	
	dialogRef.afterClosed().subscribe(async (bool: boolean) => {
		if (!bool) return;
		
		try {
			await doAsyncFunc();
			this.router.navigate(['/path']);
		} catch(e) {
			this.errorHandler(e);
		}
	});
}
```
こんな感じになると思うが、これをテストコードで書くとき`onDialogOpen()`自体の処理は良いものの、このメソッド内の`Observable.subscribe()`内の処理はどうしたら書けるのだろうと長い時間詰まってしまった。(主に非同期処理系)
が、ようやくその解決方法を見つけたので(これが正しいのかわからないが一応…)共有する。
※今回の解決方法の他に良いものがあったらぜひぜひ教えてくださいm(_ _)m

## 注意事項
- 今回行うテストはDOMのテストではなく **Componentクラスのカバレッジテスト**
- テストツールはJestを使用


## そもそも`subscribe()`内のメソッドを別に分ければ良い話だった
どうしてそこに気がつかなかったのか…。という話は置いておいて参考リンク↓
[afterClosedマットダイアログサブスクリプション内でイベントエミッターをテストするにはどうすればよいですか？](https://tutorialmore.com/questions-1989041.htm)

上記のリンク先の「解決した方法１」のところに、

> これを行う最も簡単な方法は、私の意見では、afterClosedコールバックを関数にカプセル化し、それを直接呼び出すことで単体で単体テストすることです。

> ダイアログを閉じるときにすべてが正常に行われていることを確認する場合は、サイプレスなどのテストスーツを使用して、エンドツーエンドテストでこれをテストする必要があります。

あぁ…あぁ…。おっしゃる通りで…。

## 実際に書いて試してみる
ちなみに、既にテストしやすいようにさっきの修正を加えた。
`onSignOutDialogClose()`というメソッドの処理がもともと`DialogRef.afterClosed().subscribe()`内で行われていた処理。

今回テスト対象のメソッドは`onSignOut()`メソッド。
このメソッドが呼ばれると確認ダイアログが開く。そのダイアログのOKボタンを押すとサインアウト処理が実行され、Candelボタンが押されるとダイアログを閉じ何もしない。
サインアウト処理では成功すれば`Router.navigate`が呼ばれてログイン画面に戻る。
サインアウト処理で例外が吐かれた場合は、その時のmessageの中身によってエラーハンドリングをするという処理になっている。

※ソースコードのリポジトリは[こちら](https://github.com/m-Suda/angular-component-test-example)

```typescript:home.component.ts
import { Component } from '@angular/core';
import { Observable } from 'rxjs';
import { Router } from '@angular/router';
import { MatDialog } from '@angular/material/dialog';
import { User, UserListService } from '../../core/services/user-list.service';
import { SignOutService } from '../../core/authentication/sign-out.service';
import { SignOutConfirmDialogComponent } from './sign-out-confirm-dialog/sign-out-confirm-dialog.component';

@Component({
    selector: 'app-home',
    templateUrl: './home.component.html',
    styleUrls: ['./home.component.scss'],
})
export class HomeComponent {

    constructor(
        private user: SignOutService,
        private router: Router,
        private dialog: MatDialog
    ) { }

    public async onSignOut(): Promise<void> {
        const signOutConfirmDialog = this.dialog.open(SignOutConfirmDialogComponent, {
            width: '250px',
            autoFocus: false,
        });

        signOutConfirmDialog.afterClosed().subscribe(r => this.onSignOutDialogClose(r));
    }

    private async onSignOutDialogClose(signOut: boolean) {
        if (!signOut) return;

        try {
            await this.user.signOut();
            console.log('SignOut');
            await this.router.navigate(['/']);
        } catch (e) {
            this.errorHandler(e);
        }
    }

    private errorHandler({ message }): void {
        switch (message) {
            case '418':
                return this.errorTeapot();
            default:
                return this.errorOther();
        }
    }

    private errorTeapot(): void {
        console.log(`I'm a teapot`);
    }

    private errorOther(): void {
        console.log(`Other Error`);
    }
}
```

## テストコードを書いてみる
出来上がったテストコードはこちら。
長いので次の見出しからいくつかポイントを説明していく。

```typescript:home.component.spec.ts
import { Router } from '@angular/router';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { MatTableModule } from '@angular/material/table';
import { MatButtonModule } from '@angular/material/button';
import { HomeComponent } from './home.component';
import { UserListService } from '../../core/services/user-list.service';
import { SignOutService } from '../../core/authentication/sign-out.service';

describe('HomeComponent', () => {
    let component: HomeComponent;
    let fixture: ComponentFixture<HomeComponent>;

    const routerFuncNavigateSpy = jest
        .spyOn(Router.prototype, 'navigate')
        .mockImplementation(async () => true);
    const signOutServiceFuncSingOutSpy = jest.spyOn(SignOutService.prototype, 'signOut');
    const componentFuncErrorTeapotSpy = jest
        .spyOn((HomeComponent as any).prototype, 'errorTeapot')
        .mockImplementation(() => console.log('Error Teapot mock'));
    const componentFuncErrorOtherSpy = jest
        .spyOn((HomeComponent as any).prototype, 'errorOther')
        .mockImplementation(() => console.log('Error Other mock'));

    beforeEach(async(() => {
        TestBed.configureTestingModule({
            declarations: [HomeComponent],
            imports: [MatTableModule, MatButtonModule, MatDialogModule],
            providers: [
                UserListService,
                MatDialog,
                {
                    provide: Router,
                    useClass: class {
                        navigate = routerFuncNavigateSpy;
                    },
                },
                SignOutService,
            ],
        }).compileComponents();
    }));

    beforeEach(() => {
        fixture = TestBed.createComponent(HomeComponent);
        component = fixture.debugElement.componentInstance;
    });

    afterEach(() => {
        routerFuncNavigateSpy.mockClear();
        signOutServiceFuncSingOutSpy.mockClear();
        componentFuncErrorTeapotSpy.mockClear();
        componentFuncErrorOtherSpy.mockClear();
    });

    test('ログアウトポップアップでOKが押されログアウト処理に問題がない時Router.navigateが呼ばれるかテスト', async () => {
        signOutServiceFuncSingOutSpy.mockImplementation(async () =>
            console.log('SignOut Mock called')
        );

        await (component as any).onSignOutDialogClose(true);

        expect(signOutServiceFuncSingOutSpy).toHaveBeenCalled();
        expect(routerFuncNavigateSpy).toHaveBeenCalled();
    });

    test('ログアウトポップアップでOKが押されたがログアウト処理で問題が発生し、StatusCode418返されたときのテスト', async () => {
        signOutServiceFuncSingOutSpy.mockImplementation(async () => {
            throw new Error('418');
        });

        await (component as any).onSignOutDialogClose(true);

        expect(signOutServiceFuncSingOutSpy).toHaveBeenCalled();
        expect(routerFuncNavigateSpy).not.toHaveBeenCalled();
        expect(componentFuncErrorTeapotSpy).toHaveBeenCalled();
        expect(componentFuncErrorOtherSpy).not.toHaveBeenCalled();
    });

    test('ログアウトポップアップでOKが押されたがログアウト処理で問題が発生し、StatusCode418以外返されたときのテスト', async () => {
        signOutServiceFuncSingOutSpy.mockImplementation(async () => {
            throw new Error('500');
        });

        await (component as any).onSignOutDialogClose(true);

        expect(signOutServiceFuncSingOutSpy).toHaveBeenCalled();
        expect(routerFuncNavigateSpy).not.toHaveBeenCalled();
        expect(componentFuncErrorTeapotSpy).not.toHaveBeenCalled();
        expect(componentFuncErrorOtherSpy).toHaveBeenCalled();
    });

    test('ログアウトポップアップでCancelが押されログアウト処理が行われないかテスト', async () => {
        signOutServiceFuncSingOutSpy.mockImplementation(async () =>
            console.log('SignOut Mock Called')
        );

        await (component as any).onSignOutDialogClose(false);

        expect(signOutServiceFuncSingOutSpy).not.toHaveBeenCalled();
        expect(routerFuncNavigateSpy).not.toHaveBeenCalled();
    });
});
```

## 呼び出し判定したいメソッドのmockをあらかじめ取得
今回は`onSignOutDialogClose`のテストをしたいので、その中でmockに置き換えたいものをそれぞれ作成した。
`routerFuncNavigateSpy`はAngular標準のRouterクラスの`navigate`メソッドをmock
`signOutServiceFuncSingOutSpy`はSignOut処理をする自作のServiceクラスのメソッドをmock
`componentFuncErrorTeapotSpy`と`componentFuncErrorOtherSpy`はそれぞれサインアウト失敗時、エラーハンドリングで呼び出されるメソッドをmock

それぞれのmock関数が呼び出されたか、呼び出されていないかで判定をする。
ちなみにspyOnの第一引数に指定するところは`Router.prototype`のように指定する必要がある。(これは正直よくわかっていない…。いろいろ調べた結果こうだとできた。その参考リンクたちはどこかおなくなりに…。)

```typescript:home.component.spec.ts
const routerFuncNavigateSpy = jest
    .spyOn(Router.prototype, 'navigate')
    .mockImplementation(async () => true);
const signOutServiceFuncSingOutSpy = jest.spyOn(SignOutService.prototype, 'signOut');
const componentFuncErrorTeapotSpy = jest
    .spyOn((HomeComponent as any).prototype, 'errorTeapot')
    .mockImplementation(() => console.log('Error Teapot mock'));
const componentFuncErrorOtherSpy = jest
    .spyOn((HomeComponent as any).prototype, 'errorOther')
    .mockImplementation(() => console.log('Error Other mock'));
```

また`Router`をそのまま`TestBed.configureTestingModule`の`providers`に追加しても、

```
Can't resolve all parameters for Router: (?, ?, ?, ?, ?, ?, ?)
```
こんな感じのエラーが出てうまく行かない。
そういう時は、

```typescript:home.component.spec.ts
    beforeEach(async(() => {
        TestBed.configureTestingModule({
            declarations: [HomeComponent],
            imports: [MatTableModule, MatButtonModule, MatDialogModule],
            providers: [
                UserListService,
                MatDialog,
                {
                    provide: Router,
                    useClass: class {
                        navigate = routerFuncNavigateSpy;
                    },
                },
                SignOutService,
            ],
        }).compileComponents();
    }));
```
このように指定する必要がある。

## テストの内容
そんなに難しいことはやっていなく、mock化した関数の値を決めテスト対象のメソッドを実行、指定されたmockが呼ばれたか・呼ばれてないかを確認するだけ。
例えば正常処理の場合であれば、

```typescript:home.component.spec.ts
test('ログアウトポップアップでOKが押されログアウト処理に問題がない時Router.navigateが呼ばれるかテスト', async () => {
    // Resolveを返すよう設定
    signOutServiceFuncSingOutSpy.mockImplementation(async () =>
        console.log('SignOut Mock called')
    );

    // テスト対象のメソッドを実行
    await (component as any).onSignOutDialogClose(true);

    // signOutが呼ばれたかチェック
    expect(signOutServiceFuncSingOutSpy).toHaveBeenCalled();
    // Router.navigateが呼ばれたかチェック
    expect(routerFuncNavigateSpy).toHaveBeenCalled();
});
```

異常系であれば、

```typescript:home.component.spec.ts
test('ログアウトポップアップでOKが押されたがログアウト処理で問題が発生し、StatusCode418返されたときのテスト', async () => {
    // {message: '418'}のErrorオブジェクトをRejectする設定
    signOutServiceFuncSingOutSpy.mockImplementation(async () => {
        throw new Error('418');
    });
    // テスト対象を実行
    await (component as any).onSignOutDialogClose(true);

    // SignOutが呼ばれたかチェック
    expect(signOutServiceFuncSingOutSpy).toHaveBeenCalled();
    // Router.navigateが呼ばれていないかチェック
    expect(routerFuncNavigateSpy).not.toHaveBeenCalled();
    // errorTeapot()が呼ばれたかチェック
    expect(componentFuncErrorTeapotSpy).toHaveBeenCalled();
    // errorTeapot()が呼ばれてないかチェック
    expect(componentFuncErrorOtherSpy).not.toHaveBeenCalled();
});

test('ログアウトポップアップでOKが押されたがログアウト処理で問題が発生し、StatusCode418以外返されたときのテスト', async () => {
    // {message: '500'}のErrorオブジェクトをRejectする設定
    signOutServiceFuncSingOutSpy.mockImplementation(async () => {
        throw new Error('500');
    });

    // テスト対象を実行
    await (component as any).onSignOutDialogClose(true);

    // SignOutが呼ばれたかチェック
    expect(signOutServiceFuncSingOutSpy).toHaveBeenCalled();
    // Router.navigateが呼ばれていないかチェック
    expect(routerFuncNavigateSpy).not.toHaveBeenCalled();
    // errorTeapot()が呼ばれてないかチェック
    expect(componentFuncErrorTeapotSpy).not.toHaveBeenCalled();
    // errorTeapot()が呼ばれたかチェック
    expect(componentFuncErrorOtherSpy).toHaveBeenCalled();
});
```

のようになる。
ちなみにテスト対象の実行の部分、

```typescript:home.component.spec.ts
await (component as any).onSignOutDialogClose(true);
```

のようにしているのはテスト対象メソッドがコンポーネントクラスのprivateメソッドだから。
privateメソッドをテストコードで実行したい場合はこのように指定する。
参考： [Typescriptでprivateメソッドをテストする](https://qiita.com/hitochan777/items/f68e77f13becd24e2691#how)

## 最後に
「テストコードが書きづらいな？」と少しでも思ったら、クラスやメソッドの設計を見直したほうが良いなと思った…。
逆にテストコードを書くということは「設計の悪さ」を気づかせてくれるのかもしれない。