Angular でテストコードの書き方を纏めました
Angular でテストする方法についてまとめました。 jasmine(ジャスミン)というテストフレームワークが用意されています。Karma(カルマ)というテストランナーが標準で用意されています。
Angular はJasmine + Karmaでテストコードを書けるように標準装備されているようです。
Angular では~spec.tsというファイルがデフォルトで用意され、このファイルがテストファイルになります。
テストスイートはdescribe,一つのテストはitで書きます。 テストを実行するには
npm run test
または
npm test
で実行します。(ng test) npm scriptsは、start,stop,testの場合のみrunを省略する事ができます。
このテストはスキップしたい、という場合はxdescribe,xitなどというように最初にxをつけます。
逆にこのテストのみ実行したい、という場合はfdescribe,fitというように最初にfをつけます。
skip,onlyのほうがわかりやすいですね、、。fはおそらくfocus,xはわかりません。 beforeEachメソッドとafterEachメソッド 各itのテストの前に実行されるbeforeEachメソッド、afterEachメソッドが用意されています。 Matcher の種類 Matcher は豊富に用意されています。基本的には以下のように記述します。
expect(実際値).Matcher(期待値)
Matcher によっては以下のように書きます。例えばtoBeTruthy()のようなMatcher の場合です。
expect(実際値).toBeTruthy()
主なMatcher です。
Matcher | 検証 |
---|---|
toBe | 同一オブジェクトかどうか |
toEqual | 同一値かどうか |
toBeTruthy | trueかどうか |
toBeFalsy | falseかどうか |
toBeNull | nullかどうか |
toBeNaN | NaNかどうか |
toThrow | 例外が発生するかどうか |
toThrowError | 例外が発生するかどうか |
toContain | 実効値が期待値に含まれているかどうか |
toBeLessThan | 実効値が期待値より大きいかどうか |
toBeGreaterThan | 実効値が期待値より大きいかどうか |
toBeDefined | undefinedではないこと |
toBeUndefined | undefinedであること |
toHaveBeenCalled | メソッドが実行されたこと |
toHaveBeenCalledWith | メソッドが実行されたこと(引数チェックも行える) |
否定形も使えて、その場合はnot.MatcherとすればOKです。
expect(xxx).not.ToEqual(yyy)
サービスやパイプのテストは簡単ですが、コンポーネントのテストが大変です。 デフォルトで用意されているapp.component.spec.tsファイルを見るとよくわかると思います。
beforeEach(async(() => { TestBed.configureTestingModule({ declarations: [ AppComponent // ここにテストコンポーネントを追加する ], }).compileComponents(); }));
beforeEachメソッドでTestBed.configureTestingModuleメソッドでテストコンポーネントを定義します。
TestBed.createComponentメソッド このメソッドでコンポーネントのインスタンスを作成します。
fixture = TestBed.createComponent(AppComponent);
fixture.detectChangesメソッド このメソッドが、コンポーネントの変更を検知する重要なメソッドになります。 @Injectableデコレータが付いている物に関してはbeforeEachでDIすることが可能です。 以下記述例です。
let xxSrv: XxxService; beforeEach (()=> { fixture = TestBed.createComponent(AppComponent); xxSrv = fixture.debugElement.injector.get(XxxService); fixture.detectChange(); }
サービスをモックしたい場合にはスパイを使用する コンポーネントによってはサービスに依存すると思います。
こういった場合はスパイでモックし、コンポーネントとしての動作を担保します。
サービスはサービスで動作を担保すればよいわけです。
サービスのメソッドをモック(スパイ)するには、spyOnを使います。
spyOn(サービスのインスタンス, 'メソッド名').and.returnValue(Promise.resolve(JSON形式とか?))
だいたい上記のような書き方になるかと思います。
サービスのメソッドをspyOnするということは非同期になりますのでasync/awaitを使いましょう。
it('テスト名1111-11-11', async (done: DoneFn) => { await component.onClick(); expect(~).toEqual(~); done(); // 終了を知らせる });
スパイしたメソッドから意図的にthrowする spyOnでスパイしたメソッドからthrowさせることができます。
spyOn(インスタンス, 'メソッド名').and.throwError('例外発生');
これで例外を発生させることができます。
エラーでもカスタムエラークラスなどを作成していて、そのカスタムクラスを返したい場合はthrowErrorではなく、callFakeで返します。CustomErrorクラスとします。
spyOn(インスタンス, 'メソッド名').and.callFake( ()=>{throw new CustomError();});
関数を定義してカスタムクラスをthrowして返してあげればcatch句に入ります。
プライベートメソッドはspyOnできませんので、プライベートメソッドをthrowさせたい場合は別の方法で実現させる必要があります。
プライベートメソッドでthrowさせる方法 プライベートメソッドでthrowさせるには、以下のように関数を代入します。
component['プライベートメソッド'] = () => { throw new Error(); };
ちなみにspyOnの戻り値はjasmine.Spyです。
let spy: jsmine.Spy = spyOn(instance,'getSuperData');
テストコードからプライベート変数、プライベートメソッドにアクセスする
コンポーネントにprivate修飾子をつけていると、component.~~としてアクセスすることができません。以下の記述方法でアクセスする必要があります。
component['変数名'] = true; // 変数にtrueを代入 component['メソッド名'] = () => true; // trueを返すメソッドを代入 component['メソッド名'] = () => []; // 配列を返すメソッドを代入 component['メソッド名'] = async () => {await Promise.resolve();}; // 非同期処理の関数を代入
上記はprivateメソッドをモックしたようなイメージです。privateメソッドとprotectedメソッドのアクセス方法は同じです。
そうではなく、privateメソッドを実行したい場合は以下のように()をつけます。
component['メソッド名']();
これでprivateメソッドを実行することができます。
さらにモックしたprivateメソッドを実行するには以下のように記述することで、実行が可能です。いずれも()を付ける必要があります。
const func = component['メソッド名'] = () => '戻り値'; expect(func()).toEqual( '戻り値' );
privateメソッドをspyOnする方法
privateメソッドをspyOnすることができます。spyOnする場合にインスタンス名 as anyとすることによってpublicメソッドだけでなくprivateメソッドもspyすることができるようになります。
以下、記述例です。
spyOn(component as any, 'プライベートメソッド' );
spyOnしてもメソッドの実際の実装は実行する
spyOnされたメソッドは実行はされないため、カバレッジがグリーンになりません。
これだとカバレッジ網羅率が上がらないため、spyOnしながら実際の実装を実行するようにすることができます、and.callThrough()メソッドを使用します。以下、記述例です。
spyOn(component, 'パブリックメソッド').and.callThrough();
これでメソッドが呼ばれたことも確認できますし、カバレッジもグリーンになります。
戻り値の型を確認する
jasmine.anyを使用することによって戻り値の型を確認することができます。
以下はPromiseオブジェクトであることを確認しています。
const ret = component['メソッド名'] = async => {await Promise.resolve();}; expect( ret() ).toEqual( jasmine.any( Promise ) );
この結果は成功します。文字列の場合は、jasmine.any( String )とします。
オブジェクトの場合は、jasmine.any( Object )とします。
ブレークポイントを貼ってテストをデバッグする
karma.conf.jsやlaunch.jsonを編集すればできるみたいですが、まだ調査中です。 2018/07/11追記 色々調べていると、VSCodeでコード中に、debugger
というキーワードをタイプすればデバッグできるようです。 tslintをしているとエラーか警告になりますが、コメントで回避すればよいです。 テストコード(~.spec.ts)内に記述し、テストを実行するとVSCode上で止まってくれます。
// tslint:disable-next-line:no-debugger debugger;
正確には以下の手順です。
- ブレークしたい箇所に
debugger
とタイプする - npm testを実行する
- デバッグを開始し、デバッグパネルを表示する
これでテストコードもデバッグすることができるようになりました。
ブレークポイントで止まってしまえば、そのあとは、F8やF5が使えます。ウォッチなども可能です。
日付はnew Date()しない方が良い
new Date()だと、テストする日によってテスト結果が変わる可能性があるので、日付を指定したほうが良いです。beforeEach()メソッド内か各it内に追加しておくと便利です。
const baseTime = new Date(2018, 8, 7); jasmine.clock().mockDate(baseTime);
カバレッジについては「Angular でカバレッジレポートを出力する」を参照ください。
privateインスタンスのprivateインスタンスのpublic変数をモックする
変数は配列とします。例えば空の配列でモックします。
component['インスタンス名']['インスタンス名'].変数名 = [];
このように記述することで階層が深くなっても関係なくモックすることが可能です。
JQuery<HTMLElement>型の変数を作成する
テストしたいイベントハンドラの引数がJQuery<HTMLElement>型の場合、無理やり作ってみました。
const el = <HTMLElement>document.createElement('ul'); const elem = :JQuery = $(el); // elemがJQuery<HTMLElement>型になる
jQueryオブジェクトのonメソッドはtriggerメソッドで発火させる
jQueryオブジェクト.on('change', ()=>{〜});
上記のようなコードは、jQuery.trigger('change');
でカバレッジを通す事が可能です。
onメソッドで第二引数にセレクタが指定してある場合は、triggerメソッドでカバレッジを通す事が出来ませんでした。誰か教えて下さい、、。
toHaveBeenCalled()でメソッドを呼ばれていることを確認するにはspyOnしておく必要がある
toHaveBeenCalled()でメソッドが呼ばれていることを確認しようとするとエラーが出てUsageが表示されます。
Usageを見るとspyObjでないとtoHaveBeenCalledは使えないようです。ということでテストの最初にspyOnしておきます。
spyOn(component.インスタンス, 'メソッド名'); ~ component.method(); // methodというイベントハンドラを実行する expect(component.インスタンス.メソッド名).toHaveBeenCalled(); // spyOnしているから確認ができる
Event.targetをダミーで作成する
Event.targetはreadonlyのため、セットすることができません。 ダミーで作成するのにちょっと工夫が必要です。
it( 'テスト', async (done: DoneFn) => { interface AAA<T extends HTMLElement> extends Event { target: T; } // インタフェースを作る const elem = <HTMLInputElement>document.createElement( 'input' );// HTMLInputElementを作成 elem.addEventListener( 'click', { handleEvent: ( event: AAA<HTMLInputElement> ) => { event.target.setAttribute('xx', 'xx'); // ここで設定可能 event.target.value = 'xx'; // ここで設定可能 component.method(event); // methodイベントハンドラにEventを渡すことができる } }, false ); elem.click(); // ここで発火 expect(~).toEqual(~); // 評価する done(); });
click()することによりaddEventListenerが実行され、その中の第二引数でevent.target.~~を設定することが可能です。正直かなりハマってしまいました。
実はeventと書くだけでokayみたい
Eventオブジェクトの生成にはかなりハマってしまったのですが、普通にeventと記述するだけでもokayでした。。
windowと記述するのと同じですね。ただし、そのeventを使用して複雑なロジックを書いているようなら、上記のようにaddEventListener内に書いたほうがよいかもしれません。
ちなみにMouseEventの場合は、インタフェースを以下の通り変更するだけです。
interface AAAextends MouseEvent { target: T; }
というか同じ構造のオブジェクト作るだけでokay
色々試行錯誤していると、要するにEventに拘る事なく同じ構造のオブジェクトをつくってあげれば良いです。以下、例です。
const event: any = {target:{value:'1',id:'test'}};
moment型とmoment型をtoEqualで比較できない
Jasmineではどうもmoment型とmoment型は比較に失敗します。
ポインタを比較しているからか?よくわかりませんが失敗するのでgetDate()メソッドを使って比較すれば期待する結果が得られます。
expect( moment1.getDate() ).toEqual( moment2.getDate() );
参考サイト
toThrowとtoThrowErrorはasyncファンクションでは使えない
プログラムでtry-catchしてthrow句のテストコードを書きたい時、throwされたらtoThrowで確認できると思ったらできたりできなかったりで挙動がどうも怪しいです。
色々調べてみると、asyncファンクションのイベントハンドラではtoThrowもtoThrowErrorも使えません。普通のファンクションなら使えるようです。
public async onTest() { // asyncファンクション throw new Error('test-desu'); }
このようなコードを書いてテストコードを書いてみましたが、asyncがあるとないとでtoThrowが使えたり使えなかったりしますので注意です。
asyncファンクションの状態でテストコードを書いてみます。
expect(() => component.onTest()).toThrow(); // エラーとなる
これはエラーとなります。以下、参考サイトのようにthrowされた時のmessageで確認したほうが良いです。
参考サイト
コンストラクタ内で分岐があると大変
コンストラクタが実行されるのはnewされるときなので、コンストラクタ内の分岐をテストするのは難しいです。
サービスなどに依存している場合はinject内で行います。injectでは、@Injectable()しているクラスをDIすることができます。
it('test', (done: DoneFn) => { inject([aService, bService], async (a: aService,b:bService) => { const test: TestService = new TestService(a, b); // new することによりコンストラクタが実行される expect( test ).toBeTruthy(); done(); })(); });
[object ErrorEvent] thrown
このエラーが発生したら、HTML側のエラーだったことがあります。 例えば以下のような記述の場合、dataがundefinedの場合、エラーとなります。
data.firstName
これは以下のように書き換えた方が良いです。
data?.firstName
これならdataがundefinedでもエラーにはなりません。 karma-parallelでテストを並列に実行し高速化する karma-parallelプラグインでブラウザを複数起動し、テストすることが可能です。 karma.conf.jsを修正します。
config.set({ frameworks: ['parallel', 'jasmine', '@angular-devkit/build-angular'], // 'parallel'を追加 parallelOptions: { // この行を追加 executors: (Math.ceil(require('os').cpus().length / 2)), // この行を追加 sharedStrategy: 'round-robin' // この行を追加 }, // この行を追加 plugins: [ require('karma-jasmine'), require('karma-chrome-launcher'), require('karma-jasmine-html-reporter'), require('karma-coverage-istanbul-reporter'), require('@angular-devkit/build-angular/plugins/karma'), require('karma-parallel') // この行を追加 ],
executorsの個所は2としても良いですし、デフォルトは1です。上記は計算しています。
テストケースが多くなるとマシンスペックによってはテストで、DISCONECCTEDと表示されてしまうようです。karma.conf.jsの設定に以下を追記することによって回避することはできました。
browserDisconnectTolerance: 1, singleRun:ture // デフォルトはfalse
KarmaとJasmineのバージョンを上げることによっても回避できるようです。 NgbTabChangeEventのダミーオブジェクトを作成する方法 イベントハンドラの引数がNgbTabChangeEventの場合のテストで困ったのでメモです。
public onChange( event: NgbTabChangeEvent ) { ~ }
NgbTabChangeEventはインターフェースでnewすることはできません。 activeIdとnextIdとpreventDefaultを持つオブジェクトであることが分かるので以下のようなモックイベントを作成します。
const mockEvent: any = {preventDefault: () => {};
NgbPopoverのダミーオブジェクトを作成する方法 これもイベントハンドラの引数の型がNgbPopoverの場合にダミーを作成する必要がありましたのでメモです。以下ではcloseメソッドだけ定義しています。必要に応じて定義していけばよいです。
public onPopOver(event: NgbPopover){ ~ }
このイベントハンドラのテストのモック作成は以下のようにします。
const mockEvent: any = { close: () => {}}; component.onPopOver( mockEvent );
constructorにChangeDetectorRefがあるコンポーネントをnewする方法 コンポーネントをnewするテストはまれだと思いますが機会があったのでメモです。
interface AAA<T> extends ChangeDetectorRef { target: T; } let cd: AAA; component.testComponent = new TestComponent( cd ); // これでOK
_elementRef.nativeElementをモックする方法 これも適したオブジェクトを作成してあげればモックすることが可能です。
const mock = { _elementRef: { nativeElement: { contains: () => false }}, // このケースはfalseにしているだけ close: () => {} };
Windowオブジェクトのモックは難しい 色々頑張ってみたけどネイティブオブジェクトをモックするって難しい。 出来たのはopenとcloseをspyOnするだけ。
spyOn( window, 'open').and.returnValue(window); spyOn( window, 'close');
window.locationをモックしようとすると、readonlyだからかどうがんばっても出来ません。 試したがNGだったのは以下。
spyOn( window.location, 'reload'); spyOn( window.location, 'replace'); window.location.reload = () => {} window.location.replace = () => {}
いずれもモックできませんでした。色々調べてみると、このサイトを見つけました。 readonlyでも、writableやconfigurableによって再定義したりすることが可能なようです。 writableやconfigurableを調べるには
Object.getOwnPropertyDescriptor(window.location, 'reload');
で調べることができます。 reloadの場合は以下になります。
Object configurable: false enumerable: true value: ƒ reload() writable: false __proto__: Object
writableもconfigurableもfalseなので、モックすることはできません。 window.cryptoのようにconfigurable:trueなら、再定義してモックすることが可能です。(奥が深い、、) getアクセサはspyOnPropertyを使う getアクセサはspyOnpropertyでモックすることができます。
spyOnProperty(インスタンス,'getアクセサ名').and.returnValue(true);
上記はgetアクセサがbooleanを返す例です。 setアクセサは代入する setアクセサは簡単で、代入するだけです。
component.setアクセサ名 = true;
これでget,setアクセサのモックが可能です。 各テストでexpectしないといけない it単位でテストをしますが、expectをしていないテストは、「‘Spec ‘テストタイトル’ has no expectations.’」というエラーが表示されます。必ずexpectして期待値と実効値を比較しなければいけません。 jasmine.getEnv().allowRespy(true); 同じインスタンスの同じメソッドをspyOnするとします。その場合、以下エラーが出ます。
Error: <spyOn> : メソッド名 has already been spied upon
このため、一旦リセットしてあげる必要があります。それが
jasmine.getEnv().allowRespy(true);
です。リセットしたい行でこの1文を書いてあげればよいです。 spyOnPropertyをリセットする spyOnのリセット方法はjasmine.getEnv().allowRespy(true);
とするだけですが、spyOnPropertyをリセットするにはこの1行ではできませんでした。 jasmine.Spyにあるcalls.reset()を使用する必要があります。
const spyA = spyOnProperty(インスタンス,'メソッド'); const spyB = spyOnProperty(インスタンス,'メソッド'); spyA.and.returnValue(true); spyB.and.returnValue(true); // リセットする spyA.calls.reset(); spyB.calls.reset(); // 再度スパイ spyA.and.returnValue(false); spyB.and.returnValue(false);
console.logを抑制する プログラム上にconsole.logやconsole.errorを書いている場合、テストコードを実行するとコンソール上に出力されてしまいます。 これを抑制するにはspyOnを使用します。
spyOn(console,'log'); // これで抑制できる
alertを抑制する alertはそもそもwindow.alert()なので、spyOnで以下のようにコーディングすることで抑制することができます。
spyOn(window, 'alert');
ディレクティブのテストはダミーコンポーネント作成する また後日、、。 動作環境です。
環境 | バージョン |
---|---|
Karma | 2.0.2 |
Jasmine | 3.0 |
KHI入社して退社。今はCONFRAGEで正社員です。関西で140-170/80~120万から受け付けております^^
得意技はJS(ES6),Java,AWSの大体のリソースです
コメントはやさしくお願いいたします^^
座右の銘は、「狭き門より入れ」「願わくは、我に七難八苦を与えたまえ」です^^
コメント