狛ログ

2022年5月13日金曜日

Angular ユニットテスト エラー( NullInjectorError: No provider for Store )の対応。

オフィス狛 技術部のKoma(Twitterアカウントの中の人&CEO)です。

前回に引き続き、Angularでユニットテスト作成時のエラー対応です。
今回もDI関連のエラーです。
NullInjectorError: R3InjectorError(DynamicTestModule)[Store -> Store]: 
  NullInjectorError: No provider for Store!
上記のエラーが出てしまった場合の対応です。

これは、NgRxのStoreを使用している場合に出てくるエラーとなります。
NgRxの公式サイトを見ると、コンポーネント側のテスト記載のサンプルがあるので、詳細はそちらを見るとして、とりあえず、エラーを無くしてみます。
まずは、コンポーネント側のロジックを確認してみましょう。

例として、よくある「入力画面(から確認画面)」を挙げてみます。
「入力画面で、入力した値をSubmitによって、Storeに保存し、確認画面へ引き継ぐ」って感じですね。
[input.component.ts]
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import * as fromInput from '../../store/reducers';
import * as InputActions from '../../store/actions/input.actions';
import { InputViewSaveModel } from '../../models/input';
// (中略)
  constructor(
    private router: Router,
    public store: Store<fromInput.State>,
  ) {}
// (中略)
  onSubmit(inputData: InputViewSaveModel): void {
    // 入力情報をStoreに格納して(=引き継ぎ情報として)確認画面へ遷移
    this.store.dispatch(InputActions.setRegisterPostData({ data: inputData }));
    this.router.navigateByUrl('/koma/confirm');
  }

ここではNgRxの仕様については触れませんので、NgRxを知っている前提で進めます。
それと、前回やった「Router」もありますので、一緒にエラーが出ないように対応してみます。

まずは必要モジュールのインポートと、DIをします。
(Angularでは、DI可能なクラスであれば、コンポーネントのコンストラクタに定義する事で、DIして使用可能になります。)
[input.component.spec.ts]
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { provideMockStore } from '@ngrx/store/testing';
import { InputComponent } from './input.component';

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

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [InputComponent],
      providers: [provideMockStore()],
      imports: [RouterTestingModule],
    }).compileComponents();
  });

  beforeEach(() => {
    fixture = TestBed.createComponent(InputComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });
});
と言う感じになります。

Storeに関する部分は、
import { provideMockStore } from '@ngrx/store/testing';
と、
      providers: [provideMockStore()],
を追加しています。
先ほどのエラー(NullInjectorError)を無くすだけであれば、実は上記でOKです。

ただ、当然テストケースは作らないといけないので、ついでにやってみましょう
[input.component.spec.ts]
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { Router } from '@angular/router';
import { provideMockStore, MockStore } from '@ngrx/store/testing';
import * as fromInput from '../../store/reducers';
import * as InputActions from '../../store/actions/input.actions';
import { InputViewSaveModel } from '../../models/input';
import { InputComponent } from './input.component';

describe('InputComponent', () => {
  let component: InputComponent;
  let fixture: ComponentFixture<InputComponent>;
  // 全テストケースで利用出来るように、ここで定義
  let router: Router;
  let store: MockStore<fromInput.State>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [InputComponent],
      providers: [provideMockStore()],
      imports: [RouterTestingModule],
    }).compileComponents();
  });

  beforeEach(() => {
    fixture = TestBed.createComponent(InputComponent);

    // DI後のインスタンスを取得
    router = TestBed.inject(Router);
    store = TestBed.inject(MockStore);

    component = fixture.componentInstance;
    fixture.detectChanges();
  });

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

  describe('ボタンアクションテスト', () => {
    it('確認ボタン押下時 Storeにデータを保存し、確認画面へ遷移する事', () => {
      // 処理のモックを作成
      const dispatchSpy = spyOn(store, 'dispatch');
      const navigateByUrlSpy = spyOn(router, 'navigateByUrl');

      // 画面から入力された値を再現
      const formModel: InputViewSaveModel = {
        id: '111',
        name: 'koma-chan',
        address: 'tokyo',
      };

      // 実行されるアクションを定義(画面からの入力値をStoreへ保存するアクション)
      const expectedAction = InputActions.setRegisterPostData({ data: formModel });

      // テスト対象のメソッドを呼び出す
      component.onSubmit(formModel);

      // Storeのdispatchが、アクションを引数として呼ばれているか確認
      expect(dispatchSpy).toHaveBeenCalledWith(expectedAction);

      // navigateByUrlが、'/koma/confirm'を引数として呼ばれているか確認
      expect(navigateByUrlSpy).toHaveBeenCalledWith('/koma/confirm');
    });
  });
});

ポイントとしては、DIしたクラスをテスト側で使用する為には、
    store = TestBed.inject(MockStore);
が必要、というところです。
実際のテストの部分は、プログラム内のコメントを参照して頂ければと思いますが、流れ的に、

① spyOn で、Store の dispatch に対するモックを作成
② 画面の入力値を Store に保存するアクションを定義
③ コンポーネント側の onSubmit を呼び出す
③ expect の toHaveBeenCalledWith で、指定の引数(アクション)で dispatch が呼ばれたかを判定

となっています。

ちょっと長くなってしまったので、その他のStore関連のテストエラーは別途記事にしようと思います。

0 件のコメント:

コメントを投稿