(WIP) Angular Testing メモ

これは何か

Angular の公式ドキュメント(Angular - Testing)とテストに関する前提知識(用語、Jasmine使い方など)をまとめようとしたもの。

前提知識

Jasmine

spyOn(オブジェクト, 'メソッド名')

// 本来の実装も実行
spyOn(obj, 'method').and.callThrough();

// 戻り値を変更
spyOn(obj, 'method').and.returnValue('stub');

// 処理を任意の関数に置き換える
spyOn(obj, 'method').and.callFake(...)

calls.count();
calls.first();
calls.mostRecent();

// 裸のスパイ関数
var spyFunction = jasmine.createSpy();

// 裸のスパイオブジェクト
var spyObj = jasmine.createSpyObj('spyName', ['hoge', 'fuga', 'piyo']);

用語

  • テストダブル - Wikipedia
  • テストダブル (Test Double) とは、ソフトウェアテストにおいて、テスト対象が依存しているコンポーネントを置き換える代用品のこと。ダブルは代役、影武者を意味する

    • テストスタブ (テスト対象に「間接的な入力」を提供するために使う。)
    • テストスパイ (テスト対象からの「間接的な出力」を検証するために使う。出力を記録しておくことで、テストコードの実行後に、値を取り出して検証できる。)
    • モックオブジェクト (テスト対象からの「間接的な出力」を検証するために使う。テストコードの実行前に、あらかじめ期待する 結果を設定しておく。検証はオブジェクト内部で行われる。)
    • フェイクオブジェクト (実際のオブジェクトに近い働きをするが、より単純な実装を使う。例として、実際のデータベースを置き換えるインメモリデータベースが挙げられる。)
    • ダミーオブジェクト (テスト対象のメソッドがパラメータを必要としているが、そのパラメータが利用されない場合に渡すオブジェクト。
  • スタブ - Wikipedia

    • スタブ (stub) とは、コンピュータプログラムのモジュールをテストする際、そのモジュールが呼び出す下位モジュールの代わりに用いる代用品のこと。

  • FakeとMockとStubの違い - kkamegawa’s weblog
    • Stub: Prevent the dependency from obstructing the method-under-test and torespond in a way that helps it proceed through its logical steps.

    • Mock: Allow the test code to verify that the code-under-test’s interaction with the dependency is proper, valid, and expected.

Service Tests

https://angular.io/guide/testing#service-tests

コンポーネントのテストは複雑になりがちなので、まずはサービスのテストから。

依存がない場合

service = new ValueService();

他のサービスに依存している場合

// 1. 実際のサービスをInject
masterService = new MasterService(new ValueService());

// 2. FakeサービスをInject
masterService = new MasterService(new FakeValueService());

// 3. Fake objectをInject
const fake =  { getValue: () => 'fake value' };
masterService = new MasterService(fake as ValueService);

// 4. Spy serviceをInject
const valueServiceSpy = jasmine.createSpyObj('ValueService', ['getValue']);
const stubValue = 'stub value';
valueServiceSpy.getValue.and.returnValue(stubValue);
masterService = new MasterService(valueServiceSpy);

実際のアプリケーションと同じように Angular の DI を利用する必要があるのであれば TestBed を利用。
https://angular.io/guide/testing#angular-testbed

let service: ValueService;

beforeEach(() => {
  TestBed.configureTestingModule({ providers: [ValueService] });
});

it('should use ValueService', () => {
  service = TestBed.get(ValueService);
  expect(service.getValue()).toBe('real value');
});

Testing without beforeEach()

https://angular.io/guide/testing#testing-without-beforeeach

beforeEachを必ずしも利用する必要はない

function setup() {
  const valueServiceSpy =
    jasmine.createSpyObj('ValueService', ['getValue']);
  const stubValue = 'stub value';
  const masterService = new MasterService(valueServiceSpy);

  valueServiceSpy.getValue.and.returnValue(stubValue);
  return { masterService, stubValue, valueServiceSpy };
}

it('#getValue should return stubbed value from a spy', () => {
  const { masterService, stubValue, valueServiceSpy } = setup();
  // ...
});

Testing HTTP services

https://angular.io/guide/testing#testing-http-services

HttpClientのSpyをつくる

beforeEach(() => {
  httpClientSpy = jasmine.createSpyObj('HttpClient', ['get']);
  TestBed.configureTestingModule({
    providers: [
      ApiService,
      { provide: HttpClient, useValue: httpClientSpy },
    ],
  });
});
// or
beforeEach(() => {
  httpClientSpy = jasmine.createSpyObj('HttpClient', ['get']);
  service = new ApiService(<any>httpClientSpy);
});
// 正常系
httpClientSpy.get.and.returnValue(asyncData(json));
// エラー系
httpClientSpy.get.and.returnValue(asyncError(errorResponse));

Component Test Basics

https://angular.io/guide/testing#component-test-basics

大抵の場合、DOM関与なしでコンポーネントクラスだけをテストした方が、動作の多くを簡単に検証できる。実際、単純なコンポーネントならnew FooComponent()のようにインスタンスを生成するだけでテストができる。

// component
export class DashboardHeroComponent {
  @Input() hero: Hero;
  @Output() selected = new EventEmitter<Hero>();
  click() { this.selected.emit(this.hero); }
}

// testing
it('raises the selected event when clicked', () => {
  const comp = new DashboardHeroComponent();
  const hero: Hero = { id: 42, name: 'Test' };
  comp.hero = hero;

  comp.selected.subscribe(selectedHero => expect(selectedHero).toBe(hero));
  comp.click();
});

依存関係がある場合はTestBedを利用する。

beforeEach(() => {
  TestBed.configureTestingModule({
    // provide the component-under-test and dependent service
    providers: [
      WelcomeComponent,
      { provide: UserService, useClass: MockUserService }
    ]
  });
  // inject both the component and the dependent service.
  comp = TestBed.get(WelcomeComponent);
  userService = TestBed.get(UserService);
});

// ...

// 手動で ngOnInit 
it('should welcome logged in user after Angular calls ngOnInit', () => {
  comp.ngOnInit();
  expect(comp.welcome).toContain(userService.user.name);
});

Component DOM testing

https://angular.io/guide/testing#component-dom-testing

DOMのテストも必要な場合。コンポーネントを生成してテストする必要がある。

コンポーネントを生成するテストコード

describe('BannerComponent (minimal)', () => {
  it('should create', () => {
    TestBed.configureTestingModule({
      declarations: [ BannerComponent ]
    });
    const fixture = TestBed.createComponent(BannerComponent);
    const component = fixture.componentInstance;
    expect(component).toBeDefined();
  });
});

nativeElement

https://angular.io/guide/testing#nativeelement

DOMのAPIを利用したい場合。

const bannerElement: HTMLElement = fixture.nativeElement;
const p = bannerElement.querySelector('p');

DebugElement

https://angular.io/guide/testing#debugelement

The DebugElement offers query methods that work for all supported platforms.

Component Test Scenarios

https://angular.io/guide/testing#component-test-scenarios

Component binding

https://angular.io/guide/testing#component-binding

Change an input value with dispatchEvent()

input elementの場合はdispatchEventも実行させる。

const nameInput: HTMLInputElement = hostElement.querySelector('input');

// ...

// simulate user entering new name into the input box
nameInput.value = inputName;

// dispatch a DOM event so that Angular learns of input value change.
nameInput.dispatchEvent(newEvent('input'));

// Tell Angular to update the display binding through the title pipe
fixture.detectChanges();

Component with external files

ng testなら不要

Component with a dependency

https://angular.io/guide/testing#component-with-a-dependency

依存関係があるコンポーネント

// providers: [ UserService ] // NO! Don't provide the real service!
// Provide a test-double instead

Provide service test doubles

https://angular.io/guide/testing#provide-service-test-doubles

A component-under-test doesn't have to be injected with real services. In fact, it is usually better if they are test doubles (stubs, fakes, spies, or mocks).

let userServiceStub: Partial<UserService>;

userServiceStub = {
  isLoggedIn: true,
  user: { name: 'Test User'}
};
  • Get injected services
// UserService actually injected into the component
userService = fixture.debugElement.injector.get(UserService);

// UserService from the root injector
userService = TestBed.get(UserService);

Component with async service

https://angular.io/guide/testing#component-with-async-service

Testing with a spy

const twainService = jasmine.createSpyObj('TwainService', ['getQuote']);
// Make the spy return a synchronous Observable with the test data
getQuoteSpy = twainService.getQuote.and.returnValue( of(testQuote) );

Synchronous tests

https://angular.io/guide/testing#synchronous-tests

Async test with fakeAsync()

https://angular.io/guide/testing#async-test-with-fakeasync

The tick() function

Calling tick() simulates the passage of time until all pending asynchronous activities finish. In this case, it waits for the error handler's setTimeout();

Async observables

Async observable helpers

RxJS defer() returns an observable. It takes a factory function that returns either a promise or an observable. When something subscribes to defer's observable, it adds the subscriber to a new observable created with that factory.

RxJS defer() transform the Promise.resolve() into a new observable that, like HttpClient, emits once and completes. Subscribers will be unsubscribed after they receive the data value.

以下を用意

import { defer } from 'rxjs/observable/defer';

/** Create async observable that emits-once and completes
 *  after a JS engine turn */
export function asyncData<T>(data: T) {
  return defer(() => Promise.resolve(data));
}

/** Create async observable error that errors
 *  after a JS engine turn */
export function asyncError<T>(errorObject: T) {
  return defer(() => Promise.reject(errorObject));
}

More async tests

Async test with async()

https://angular.io/guide/testing#async-test-with-async

fakeではないasync

whenStable

The test must wait for the getQuote() observable to emit the next quote. Instead of calling tick(), it calls fixture.whenStable().

tickの代わりにfixture.whenStableを実行する必要がある

Jasmine done()

https://angular.io/guide/testing#jasmine-done

もちろん一般的な done も使える

But it is occasionally necessary. For example, you can't call async or fakeAsync when testing code that involves the intervalTimer() or the RxJS delay() operator.

Component marble tests

https://angular.io/guide/testing#component-marble-tests

Component with inputs and outputs

https://angular.io/guide/testing#component-with-inputs-and-outputs

@Input に対応

// ...

comp = fixture.componentInstance;

// ...

// mock the hero supplied by the parent component
expectedHero = { id: 42, name: 'Test Name' };

// simulate the parent setting the input property with that hero
comp.hero = expectedHero;

// trigger initial data binding
fixture.detectChanges();

Clicking

https://angular.io/guide/testing#clicking

Component inside a test host

https://angular.io/guide/testing#component-inside-a-test-host

  • Component with inputs and outputsと違うやり方。ホストのコンポーネントから操作する。
  • TestHostComponentを作ってその中で制御

Routing component

https://angular.io/guide/testing#routing-component

  • Routerをもっているコンポーネントのテスト
  • 「Routerのテスト」をする必要はないのでspyをつくる
const routerSpy = jasmine.createSpyObj('Router', ['navigateByUrl']);

// ...

const spy = router.navigateByUrl as jasmine.Spy;
const navArgs = spy.calls.first().args[0];

Routed components

https://angular.io/guide/testing#routed-components

  • Router Navigation の遷移先
  • ActivatedRoute をどうテストするか

paramMap returns an Observable that can emit more than one value during a test. You need the router helper function, convertToParamMap(), to create a ParamMap. Other routed components tests need a test double for ActivatedRoute.

Nested component tests

https://angular.io/guide/testing#nested-component-tests

不要なコンポーネントをどう扱うか。

Stubbing unneeded components

@Component({selector: 'app-banner', template: ''})
class BannerStubComponent {}

TestBed.configureTestingModule({
  declarations: [
    AppComponent,
    BannerStubComponent
  ]
});

NO_ERRORS_SCHEMA

tells the Angular compiler to ignore unrecognized elements and attributes.

Use both techniques together

実際には両方のテクニックを利用するはず

https://angular.io/guide/testing#components-with-routerlink

RouterLinkのスタブを作成

@Directive({
  selector: '[routerLink]',
  host: { '(click)': 'onClick()' }
})
export class RouterLinkDirectiveStub {
  @Input('routerLink') linkParams: any;
  navigatedTo: any = null;
  onClick() {
    this.navigatedTo = this.linkParams;
  }
}

By.directive and injected directives

beforeEach(() => {
  fixture.detectChanges(); // trigger initial data binding

  // find DebugElements with an attached RouterLinkStubDirective
  linkDes = fixture.debugElement
    .queryAll(By.directive(RouterLinkDirectiveStub));

  // get attached link directive instances
  // using each DebugElement's injector
  routerLinks = linkDes.map(de => de.injector.get(RouterLinkDirectiveStub));
});

Use a page object

Setup with module imports

https://angular.io/guide/testing#setup-with-module-imports

モジュールインポートについて解説

One approach is to configure the testing module from the individual pieces as in this example:

  • Import a shared module でも可能
  • 実は自身を含む Feature module を読み込んでもおk

Override component providers

https://angular.io/guide/testing#override-component-providers

どうやってコンポーネントprovidersで登録されたサービスをおきかえるか


(すでに中途半端な内容になっていますが、一旦ここまで。ここからさらに、DirectiveやPipeのテストの説明、よくある質問などがAngular - Testingには記述されています。)