Wiele osób używa frameworka Angular wraz z biblioteką do zarządzania stanem – NgRx i zapewne w pierwszych dniach pracy z tym narzędziem zastanawiało się, jak i co testować. Tak też było i ze mną :-). Z NgRx pracuję w komercyjnych aplikacjach i w tym wpisie chciałbym się podzelić wiedzą jak rozpocząć przygodę z testowaniem funkcjonalności NgRx. Wykonam przegląd podejść, z których korzystam, mam nadzieję, że będą dla Ciebie pomocne!
PS. W tym wpisie nie znajdziesz informacji dlaczego warto testować swój kod oraz czym są unit testy, pozostawiam to Tobie do zagłębienia we własnym zakresie. Skupię się wyłącznie na testowaniu NgRx z użyciem Jasmine. Artykuł jest przeznaczony dla osób już obytych z pracą w NgRx & Angular i ze znajomością frameworka do testowania Jasmine lub innego.
1. Co testować?
NgRx składa się z 4 głównych filarów:
– akcje
– reducery
– selektory
– efekty
Testuję wszystko z wyjątkiem klas “Action Creators”:
1 2 3 4 5 |
export class StoreCars implements Action { // to jest Action Creator readonly type = CarsActionTypes.STORE_CARS; constructor(public payload: { carsList: Car[] }) {} } |
Akcje nie posiadają i nie powinny posiadać swojej logiki, Czy akcja jest tworzona poprawnie, jest już sprawdzane chociażby podczas testów reducerów. Poza tym tworzenie instancji klasy z samym kostruktorem lub tworzenie akcji poprzez Factory function (używanie funkcji do produkcji akcji jest także OK) jest zbyt trywialną logiką do testowania w mojej ocenie i nie widzę powodów, aby pokrywać to testami. Zastanówmy się lepiej jak przetestować inne składowe.
Selektory
Testowanie selektorów jest bardzo istotne, ponieważ:
- często posiadają złożoną logikę, np. wykonują kalkulacje, filtrują dane ze Store etc.
- dany selektor jest często używany w wielu miejscach aplikacji np. z combineLatest, withLatestFrom lub mapowaniem na potrzeby klasy komponentu
Testuję wyłącznie selektory z logiką, pomijam testy dla selektorów, które wyłącznie wyciagają dane ze store po kluczu (to już jest otestowane przy okazji testów komponentów).
Przykład:
1 2 3 |
export const getTotalGarageValue = createSelector(getCars, (cars: Car[]) => { return cars.reduce((total, car) => total + car.price, 0); }); |
Powyższy selektor zwraca łączną wartość samochodów w naszym garażu.
Test:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
import {Car} from '../car.model'; const mockCars: Partial<Car>[] = [ { price: 1200 }, { price: 1300 }, ]; describe('CarsSelectors', () => { describe('getTotalGarageValue', () => { it('should return total value of all cars in the garage', () => { expect(fromCars.getTotalGarageValue.projector(mockCars)) .toEqual(2500); }); }); }); |
Selektory testuję poprzez sprawdzenie, czy “projector function” selektora:
1 2 3 |
(cars: Car[]) => { return cars.reduce((total, car) => total + car.price, 0); } |
zwraca to co powinien dla zadanych parameterów. Zatem mokuję minimalny, niezbędny zestaw danych dla paramteru tej funkcji (często korzystam z typu Partial aby nie tworzyć całego obiektu, dopóki nie mam takiej potrzeby):
1 2 3 4 |
const mockCars: Partial<Car>[] = [ { price: 1200 }, { price: 1300 }, ]; |
A następnie wywołuję metodę “projector” na selektorze, przekazuję argument i sprawdzam oczekiwaną zwrotkę:
1 2 3 |
it('should return total value of all cars in the garage', () => { expect(fromCars.getTotalGarageValue.projector(mockCars)).toEqual(2500); }); |
Bardzo wygodny w tym podejściu jest brak potrzeby mockowania całego Store. Interesuje mnie wyłącznie, czy selektor zwrócił to co powinien dla określonych argumentów. Pamiętaj, aby przetestować wszystkie sceneriusze dla Twojego selektora, jeden test case to często za mało dla bardziej skomplikowanych selektorów!
Reducery
W przypadku reducerów testuję każdy zapis do Store, niezależnie czy jest jakaś logika dla danego przypadku (staraj się tworzyć jak najbardziej trywialne reducery, zastanów się zawsze, czy jakaś skomplikowana logika w reducerze, nie powinna być w innym miejscu). Dodatkowo, reducery to pure functions – więc testowanie to czysta przyjemność :-).
Przykład:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
export interface State { carsList: Car[]; } export const initialState: State = { carsList: [], }; export function carsReducer(state = initialState, action: CarsActionsUnion): State { switch (action.type) { case CarsActionTypes.STORE_CARS: return { ...state, carsList: action.payload.carsList, }; default: { return state; } } } |
Chcemy teraz przetestować, czy dla akcji STORE_CARS, nastąpi prawidłowa zwrotka nowego stanu.
Test:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
import {carsReducer, initialState} from './cars.reducer'; import {StoreCars} from './cars.actions'; describe('carsReducer', () => { describe('CarsActionTypes.STORE_CARS', () => { it('should store cars', () => { const carsList = [{ id: 1, price: 300, model: 'Mazda', power: 300 }]; const action = new StoreCars({carsList}); const newState = carsReducer(initialState, action); const expectedState = {...initialState, carsList}; expect(newState).toEqual(expectedState); }); }); }); |
Powyższy kod jest dość prosty, nie ma za wiele tutaj do tłumaczenia. Sprawdzam, czy nowy stan zwrócony przez carsReducer dla akcji STORE_CARS, odpowiada oczekiwanemu stanowi.
Efekty
Efekty…tam naczęściej dzieje się najwięcej magii. W najbardziej typowym przypadku, gdzie w efektach wykonuję zapytania Http, testuję czy zostały zwrócone akcje na sukces i niepowodzenie. Jeśli mam efekt, który nie zwraca akcji (@Effect({dispatch: false}), to testuję, czy np. funkcja w operatorze “tap”, została zawołana z odpowiednim paramaterem.
Przykład:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
@Injectable() export class CarsEffects { @Effect() fetchCars$ = this.actions$.pipe( ofType(CarsActions.CarsActionTypes.FETCH_CARS), switchMap(() => this.carsService.getCars() .pipe( map(carsList => new CarsActions.StoreCars({ carsList })), catchError(() => of(new CarsActions.FetchCarsFailed())), ) ) ); constructor( private actions$: Actions, private carsService: CarsService, ) {} } |
Dla powyższego efektu przetestuję:
- czy zostanie zwrócona akcja StoreCars gdy carsService.getCars() zakończy się sukcesem (wejdzie do operatora map)
- czy zostanie zwrócona akcja FetchCarsFailed gdy carsService.getCars() zakończy się errorem (np. status Http 404, wejdzie do operatora catchError)
W przypadku testowania efektów mamy najwięcej pracy, gdyż musimy zamokować nasze wstrzyknięte serwisy i stream akcji (actions$).
Test:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 |
import {TestBed} from '@angular/core/testing'; import {provideMockActions} from '@ngrx/effects/testing'; import {of, ReplaySubject, throwError} from 'rxjs'; import {CarsEffects} from './cars.effects'; import {CarsService} from '../cars.service'; import {FetchCars, FetchCarsFailed, StoreCars} from './cars.actions'; const mockCars = [ { id: 1, price: 300, model: 'Mazda', power: 300 }, { id: 2, price: 300, model: 'Mazda', power: 300 } ]; describe('CarsEffects', () => { let carsEffects: CarsEffects; let carsService: CarsService; let actions$: ReplaySubject<any>; beforeEach(() => TestBed.configureTestingModule({ providers: [ CarsEffects, provideMockActions(() => actions$), { provide: CarsService, useValue: jasmine.createSpyObj('carsService', ['getCars']), }, ], })); beforeEach(() => { carsEffects = TestBed.get(CarsEffects); carsService = TestBed.get(CarsService); (carsService.getCars as jasmine.Spy).and.returnValue(of(mockCars)); }); describe('fetchCars$', () => { beforeEach(() => { actions$ = new ReplaySubject(1); actions$.next(new FetchCars()); // wysyłam akcje fetchCars, efekt się odpala }); it('should return a StoreCars action on success', () => { carsEffects.fetchCars$.subscribe(resultAction => { expect(resultAction).toEqual(new StoreCars({carsList: mockCars})); }); }); it('should return FetchCarsFailed action on failure', () => { (carsService.getCars as jasmine.Spy).and.returnValue(throwError({status: 404})); carsEffects.fetchCars$.subscribe(resultAction => { expect(resultAction).toEqual(new FetchCarsFailed()); }); }); }); }); |
Nową rzeczą może być dla Ciebie linia z następującym kodem:
1 |
provideMockActions(() => actions$), |
Powyższą funkcję importujemy z ‘@ngrx/effects/testing’. Jest to tzw. mock test provider dla strumienia actions$, przez który przechodzą wszystkie akcje NgRx, i na które nasłuchujemy w efektach. Owy provider, dostarcza nam nowego observabla dla każdego testu.
Używam również ReplaySubject przypisanego do actions$, aby emitować akcje, które zostają przechwycone przez efekty. Następnie w “subscribe” sprawdzam zwracane przez efekty akcje.
Pamiętaj zawsze o przetestowaniu akcji zwracanej w catchError! Wykorzystaj do tego observabla z errorem:
1 |
(carsService.getCars as jasmine.Spy).and.returnValue(throwError({status: 404})); |
Istnieje także inne podejście do testowania efektów z użyciem Jasmine Marbles. Przykład znajdziesz w demonstracyjnej aplikacji NgRx:
https://github.com/ngrx/platform/blob/master/projects/example-app/src/app/auth/effects/auth.effects.spec.ts
Podsumowanie
Wbrew pozorom, testowanie NgRx jest przyjemne. Czy testować? Tutaj jest tylko jedna odpowiedź – koniecznie!. Stan i przepływ danych z nim zwiazany jest kluczowym elementem w aplikacjach opartych o Angular & NgRx.
Pozostaje nam jeszcze testowanie komponentów świadomych Store, gdzie jest najwięcej zabawy w przygotowaniu dedykowanego modułu testowego. To jest temat na mój kolejny wpis (już w produkcji :)). Miłego testowania!
Pingback: Testowanie NgRx – komponenty – Angular.love
Fajny art.
Dla zainteresowanych dodałbym, że selektory dzielą się na 2 kategorie:
– getter selectors (wybierają tylko części stanu),
– derive selectors (zwracają view modele).
Wspomniałeś o tym, ale bez nazwania tych selektorów.
Z uwagi na brak czasu na testy w pracy, ja ograniczam się do testowania projector functions w selektorach oraz efektów, w których występują higher-order operators lub operatory ograniczające liczbę notyfikacji (first, take itp). Polecam również korzystanie z jasmine-marbles, dlatego że oprócz testowania można przy okazji dogłębnie zrozumieć działanie niektórych operatorów.
Jeżeli chodzi o mockowanie zwrotki z backendu polecam ten art:
https://netbasal.com/testing-observables-in-angular-a2dbbfaf5329
Cytując: ‘That’s what I call cheating’ 😀
metody it nie powinny poczekac tak jak to ma miejsce przy promisach?
hej Darek,
It poczeka w momencie gdy beforeEach opakujesz w helpera async(…) ale to ma sens gdy w beforeEach dzieje się coś asynchornicznego.
Dzieki za odpowiedz.
Mam jeszcze jedne pytanie. Co w przypadku gdy mamy timer w pipe?
A po co Ty chcesz mieć timer w Pipe?