狛ログ

2022年5月16日月曜日

Angular ユニットテスト エラー(NgRxのReducerで発生する「TypeError: Cannot read properties of undefined (reading 'xxxxx')」)の対応。

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

前回前々回に引き続き、Angularでユニットテスト作成する時のエラー対応です。
今回はNgRx関連のエラーです。
error properties: Object({ longStack: 'TypeError: Cannot read properties of undefined (reading 'xxxxx')
      at http://localhost:9876/_karma_webpack_/webpack:/src/app/koma/store/reducers/index.ts:xx:xx
みたいなエラーが出てしまった場合の対応です。

・・・・ええ、言いたいことは分かります。
undefinedって出ているんだから、該当箇所見ればすぐ分かるじゃん。
って事ですよね?

まあ、仰る通りなのですが、エラーとなっている箇所は、コンポーネント側ではなく、NgRxのReducer(index.ts)で、エラーも微妙な位置なので、どう修正すれば良いのか、分からないんですよね・・・・。

ちなみに、コンポーネント側は、
[koma-confirm.component.ts]
export class KomaConfirmComponent implements OnInit, OnDestroy {
  komaConfirmSubscription: Subscription = new Subscription();
  saveData: KomaViewSaveModel;

  constructor(
    private router: Router,
    public store: Store<fromKoma.State>
  ) {}

  ngOnInit(): void {
    // 画面間保持項目の退避
    this.komaConfirmSubscription.add(
      this.store
        .pipe(select(fromKoma.getDataRegisterPost))
        .subscribe(model => {
          if (model) {
            this.saveData = model;
          } else {
            this.router.navigateByUrl('/koma/home');
          }
        }),
    );
  }

  ngOnDestroy(): void {
    this.komaConfirmSubscription.unsubscribe();
  }
}
となっています。

良くある登録系機能(入力→確認→完了)の「確認画面」を想定して頂くと分かりやすいです。
入力画面で入力された値をStoreに格納し、それを確認画面(のngOnInit)で取得しようとしています。
もし、Storeからデータを取得出来ない場合、不正な遷移を行ったと見做し、ホーム画面に遷移させています。

なので、
      this.store
        .pipe(select(fromKoma.getDataRegisterPost))
が何となく怪しいのは分かるのですが、Reducer(index.ts)側は、

[/src/app/koma/store/reducers/index.ts]
export const getKomaState = createSelector(
  getKomaFeatureState,
  (state: KomaFeatureState) => state.koma, // ←ここがエラーになってる。
);

export const getDataRegisterPost = createSelector(
  getKomaState,
  fromKoma.getDataRegisterPost, // ←せめてここでエラーになっていれば・・・・
);
上記の「state.koma」の部分がエラーとなっていて、内容としては、
TypeError: Cannot read properties of undefined (reading 'koma')
と出ています。

せめて、
  fromKoma.getDataRegisterPost,
の部分でエラーになっていれば、エラー原因の当たりがすぐにつくのですが・・・・

では、このエラーの対応をしていきたいと思います。

テストファイルの方を見ていきましょう。
[koma-confirm.component.spec.ts]
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { provideMockStore } from '@ngrx/store/testing';
import { KomaConfirmComponent } from './koma-confirm.component';

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

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

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

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

  it('should create', () => {
    expect(component).toBeTruthy();
  });
});
DIの設定だけはしている感じですね。

前回は、「StoreのdispatchでActionの実行をテストする」だったので、特に意識しなかったのですが、
Storeからデータを取得する、という場合は、「selector」と、取得するデータを定義する必要があります。

[koma-confirm.component.spec.ts]
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { provideMockStore } from '@ngrx/store/testing';
import * as fromKoma from '../../store/reducers';
import { KomaConfirmComponent } from './koma-confirm.component';

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

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [KomaConfirmComponent],
      providers: [
        provideMockStore({
          selectors: [
            { selector: fromKoma.getDataRegisterPost,
              value: {
                komaId: '1',
                komaData: '1111',
              }
            },
          ]
        }),
      ],
      imports: [
        RouterTestingModule,
      ],
    }).compileComponents();
  });

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

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

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

provideMockStoreの定義に引数として、selectorとvalueを設定しています。

これで、エラー(TypeError: Cannot read properties of undefined (reading 'xxxxx'))は出なくなると思います。

では、ちゃんとテストケースを追加していこうと思います。

まず、書き換えたいのが、
provideMockStoreの定義に引数として、selectorとvalueを設定している部分です。
今、そこを追加したのに・・・変えるんかい!って感じですね。

後々、テストケースが追加になった時に、selector内の値の入れ替えを楽にしたいので、
provideMockStoreの引数で設定するのではなく、テストクラス内で使えるように変えていこうと思います。
テストケースも追加していきます。
[koma-confirm.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 fromKoma from '../../store/reducers';
import { KomaConfirmComponent } from './koma-confirm.component';
import { KomaViewSaveModel } from '../../models/koma';

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

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

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

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

    // データの初期値を設定
    mockDataRegisterPostSelector = store.overrideSelector(
      fromAccount.getDataRegisterPost, {
        komaId: '1',
        komaData: '1111'
      }
    );

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

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

  describe('ngOnInitのテスト', () => {
    it('画面引き継ぎ情報が無い場合、ホーム画面へ遷移すること', () => {
      // 処理のモックを作成
      const navigateByUrlSpy = spyOn(router, 'navigateByUrl');

      // データにnullを設定
      mockDataRegisterPostSelector.setResult(null);

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

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

    it('画面引き継ぎ情報がある場合、データを退避すること', () => {
      // 処理のモックを作成
      const navigateByUrlSpy = spyOn(router, 'navigateByUrl');

      // データを再設定
      const formModel: KomaViewSaveModel = {
        komaId: '2',
        komaData: '2222'
      };
      mockDataRegisterPostSelector.setResult(formModel);

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

      // 退避されたデータの中身をチェック
      expect(component.saveData).not.toBeUndefined();
      expect(component.saveData).not.toBeNull();
      expect(component.saveData.komaId).toBe('2');
      expect(component.saveData.komaData).toBe('2222');

      // navigateByUrlが、呼ばれていないか確認
      expect(navigateByUrlSpy).not.toHaveBeenCalled();
    });
  });
});

ポイントとしては、「overrideSelector」によって、selectorを再定義している部分
    mockDataRegisterPostSelector = store.overrideSelector(
        // (中略)
    );
と、値を再設定する
    mockDataRegisterPostSelector.setResult(formModel);
の部分ですね。

これで、Store内のデータを設定し、テストをすることが可能になりました!

今後も、Angularでユニットテストについて、ブログに書いて行こうと思います。

0 件のコメント:

コメントを投稿