こんにちは!フロントエンドエンジニアの平奥です!
これは 😺TECHSCORE Advent Calendar 2019😺の15日目の記事です。
最近 「HIGH OUTPUT MANAGEMENT」という書籍を読みました。インテルの元CEOのアンディ・グローブさんが書いた書籍です。初版は1984年に刊行されたのですが、今読んでも全く色褪せておらず、本当に30年以上も前に書かれた書籍なのかと感動すら覚えるほどでした。
内容に関してもミドルマネジャーがマネジメントするときに役に立つノウハウが書かれた書籍で、アウトプットを最大化するためにはどうすべきか、マネージャーがやるべき仕事は何なのかなど、日頃マネージャーが業務する上で一度は考えるであろう疑問や悩みを詳しい説明を交えながら解説してくれています。
その中で単体テストについての重要性を朝食を作る工場に例えて説明してくれている箇所があり、そこを読んだときに腑に落ちた感じがしたので、今回は単体テストについて書いてみようと思います。
業務では主に Angular を使っていますので、Angular の Component のテストコードに絞ってみようと思います。
Angularのバージョンは8を使っています。
Component のテストコード
以下に Component のサンプルコードを用意して、そのコンポーネントで実施するべきテストコードを記載します。
サンプルコード
synergy-test.component.ts
コンポーネントの実装ファイルです。
タイトルとボタンを表示して、ボタンを押した回数を表示するサンプルコンポーネントです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
import { Component, OnInit, Input, Output, EventEmitter } from '@angular/core'; @Component({ selector: 'app-synergy-test', templateUrl: './synergy-test.component.html', styleUrls: ['./synergy-test.component.scss'] }) export class SynergyTestComponent implements OnInit { @Input() title: string; @Input() buttonDisabled: boolean; @Output() clickEvent: EventEmitter<string> = new EventEmitter(); count: number; constructor() { } ngOnInit() { this.count = 0; } onClick() { this.count++; this.clickEvent.emit(`${this.count}`); } } |
synergy-test.component.html
サンプルコンポーネントの HTML です。
1 2 3 4 5 6 7 |
<div> <span class="sy-title">{{title}}</span> <span class="sy-count">{{count}} 回</span> <div *ngIf="!buttonDisabled"> <button class="sy-button" (click)="onClick()"></button> </div> </div> |
他にも SCSS ファイルなど必要なコードはありますが、説明には必要ないので割愛させていただきます。
テストコード
Input のテスト
1 2 3 4 5 6 7 |
it('Input title のテスト', () => { component.title = 'title をテスト'; fixture.detectChanges(); const element = fixture.debugElement.query(By.css('.sy-title')).nativeElement as HTMLSpanElement; expect(element.textContent).toBe('title をテスト'); }); |
Component の title に値を設定して、fixture.debugElement.query で要素を参照しています。
By.css メソッドを使って、単一の要素を取得しています。ここでは class のセレクターを利用して、title が設定された span 要素を取得しています。
expect(element.textContent) で設定した内容が要素に反映されているか確認しています。
これで Input に意図した内容が設定されているかどうかが確認できます。
Output clickEvent のテスト
1 2 3 4 5 6 7 8 9 10 11 12 |
it('Output clickEvent のテスト', () => { let count: string; component.buttonDisabled = false; component.clickEvent.subscribe(data => count = data); const button = fixture.debugElement.query(By.css('.sy-button')).nativeElement as HTMLButtonElement; button.click(); expect(count).toBe('1'); }); |
次は Output のテストです。
注意すべきは4行目の component.buttonDisabled = false; です。これがないとこのテストは成功しません。これはどういうことかというと、HTML のソースを見ていただければわかるのですが、これが true になっている場合、テストしようとしているボタンの要素が表示されていないため、要素の取得に失敗してしまいます。この例ではちょっと注意すればわかるのですが、実際に使われているコードではもっと複雑なため、分かりにくくちょっとしたハマリポイントになります。ですので、もし要素の取得に失敗した場合は、要素が表示されている状態になっているかどうか HTML を確認するとよいでしょう。
Component でイベントを受け取るため、component.clickEvent.subscribe を呼び出しています。あとは他のテストと同じようにボタン要素を取得し、click メソッドを呼び出しています。最後の行で clickEvent が実行されたかどうかを確認しています。
クリックイベント発火のテスト
1 2 3 4 5 6 7 8 9 10 11 12 13 |
it('クリックイベント発火のテスト', fakeAsync(() => { component.buttonDisabled = false; spyOn(component, 'onClick'); const btn = fixture.debugElement.query(By.css('.sy-button')); btn.triggerEventHandler('click', null); tick(); fixture.detectChanges(); expect(component.onClick).toHaveBeenCalled(); })); |
spyOn はメソッドが呼ばれた場合の結果を変えたい場合に使います。ここでは、呼び出されたかどうかを判定するために使用しています。 fixture.debugElement.query でボタンの要素を取得し、btn.triggerEventHandler でイベントを送信し、onClick メソッドが呼び出されたかどうかを判定しています。
fakeAsync は非同期の操作などを同期的に処理を行うことができるメソッドです。詳細はここを参照してください。ここでは tick メソッドでイベントを待ち、fixture.detectChanges で変更を検知しています。
これらの処理でクリックイベントの発火が行われているかのテストをしています。
スナップショットテスト
1 2 3 4 |
it('snapshot test', () => { const compiled = fixture.debugElement.nativeElement; expect(compiled).toMatchSnapshot(); }); |
最後はスナップショットのテストをしています。スナップショットとはある瞬間のシステムの状態を切り取りバックアップして、コード変更によってそのスナップショットと差異が出ていないかどうかを確認するテストです。ここでは UI 部分の変更を検知するために要素を取得しスナップショットと比較しています。スナップショットははじめに実行されたときにバックアップがなければ__SNAPSHOT__フォルダが作成され、ファイルが保存されます。そして次回以降はこのスナップショットと現在のスナップショットを比較し変更がないかをテストする仕組みとなっています。
テストコード全体
参考に上記で説明した内容を含んだテストコードすべてを以下に記載します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 |
import { async, ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { SynergyTestComponent } from './synergy-test.component'; describe('SynergyTestComponent', () => { let component: SynergyTestComponent; let fixture: ComponentFixture<SynergyTestComponent>; beforeEach(async(() => { TestBed.configureTestingModule({ declarations: [SynergyTestComponent] }) .compileComponents(); })); beforeEach(() => { fixture = TestBed.createComponent(SynergyTestComponent); component = fixture.componentInstance; fixture.detectChanges(); }); it('生成テスト', () => { expect(component).toBeTruthy(); }); it('Input title のテスト', () => { component.title = 'title をテスト'; fixture.detectChanges(); const element = fixture.debugElement.query(By.css('.sy-title')).nativeElement as HTMLSpanElement; expect(element.textContent).toBe('title をテスト'); }); it('クリックイベント発火のテスト', fakeAsync(() => { component.buttonDisabled = false; spyOn(component, 'onClick'); const btn = fixture.debugElement.query(By.css('.sy-button')); btn.triggerEventHandler('click', null); tick(); fixture.detectChanges(); expect(component.onClick).toHaveBeenCalled(); })); it('Output clickEvent のテスト', () => { let count: string; component.buttonDisabled = false; component.clickEvent.subscribe(data => count = data); const button = fixture.debugElement.query(By.css('.sy-button')).nativeElement as HTMLButtonElement; button.click(); expect(count).toBe('1'); }); it('snapshot test', () => { const compiled = fixture.debugElement.nativeElement; expect(compiled).toMatchSnapshot(); }); }); |
まとめ
いかがでしたでしょうか。公式サイトを見ればだいたいは分かるのですが、他の言語などでもテストコードを書くときは結構ハマる部分があり、そのあたりのナレッジを貯めてないとすんなり書けないのですが、 Angular に関してはすんなり書けました。とはいっても多少ハマる部分はありましたが…。それではまた!