Edited at

Angular コンポーネントのテスト(sample4 / サービスが注入されたコンポーネントのテスト)

More than 1 year has passed since last update.

目次:Angular コンポーネントのテスト


この章では(Http など Angular のビルトインサービスではなく)単純なサービスが注入されたコンポーネントのテストについて説明します。


コンポーネントに注入するためのサービスの作成

ログ出力用のテキストを生成するサービスを作成します。

$ ng generate service logger

// logger.service.ts

import { Injectable } from '@angular/core';

@Injectable()
export class LoggerService {

constructor() { }

+ log(): string {
+ return 'log text';
+ }
}

// app.module.ts

@NgModule({
...
providers: [
+ LoggerService,
],


コンポーネントの作成

テストを書くためのコンポーネントを作成します。

ボタンを押したら <p> にログが出力される単純なコンポーネントです。

$ ng generate component sample4

// sample4.component.ts

import { Component, OnInit } from '@angular/core';
+ import { LoggerService } from '../logger.service';

@Component({
selector: 'app-sample4',
templateUrl: './sample4.component.html',
styleUrls: ['./sample4.component.css']
})
export class Sample4Component implements OnInit {
+ log: string;

constructor(
+ private logger: LoggerService,
) { }

ngOnInit() {
}

+ write() {
+ this.log = this.logger.log();
+ }

}

// sample4.component.html

- <p>
- sample6 works!
- </p>

+ <button (click)="write()">log</button>
+ <p>{{log}}</p>


アプリケーションとテスト環境のインジェクタの違い

コンポーネントにサービスへの依存を定義すると NullInjectorError: No provider for LoggerService! というメッセージとともにテストが落ちるはずです。

下記のコードを書き加えてみてください。今度はテストが通ります。

// sample4.component.spec.ts

import { Sample4Component } from './sample4.component';
+ import { LoggerService } from '../logger.service';

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

beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ Sample4Component ],
+ providers: [
+ { provide: LoggerService, useClass: LoggerService },
+ ],
})
.compileComponents();
}));
...
});

なぜテストが通るようになったかと言うと、ソースコードから何となく分かる通り LoggerService に関する依存の解決方法を指定したからです。


アプリケーションのインジェクタ

アプリケーション側では以下のようにサービスをインジェクタに登録しています。

// app.module.ts

@NgModule({
...
providers: [
LoggerService,
],

Angular のインジェクタは依存解決のために サービスの「トークン」と「インスタンス」 をペアで保持します。


  • トークン: LoggerService

  • インスタンス: new LoggerService()


テスト環境のインジェクタ

テスト環境では、明示的に サービスの「トークン」と「インスタンスの生成方法」 を Angular のインジェクタに登録する必要があります。

TestBed.configureTestingModule({

...
providers: [
{ provide: LoggerService, useClass: LoggerService },
],


  • トークン(provide): LoggerService

  • インスタンスの生成方法(useClass): new LoggerService()

依存が複数あるようなケースでは providers に全ての依存を定義します。

// コンポーネントに紐付いたクラスのコンストラクタ

constructor (
private foo: FooService,
private bar: BarService,
...
) {}

// テストの依存解決
TestBed.configureTestingModule({
providers: [
{ provide: FooService, useClass: FooService },
{ provide: BarService, useClass: BarService },
...
]


テスト環境のインスタンス生成方法のパターン

テスト環境では、コンポーネントが依存するサービスの注入方法(インスタンス生成方法)を providers に渡すオブジェクトのキーとして指定します。


useClass

new {クラス名}() でインスタンスを生成します。

コンポーネントが依存するサービスと同じクラス名を指定すると、そのサービスと同じ振る舞いを持ったテストダブルが生成されます。コンポーネントの振る舞いを検証するにはスパイなどを利用します。

import { LoggerService } from '../logger.service';

describe('TestBed - useClass', () => {
...
beforeEach(async(() => {
TestBed.configureTestingModule({
...
providers: [
{ provide: LoggerService, useClass: LoggerService },
],
})
.compileComponents();
}));
...

it('テンプレートに出力された値を検証', () => {
const logger = TestBed.get(LoggerService);
spyOn(logger, 'log').and.returnValue('fake text via useClass');

fixture.debugElement.query(By.css('button')).nativeElement.click();
fixture.detectChanges();

expect(fixture.debugElement.query(By.css('p')).nativeElement.textContent).toBe('fake text via useClass');
expect(logger.log).toHaveBeenCalled();
});
});


useValue

useValueに渡した値がそのまま依存として注入されます。

まだサービスが実装されていない場合などは import しなくて済むので便利ですね。

describe('TestBed - useValue', () => {

...
beforeEach(async(() => {
TestBed.configureTestingModule({
...
providers: [
{ provide: LoggerService, useValue: { log: () => 'fake text via useValue' } },
],
})
.compileComponents();
}));
...

it('テンプレートに出力された値を検証', () => {
fixture.debugElement.query(By.css('button')).nativeElement.click();
fixture.detectChanges();

expect(fixture.debugElement.query(By.css('p')).nativeElement.textContent).toBe('fake text via useValue');
});
});


useFactory

依存解決のためのファクトリ関数を指定します。

依存サービスの初期設定などが必要な場合に便利ですね。

describe('TestBed - useFactory', () => {

...
class FakeLogger {
text: string;
log(): string {
return this.text;
}
}

beforeEach(async(() => {
TestBed.configureTestingModule({
...
providers: [
{ provide: LoggerService, useFactory: () => {
const logger = new FakeLogger();
logger.text = 'fake text via useFactory';

return logger;
} },
],
})
.compileComponents();
}));

...

it('テンプレートに出力された値を検証', () => {
fixture.debugElement.query(By.css('button')).nativeElement.click();
fixture.detectChanges();

expect(fixture.debugElement.query(By.css('p')).nativeElement.textContent).toBe('fake text via useFactory');
});
});


インジェクタとサービスのライフサイクル

Angular ではコンポーネントごとに専用のインジェクタが存在します。

コンポーネントがツリー構造になっているのと同様に、インジェクタもツリー構造になっています。

依存サービスを解決する時 Angular はまず最初に、コンポーネントのインジェクタにトークンを問い合わせます。

トークンが見つからない場合は、親コンポーネントのインジェクタに問い合わせます。

そこでもトークンが見つからない場合は、さらにその親コンポーネントに問い合わせ、最上位のルートインジェクタに到達するまで問い合わせを繰り返します。

一番最初に目にした NullInjectorError: No provider for LoggerService! は「(テスト環境の)ルートインジェクタまで遡ってトークンを検索したが見つからなかった」という例外処理のメッセージだった訳ですね。

インジェクタへのサービス登録は NgModule または Component デコレータの providers で定義します。

定義する場所によってサービスのライフサイクルが変化します。


ルートインジェクタにサービスを登録

// app.module.ts

import { FooService } from './foo.service.ts';

@NgModule({
...
providers: [
FooService
],

// foo.component.ts

@Component({
...
})
export class FooComponent implements OnInit {
constructor(
private foo: FooService,
) { }

依存サービスのインスタンスは アプリケーション実行時に生成され、ルートインジェクタに登録、アプリケーション終了時に破棄 されます。


コンポーネントのインジェクタにサービスを登録

// foo.component.ts

import { FooService } from '../foo.service.ts';

@Component({
...
providers: [FooService],
})
export class FooComponent implements OnInit {
constructor(
private foo: FooService,
) { }

依存サービスのインスタンスは コンポーネントと同時に生成され、コンポーネントのインジェクタに登録、 コンポーネントが解放される時に破棄 されます。


公式ドキュメント Hierarchical Dependency Injectors

https://angular.io/guide/hierarchical-dependency-injection



ライフサイクルに合わせたテスト環境におけるサービス登録

「依存サービスがどのインジェクタに登録されているか」によって、テスト環境で使用するインジェクタが変わります。


ルートインジェクタまたは親コンポーネントのインジェクタに登録されているサービス

テスト環境のモジュールのインジェクタに依存サービスを登録します。

// foo.component.ts

@Component({
...
// providers の定義がない
})

// foo.component.spec.ts

beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ FooComponent ],
+ providers: [
+ { provide: FooService, useClass: FooService },
+ ],
})
.compileComponents();
}));

依存サービスをスパイに置き換えるような場合、テスト環境のモジュールのインジェクタにアクセスします。

// foo.component.spec.ts

const foo = TestBed.get(FooService);
spyOn(foo, 'log');


コンポーネントのインジェクタに登録されているサービス

TestBed.overrideComponent を利用してコンポーネントのインジェクタに依存サービスを登録します。

// foo.component.ts

@Component({
...
+ providers: [FooService],
})

// foo.component.spec.ts

beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ FooComponent ],
})
+ .overrideComponent(FooComponent, {
+ set: {
+ providers: [
+ { provide: FooService, useClass: FooService },
+ ],
+ }
+ })
.compileComponents();
}));

依存サービスをスパイに置き換えるような場合、コンポーネントのインジェクタにアクセスします。

// foo.component.spec.ts

const foo = fixture.debugElement.injector.get(FooService);
spyOn(foo, 'log');


コンポーネントのインジェクタに登録されているサービスは、サービス登録しなくてもテスト可能

コンポーネントのインジェクタに依存サービスが登録されている場合は、テスト環境でわざわざサービス登録をしなくてもコンパイルは通ります。

「テスト環境でサービスを登録する事」は「サービスをテストダブルに置き換える事」と等価であり、テストダブルを使わないのであれば何もしなくて良いでしょう。

// foo.component.spec.ts

beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ FooComponent ],
// providers: [...]
})
// .overrideComponent(...)
.compileComponents();
}));

it('should create', () => {
expect(component).toBeTruthy();
});


次の記事:sample5 / ネストしたコンポーネントのテスト