Angular でテストコードの書き方を纏めました

angularAngular

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同一値かどうか
toBeTruthytrueかどうか
toBeFalsyfalseかどうか
toBeNullnullかどうか
toBeNaNNaNかどうか
toThrow例外が発生するかどうか
toThrowError例外が発生するかどうか
toContain実効値が期待値に含まれているかどうか
toBeLessThan実効値が期待値より大きいかどうか
toBeGreaterThan実効値が期待値より大きいかどうか
toBeDefinedundefinedではないこと
toBeUndefinedundefinedであること
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;

正確には以下の手順です。

  1. ブレークしたい箇所にdebuggerとタイプする
  2. npm testを実行する
  3. デバッグを開始し、デバッグパネルを表示する

これでテストコードもデバッグすることができるようになりました。
ブレークポイントで止まってしまえば、そのあとは、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 AAA extends 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');

ディレクティブのテストはダミーコンポーネント作成する また後日、、。 動作環境です。

環境バージョン
Karma2.0.2
Jasmine3.0

公式サイト

コメント

タイトルとURLをコピーしました