angular

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

目次: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 / ネストしたコンポーネントのテスト