この章では(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();
});