19 gru 2018
5 min

Testowanie NgRx – komponenty

W ostatnim wpisie przedstawiłem sposoby testowania selektorów, reducerów oraz efektów.

Czas zabrać się za komponenty! Teraz już nie będzie tak łatwo, szybko i przyjemnie 😉 Zaanlizuję przypadki testowania komponentów, które:

  1. wyłącznie dispatchują akcje (store.dispatch)
  2. dispatchują akcje oraz wyciągają dane ze store za pomocą metody select lub pipeable selectOperator

Z ciekawostek: W NgRx 6.1.0 pojawiła się informacja, że store.select staje się przestarzały (deprecated) i przeskakujemy na pipeable operator select. Od NgRx 7.0.0 beta jest już to nieaktualne, autorzy NgRx stwierdzili, że nie jest to jednak dobry pomysł z wielu powodów, więc można dalej śmiało używać metody select lub jak kto woli, operatora select.

UWAGA: Artykuł jest przeznaczony dla osób już obytych z pracą w NgRx & Angular i ze znajomością frameworka do testowania Jasmine lub innego. W artykule nie tłumaczę funkcji z Jasmine i mokowania modułów testowych. Skupiam się wyłącznie na teście NgRx.

Jeśli nigdy nie testowałeś komponentów, zacznij od przeczytania dokumentacji :-):

Co testować w komponencie świadomym Store?

Ja testuję wyłącznie:

  • czy store.dispatch wysyła dobrą akcję (np. na kliknięcie etc.)
  • streamy wystawione przez selektory i łączone np. poprzez combineLatest, po którym dalej np. w operatorze map jest jakaś logika. W takiej sytuacji, w teście subskrybuję się do takiego strumienia udostępnionego pod polem klasy i sprawdzam  emitowaną wartość.

Oczywiście testuję również inne zachowania komponentu, ale powyższe wymienione są stricte związane z NgRx.

1. Tylko dispatch w komponencie

Przetestowanie komponentu świadomego Store, który wyłącznie wysyła akcje, jest najprostszą sytuacją. Rozpatrzmy nasz przypadek testowy:

template:

<button (click)="fetchCars()" id="fetch-cars-btn">Fetch cars</button>

klasa:

@Component({
  selector: 'app-cars',
  templateUrl: './cars.component.html',
  styleUrls: ['./cars.component.css']
})
export class CarsComponent {
  constructor(private store: Store<CarsState>) { }

  fetchCars() {
    this.store.dispatch(new FetchCars());
  }

Jak widzisz, żadnych selektorów. Wyłącznie wysłanie akcji na kliknięcie przycisku „Fetch Cars”. Zatem napiszmy test, który sprawdzi, czy akcja została wysłana na kliknięcie:

describe('CarsComponent', () => {
  let component: CarsComponent;
  let fixture: ComponentFixture<CarsComponent>;
  let store: Store<FeatureModuleCarsState>;

  beforeEach(async(() => {
    TestBed.configureTestingModule({
      imports: [StoreModule.forRoot({})],
      declarations: [CarsComponent],
      providers: [Store],
    })
    .compileComponents();

    fixture = TestBed.createComponent(CarsComponent);
    component = fixture.componentInstance;
    store = TestBed.get(Store); // wyciągam instancję Store z Injectora
    spyOn(store, 'dispatch'); // śledzę wywołanie metody dispatch
    fixture.detectChanges();
  }));

  describe('when fetch cars button is clicked', () => {
    beforeEach(() => {
      const button = fixture.debugElement.query(By.css('#fetch-cars-btn'));
      button.nativeElement.click();
    });

    it('should dispatch FetchCars action', () => {
      expect(store.dispatch).toHaveBeenCalledWith(new FetchCars()); // czy dispatch zawołany z dobrą akcją
    });
  });
});

Ok… to było proste! 🙂 Nawet nie musiałem mockować Store, wystarczyło zaimportować StoreModule.forRoot, który normalnie oczekiwałby mapy reducerów. Nie interesuje nas testowanie reducerów, więc wstawiłem pusty obiekt, tym niemniej zaimportowanie tego modułu jest konieczne do działania Store:

imports: [StoreModule.forRoot({})],

W samym teście, interesuje nas, czy na kliknięcie została wysłana akcja:

it('should dispatch FetchCars action', () => {
   expect(store.dispatch).toHaveBeenCalledWith(new FetchCars());
});

Oczywiście musiałem wcześniej stworzyć szpiega, aby móc użyć „toHaveBeenCalledWith(new FetchCars())” :

spyOn(store, 'dispatch');

voilà! Sprawa się komplikuje, gdy w naszym komponencie pojawiają się selektory.

2. Dispatch + Select w komponencie

Dorzućmy do klasy komponentu selektor:

export class CarsComponent {
  cars$ = this.store.select(fromCars.getCars);
  ...
}

I do templatki asyncPipe:

<ul>
  <li *ngFor="let car of cars$ | async">{{ car.model }}</li>
</ul>

Pierwsza myśl – wyciągamy selektorem listę samochodów, ale skąd nasz Store w teście ma posiadać listę samochodów, którą udostępni selektorowi getCars? Spodziewam się errora przy odpaleniu wcześniej napisanego testu:

TypeError: Cannot read property 'cars' of undefined at eval (./src/app/cars/store/cars.selectors.ts?:15:18)

oczywiście, otrzymaliśmy error. Zatem nasz test musimy być świadomy listy samochodów, aby zadziałał. Pamiętaj również, że nie chcemy tu testować selektora – on ma swój osobny test.
Teraz mamy do wyboru dwie drogi:

1) zamokować co zwraca select

Z tym podejściem spotkałem się już w projektach i różnych tutorialach. Generalnie nie polecam, ale o tym zaraz ;):

beforeEach(async(() => {
  TestBed.configureTestingModule({
    ...
  })
  .compileComponents();

  store = TestBed.get(Store);
  spyOn(store, 'dispatch');
  spyOn(store, 'select').and.callFake(selector => {
    if (selector === getCars) { // getCars to nazwa selektora
      return of([]);
    }
  });

  fixture = TestBed.createComponent(CarsComponent);
  ...
}));

Powyżej użyłem .and.callFake, aby zawołać swoją implementację dla selektora, sprawdziłem także za pomocą IFa, czy chodzi mi na pewno o ten selektor, dla którego chce zwrocić Observabla (select zwraca typ Observable) z pustą listą samochodów. Teraz test się nie wysypuje i wszystko gra, bo asyncPipe w templatce, ma się na co zasubskrybować!

Minusy:
– nie działa z pipeable selectOperator (store.pipe(select(selector…)))

– w przypadku wielu selektorów w komponencie, musimy pamiętać o obsłużeniu wszystkich w callFake i zadbać, aby zwracały dobre wartości jeżeli to istotne
– niewygodne, jeśli np dla pewnego testu chciałbym aby selektor zwracał jakąś inną wartość (bo może np. selektor ma jakąś logikę, która może być powiązana z jakimś zachowaniem na UI – np. jeśli selektor wyemitował true, to pokaż mi element). Musiałbym powtarzać się z callFake.
– porównanie === wysypie się dla selektorów parametryzowanych, zwracanych przez funkcje, przykład poniżej:

// parametryzowany selektor
export const getCar = (id: number) => createSelector(getCars, (cars: Car[]) => {
  return cars.find(car => car.id === id);
});
...
// użycie w klasie komponentu
car$ = this.store.select(fromCars.getCar(5)).subscribe();
// spy
spyOn(store, 'select').and.callFake(selector => {
 if (selector === getCar(5)) { // to nie przejdzie!
 return of({});
 }
});

Plusy:
– jeśli selektor polega na wielu innych selektorach, przykładowo:

export const getFavouriteUserCarByRouteParam = createSelector(
  fromRoot.getRouterState,
  fromRoot.getUser,
  getCars,
  (routerState, user, cars) => ...),
);

To bardzo łatwo mogę zamokować co zwraca, bez dowożenia do selektora potrzebnych mu wartości
– Nie muszę mokować w teście wartości dla Store, z których korzystają selektory znajdujące się w testowanym komponencie

2) zamokować store z danymi, aby select się nie wysypał z braku danych

Następujące podejście bardziej mi się podoba. Polega na stworzeniu swojej klasy generycznej MockStore z metodą setState, i dziedziczącej po Store. Ja korzystam z gotowca.

Teraz test wygląda następująco:

describe('CarsComponent', () => {
  let component: CarsComponent;
  let fixture: ComponentFixture<CarsComponent>;
  let store: MockStore<FeatureModuleCarsState>; // dostarczamy typ, aby wiedzieć jakie klucze są prawidłowe

  beforeEach(async(() => {
    TestBed.configureTestingModule({
      imports: [StoreModule.forRoot({})],
      declarations: [CarsComponent],
      providers: [{provide: Store, useClass: MockStore}], // podrzucamy naszą klasę poprzez useClass
    })
    .compileComponents();

    store = TestBed.get(Store);
    store.setState({
      garage: {
        cars: {
          carsList: [],
          soldCars: [],
        }
      }
    });
    fixture = TestBed.createComponent(CarsComponent);
    component = fixture.componentInstance;
    spyOn(store, 'dispatch');
    fixture.detectChanges();
  }));
...
});

Kluczowym momentem powyżej jest użycie metody setState – ustawiamy w niej stan wyłącznie dla naszego testu, począwszy od najwyższej gałęzi w State (dostarczamy stan globalny, ale tylko tyle, ile potrzebujemy!).

Minusy:

  • w przypadku użycia w komponencie selektorów bazujących na wielu wartościach ze Store z różnych modułów, musimy zadbać aby je wszystkie dostarczyć w setState

Plusy:

  • nie obchodzi nas mokowanie selektorów
  • kompatybilne z pipeable select operator i store.select
  • test wygląda czyściej i rozwiązanie jest lepiej skalowalne (można np. podrzucić initialState, jeśli nie potrzebujemy danych)
  • najlepsze – możemy dla dowolnego testu zawołać store.setState z innymi wartościami i przetestować np. specyficzny przypadek zachowania UI dla danego stanu

Cały test:

describe('CarsComponent', () => {
  let component: CarsComponent;
  let fixture: ComponentFixture<CarsComponent>;
  let store: MockStore<FeatureModuleCarsState>;

  beforeEach(async(() => {
    TestBed.configureTestingModule({
      imports: [StoreModule.forRoot({})],
      declarations: [CarsComponent],
      providers: [{provide: Store, useClass: MockStore}],
    })
    .compileComponents();

    store = TestBed.get(Store);
    store.setState({
      garage: {
        cars: {
          carsList: [],
          sold: [],
        }
      }
    });
    fixture = TestBed.createComponent(CarsComponent);
    component = fixture.componentInstance;
    spyOn(store, 'dispatch');
    fixture.detectChanges();
  }));

  describe('when fetch cars button is clicked', () => {
    beforeEach(() => {
      const button = fixture.debugElement.query(By.css('#fetch-cars-btn'));
      button.nativeElement.click();
    });

    it('should dispatch FetchCars action', () => {
      expect(store.dispatch).toHaveBeenCalledWith(new FetchCars());
    });
  });
});

Podsumowanie

Najtrudniejszym momentem jest przygotowanie modułu testowego, aby test w ogóle się poprawnie uruchomił. Samo sprawdzanie wysyłania akcji to banał. Pamiętaj, aby nie testować w teście klasy komponentu czy selektor zwraca dobrą wartość! Selektory mają swoje testy. Testuj wszystko, co uważasz za słuszne i istotne dla Twojego projektu :).

Swoją drogą, autorzy NgRx pracują nad swoim mockStore utilem do testowania. Więcej informacji tutaj:
https://github.com/ngrx/platform/issues/915
Właściwie to MockStore w 7.0.0-beta.1 został już zmergowany ale to świeża sprawa (bodajże z 25 listopada), ale do końca to jeszcze nie działa z wszystkimi zapowiadanymi funkcjonalnościami, jak np. mokowanie selektorów.
https://github.com/ngrx/platform/pull/1027#issuecomment-433054427

W każdym razie, jeśli już jedziesz na NgRx 7.0.0-beta.1 to polecam wypróbować ten już z @ngrx/testing:
https://github.com/ngrx/platform/pull/1027, funkcja provideMockStore

ps. nauczyłeś się czegoś nowego? to zostaw lajka na FB!

Podziel się artykułem

Zapisz się na nasz newsletter

Dołącz do community Angular.love i bądź na bieżąco z trendami.