Czy kiedykolwiek miałeś problem z przetestowaniem serwisów w Angularze? W jaki sposób poprawnie wykonać testy jednostkowe usług HTTP? Na to pytanie odpowie nam Tomasz Borowski, którego zaprosiłem do wpisu gościnnego na moim blogu.
Tomasz Borowski to frontend developer z ponad 10 letnim doświadczeniem programistycznym, pracujący na co dzień przy implementacji złożonych aplikacji biznesowych w frameworku Angular. Tomasz był wielokrotnie speakerem na konferencjach lokalnych oraz międzynarodowych – w tym Agile Lean Europe oraz Agile Cambridge. Od ponad roku prowadzi także serię warsztatów szkoleniowych Angular in Space.
Skoro zapoznaliśmy się już z sylwetką autora, czas przejść do meritum 😉
Testowanie zapytań HTTP w Angular
Jedną z najistotniejszych odpowiedzialności usług Angularowych jest przygotowywanie danych na potrzeby komponentów. Najczęściej takie dane są pozyskiwane za pomocą zapytań HTTP, a następnie przetwarzane przy pomocy operatorów RxJS. Efektem końcowym jest zgrabne wykorzystanie gotowych danych w komponencie, który nie zna “szczegółów technicznych” ich pozyskania.
Podczas pisania testów jednostkowych dla takiej usługi wykonującej zapytania HTTP będziemy chcieli zasymulować (zamockować) wysyłanie zapytań tak, aby nie było konieczności odpytywania prawdziwego API. Rozważmy zatem dwa sposoby na poradzenie sobie w testach z zależnością HttpClient (dostępną w Angular od wersji 4.3) poprzez wykorzystanie metody spyOn z frameworka Jasmine oraz wykorzystanie HttpClientTestingModule.
Co potrzebujesz wiedzieć?
Zakładamy, że potrafisz już korzystać z usługi HttpClient oraz znasz podstawy testowania jednostkowego z biblioteką Jasmine. Jeśli nie to zachęcamy do zapoznania się z guidem do HttpClient oraz wprowadzeniem do Jasmine.
Co będziemy testować?
Przedmiotem naszych testów będzie usługa CarService, która udostępnia metodę getCars(query) do wyszukiwania samochodów za pomocą zapytań HTTP. Dane przychodzące z API są zamieniane na obiekt (za pomocą operatora map), który zawiera liczbę zwróconych wyników oraz kolekcję samochodów. W razie niepowodzenia w pobieraniu danych reemitujemy treść komunikatu błędu za pomocą operatora catchError oraz _throw. Zatem czeka nas rozważenie w testach dwóch przypadków: gdy zapytanie powiodło się oraz gdy zapytanie nie powiodło się.
Poniższa implementacja wykorzystuje tzw. pipeable operators, które są dostępne w RxJS od wersji 5.5, która jest wspierana przez Angular od wersji 5.0.0. Z racji na obecność słów zastrzeżonych część operatorów jest dostępna pod innymi nazwami: catch -> catchError, throw -> _throw.
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 |
import { Injectable } from '@angular/core'; import { HttpClient, HttpErrorResponse } from '@angular/common/http'; import { catchError, map } from 'rxjs/operators'; import { _throw } from 'rxjs/observable/throw'; import { Observable } from 'rxjs/Observable'; export interface Car { name: string, productionYear: string, doorCount: number } export interface CarCollection { collection: Car[], totalCount: number } @Injectable() export class CarService { constructor(private http: HttpClient) {} getCars(query: string): Observable<CarCollection> { return this.http.get<Car[]>('/api/cars', {params: {q: query}}).pipe( map((cars) => { return { totalCount: cars.length, collection: cars }; }), catchError((response: HttpErrorResponse) => _throw(response.error.message)) ); } } |
Testy z wykorzystaniem funkcji spyOn
Przystępując do jednostkowego testowania usługi CarService musimy “odciąć się” od jej zależności, które często wprowadzają własne, dodatkowe zależności. Nie inaczej jest w przypadku usługi HttpClient, dlatego na potrzeby testów utworzymy sobie prostą klasę FakeHttp, która powinna mieć zgodny interfejs z klasą HttpClient.
1 2 3 4 5 6 |
class FakeHttp { get(url, options) {} post(url, body, options) {} put(url, body, options) {} delete(url, options) {} } |
Następnie wykorzystując klasę TestBed konfigurujemy moduł testowy i łapiemy referencje do zarejestrowanych usług. Zwróć uwagę, że pod nazwą HttpClient w rzeczywistości rejestrujemy uproszczoną klasę FakeHttp.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
describe('CarService', () => { let carService, http; beforeEach(() => { TestBed.configureTestingModule({ providers: [ CarService, { provide: HttpClient, useClass: FakeHttp } ] }); carService = TestBed.get(CarService); http = TestBed.get(HttpClient); }); describe('getCars', () => { // tutaj zaimplementujemy testy }); }); |
Przystępując do testowania metody getCars(query) mamy do rozważenia dwa przypadki: gdy request się powiedzie oraz gdy request się nie powiedzie. W pierwszym przypadku wykorzystamy funkcję spyOn z Jasmine i zamockujemy wynik wykonania metody http.get by był to strumień RxJS zawierający kolekcję samochodów. Do utworzenia strumienia wykorzystujemy operator tworzący of.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
describe('when request is successful', () => { let cars: Car[]; beforeEach(() => { cars = [ {name: 'Maluch', doorCount: 4, productionYear: '1974'}, {name: 'Ferrari', doorCount: 3, productionYear: '2018'} ]; spyOn(http, 'get').and.returnValue(of(cars)); }); // tutaj zaimplementujemy testy }); |
Mając odpowiednio przygotowane warunki testowe możemy zweryfikować czy wykonanie getCars(query) wykona metodę http.get z poprawnymi parametrami oraz czy zwrócony strumień będzie zawierał odpowiednio przygotowane dane, za co jest odpowiedzialny operator map.
1 2 3 4 5 6 7 8 9 10 11 |
it('should make request to API', () => { carService.getCars('klasyk'); expect(http.get).toHaveBeenCalledWith('/api/cars', {params: {q: 'klasyk'}}); }); it('should return cars collection', () => { carService.getCars('klasyk').subscribe((carCollection) => { expect(carCollection.totalCount).toEqual(2, 'contains total count of cars'); expect(carCollection.collection).toEqual(cars, 'contains cars collection') }); }); |
Drugim przypadkiem występującym w metodzie getCars(query) jest niepowodzenie requesta. W celu przygotowania warunków testowych ponownie korzystamy z funkcji spyOn, ale tym razem mockujemy metodę http.get by zwracała błąd w strumieniu. Do utworzenia takiego strumienia wykorzystujemy operator tworzący _throw.
1 2 3 4 5 6 7 8 |
describe('when request is failed', () => { beforeEach(() => { spyOn(http, 'get').and.returnValue(_throw({error: new Error('Service unavailable')})); }); // tutaj zaimplementujemy testy }); |
W przypadku niepowodzenia requestu będziemy chcieli sprawdzić, czy zwracany jest komunikat błędu w strumieniu RxJS – a więc czy poprawnie wykorzystaliśmy operator catchError.
1 2 3 4 5 |
it('should return error message', () => { carService.getCars('klasyk').subscribe({error: (errorMessage) => { expect(errorMessage).toEqual('Service unavailable'); }}); }); |
W ten sposób solidnie przetestowaliśmy metodę getCar(query) z wykorzystaniem funkcji spyOn do mockowania wykonań metody http.get. Istnieje także inne podejście do tego samego tematu, wykorzystujące moduł HttpClientTestingModule.
Testy z wykorzystaniem HttpClientTestingModule
Moduł HttpClientTestingModule został dodany do Angulara, by uprościć testowanie usług wykorzystujących usługę HttpClient. Na początku musimy skonfigurować za pomocą klasy TestBed moduł testowy, który będzie importował HttpClientTestingModule. Wśród usług, jakie rejestruje ten moduł, jest usługa HttpTestingControler, którą wykorzystamy do sprawdzania wychodzących zapytań, a także do definiowania zwracanych odpowiedzi.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
describe('CarService', () => { let carService, httpMock; beforeEach(() => { TestBed.configureTestingModule({ imports: [HttpClientTestingModule], providers: [CarService] }); carService = TestBed.get(CarService); httpMock = TestBed.get(HttpTestingController); }); describe('getCars', () => { // tutaj zaimplementujemy testy }); }); |
Następnym krokiem będzie przetestowanie przypadku, gdy request kończy się powodzeniem. W ramach przygotowania warunków testowych wystarczy, że zdefiniujemy kolekcję danych, jakie będą zwracane w odpowiedzi na zapytanie
1 2 3 4 5 6 7 8 9 10 11 12 13 |
describe('when request is successful', () => { let cars: Car[]; beforeEach(() => { cars = [ {name: 'Maluch', doorCount: 4, productionYear: '1974'}, {name: 'Ferrari', doorCount: 3, productionYear: '2018'} ]; }); // tutaj zaimplementujemy testy }); |
Nasz test będzie weryfikował czy getCars(query) zwraca strumień z dokładnie takim obiektem, jaki sobie zaplanowaliśmy. W podejściu wykorzystującym HttpClientTestingModule najpierw wykonujemy kod powodujący zapytanie, a następnie za pomocą metody httpMock.expectOne wybieramy jaki request chcemy zbadać. Aby zdefiniować odpowiedź na wybrany request korzystamy z metody flush – dopiero po jej wykonaniu zostaną sprawdzone nasze oczekiwania znajdujące się w subscribe. Kod odpowiedzialny za obsługę zapytania możemy także przenieść do metody afterEach, dzięki czemu zyskamy na czytelności testów.
1 2 3 4 5 6 7 8 9 10 11 12 |
afterEach(() => { const req = httpMock.expectOne('/api/cars?q=klasyk'); expect(req.request.method).toEqual('GET'); req.flush(cars); }); it('should return cars collection', () => { carService.getCars('klasyk').subscribe((carCollection) => { expect(carCollection.totalCount).toEqual(2, 'contains total count of cars'); expect(carCollection.collection).toEqual(cars, 'contains cars collection') }); }); |
Pozostał nam do sprawdzenia drugi przypadek, w którym request kończy się niepowodzeniem. Tutaj wykorzystujemy metodę error by odpowiedzieć na zapytanie błędem. Wewnątrz subskrypcji weryfikujemy czy w strumieniu zwracany jest odpowiedni komunikat błędu.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
describe('when request is failed', () => { afterEach(() => { const req = httpMock.expectOne('/api/cars?q=klasyk'); expect(req.request.method).toEqual('GET'); req.error(new Error('Service unavailable')); }); it('should return error message', () => { carService.getCars('klasyk').subscribe({error: (errorMessage) => { expect(errorMessage).toEqual('Service unavailable'); }}); }); }); |
W ten sposób dokładnie przetestowaliśmy metodę getCar(query) z wykorzystaniem modułu HttpClientTestingModule. Więcej o możliwościach tego modułu możesz przeczytać tutaj.
Podsumowanie
Bardzo istotną częścią testowania jednostkowego jest umiejętne zastąpienie zależności klasy, którą mamy zamiar przetestować. W przypadku usługi HttpClient możemy to zrobić na co najmniej dwa sposoby: wykorzystując funkcję spyOn z biblioteki Jasmine lub wykorzystując moduł HttpClientTestingModule. To drugie rozwiązanie dostarczane jest wraz z frameworkiem Angular i eliminuje konieczność ręcznego tworzenia strumieni zawierających odpowiedź na request. Dodatkowo jeśli nasza aplikacja korzysta z interceptorów, to testowanie zapytań HTTP będzie znacznie wygodniejsze przy wykorzystaniu HttpClientTestingModule. W przypadku mniej zaawansowanych zastosowań usługi HttpClient oba podejścia są poprawne, a jego wybór sprowadza się do osobistych preferencji.
Duże podziękowania dla Tomasza za bardzo merytoryczny wpis!
Oby od dzisiaj testowanie usług HTTP, nie miało przed Tobą tajemnic 🙂 Chciałbym również dodać, że wraz z Tomaszem pracujemy wspólnie od pół roku nad projektami dla branży farmaceutycznej. Ogromna wiedza Tomasza z Angulara zrobiła na mnie duże wrażenie, stąd śmiało mogę zareklamować szkolenia prowadzone przez Tomasza pod marką Angular In Space.
https://www.facebook.com/angularinspace/
Zatem jeśli planujesz doszlifować swoją wiedzę za pomocą profesjonalnego szkolenia, zajrzyj na stronę i zapoznaj się z dostępnymi terminami szkoleń w całej Polsce.
Z radością również informuję, że Angular In Space zostaje partnerem społecznym bloga angular.love, chcemy się nawzajem wspierać w promocji Angulara w Polsce.
No cóż! to tyle na dzisiaj, a może to Ty chciałbyś wystąpić z kolejnym wpisem gościnnym? 😉 zapraszam do kontaktu:
Dodaj komentarz