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:
- wyłącznie dispatchują akcje (store.dispatch)
- 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:
1 |
<button (click)="fetchCars()" id="fetch-cars-btn">Fetch cars</button> |
klasa:
1 2 3 4 5 6 7 8 9 10 11 |
@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:
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 |
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:
1 |
imports: [StoreModule.forRoot({})], |
W samym teście, interesuje nas, czy na kliknięcie została wysłana akcja:
1 2 3 |
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())” :
1 |
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:
1 2 3 4 |
export class CarsComponent { cars$ = this.store.select(fromCars.getCars); ... } |
I do templatki asyncPipe:
1 2 3 |
<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:
1 |
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 ;):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
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:
1 2 3 4 5 |
// parametryzowany selektor export const getCar = (id: number) => createSelector(getCars, (cars: Car[]) => { return cars.find(car => car.id === id); }); ... |
1 2 |
// użycie w klasie komponentu car$ = this.store.select(fromCars.getCar(5)).subscribe(); |
1 2 3 4 5 6 |
// 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:
1 2 3 4 5 6 |
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:
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 |
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:
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 |
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!
Siema, takie pytanie:
Testując componenty defakto sa to testy integracyjne a nie jednostkowe. Wiec czy jest sens mockowania State ?
Jeśli napisałbyś ten test bez mokowanie stata to wtedy podczas configureTestingModule wogole nie trzeba podawac providers: [State].
Wciąż możesz wyciągnąć state i go zespyowac zeby sprawdzic czy akcja została z dispatchowana.
W drukim przypadku jak chcesz sprawdzic czy dana sie wyświetlone ze stata. Mozesz przekazac state zamiast pustego obiektu w:
StoreModule.forRoot({ feature: fromFeature.reducer })
.Dzieki temu test staje sie „super integracyjny” Bo sprawdzi tez czy cały Store działa poprawnie.
Dodatkowo sam test jest duzo bardziej czytelny.
P.S Jesli twoj initialState ktore przekazałes do testu w fromFeature.reducer nie ma odpowiedniej wartosci. Wciaz mozesz normalnie z dispachowac akcje ktora Ci ustawi odpowiedni state i dopiero potem wykonać jakąś assercje.
P.S.2 To podejscie sprawdza sie tez przy testowanie Effektow.
Co o tym myślisz?
hej Tomek,
„Testując componenty defakto sa to testy integracyjne a nie jednostkowe. ” -> właściwie mix integracyjnych i jednostkowych, w zależności co testujemy z komponentu :).
—-
„Wciaz mozesz normalnie z dispachowac akcje ktora Ci ustawi odpowiedni state i dopiero potem wykonać jakąś assercje.” – to podejście jest słabe w przypadku testu komponentów, zwłaszcza jak mamy komponent, który wyciąga wiele rzeczy ze stanu, wtedy musisz dispatchować X akcji na starcie.
—-
„…wogole nie trzeba podawac providers: [State].” – jak w ogóle nie trzeba? test od razu się wysypie z powodu braku dostarczenia zależności komponentu.
—-
„
StoreModule.forRoot({ feature: fromFeature.reducer })
.Dzieki temu test staje sie „super integracyjny” Bo sprawdzi tez czy cały Store działa poprawnie.” – to może być niewygodne w przypadku bardziej skomplikowanych sytuacji, jak np. mapa reducerów jest bardziej rozbudowana a komponent wyciąga ze stanu rzeczy z różnych części store, nie tylko z danej części z reducerami dodanymi do testu. I znowu pojawia się problem, jak chcemy zasymulować konkretny stan store, wracamy do dispatchowania akcji. Wygodniej jest dla mnie użyć store.setState(…), które np. dla mnie będzie bardziej czytelne.
pzdr!
Dziwne u mnie te testy przechodza bez podawania providers: [Store]. Wydaje mi sie że u Ciebie testy sie wywalaja bo podajesz pusty state w forRoot.
Nie musisz dispatchowac akcji mozesz na starcie podac taki state ktory jest wymagany do testu komponentu.
Nie do końca rozumiem 🙂
„Nie musisz dispatchowac akcji mozesz na starcie podac taki state ktory jest wymagany do testu komponentu.”
gdzie mam go podać? możesz wkleić przykład Twojego testu komponentu?
Chodzi mi o to, że albo uzywasz w testach smart componentow. `imports: [StoreModule.forRoot({})] ALBO providers: [Store].
Uzywanie obu wg mnie jest nie poprawne. Jesli uzywasz imports to wtedy testujesz component w założeniu że sa to testy integracyjne (Sprawdzaja component i caly Store)
Jesli używasz providets to testujesz unitowo.
Jeśli używasz StoreModule.forRoot to on juz sam dodaje Store wiec nie musisz go znowy dodawac w providers.
https://github.com/ngrx/platform/blob/7.0.0/modules/store/src/store_module.ts#L140
Tutaj jest repo. Napisalem testy integracyjne z danym statem
https://github.com/Tomek6789/AngularTests/blob/master/tests/src/app/app.component.spec.ts#L21