Każdy kto pisał testy (niekoniecznie w Angularze) z pewnością wie, że zmockowanie zależności testowanego elementu potrafi niekiedy być zmorą. Zależy to oczywiście od jakości kodu w naszym projekcie czy choćby stopnia jego skomplikowania. Spójrzmy na taki przykład:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
TestBed.configureTestingModule({ declarations: [ // The only declaration we care about. AppBaseComponent, // Dependencies. AppHeaderComponent, AppDarkDirective, TranslatePipe, // ... ], imports: [ CommonModule, AppSearchModule, // ... ], providers: [ SearchService, // ... ], }); |
(snippet z dokumentacji ng-mocks)
Nadmiar takich zależności może skutecznie opóźnić nam przejście do pisania właściwych przypadków testowych. Dlaczego by w takim razie nie umilić sobie życia, skracając i upraszczając konfigurację modułu testowego?
Przyjrzyjmy się więc bibliotece ng-mocks, która w założeniu ma nam pomóc w wyżej wymienionych obszarach. Wspiera ona Angulara od wersji 5 wzwyż (w nowszych wersjach działa również z Ivy), współpracuje zarówno z jest jak i jasmine – jej kompatybilność jest więc całkiem niezła.
A więc – czy warto instalować do swojego projektu kolejną bibliotekę? W tym artykule postaram się Wam pomóc znaleźć odpowiedź na to pytanie.
Uwaga odnośnie kodu
Wszystkie snippety napisane są z użyciem architektury SIFERS (Simple Injectable Functions Explicitly Returning State). Aby nie odbiegać od tematu artykułu – tym z Was którzy nie zetknęli się z SIFERS nigdy wcześniej polecam świetny artykuł Moshe Kolodnego
Podejście z użyciem helperów
W swojej najprostszej formie biblioteka ng-mocks udostępnia zestaw funkcji pomocniczych, które ułatwiają korzystanie z Angularowej klasy TestBed. Choć klasa ta nie wydaje się trudna w użyciu, to sprawy komplikują się tym bardziej im bardziej skomplikowane jest nasze drzewko zależności. To skutecznie wydłuża ilość mocków które musimy przygotować, aby pisanie przypadków testowych było w ogóle możliwe – a i tak bardzo prawdopodobne, że następna godzina minie nam pod znakiem czerwonych komunikatów w rodzaju “NullInjectorError: No provider for XXX”.
Zobaczmy więc co dobrego oferuje nam ng-mocks:
MockComponent
Funkcja tworzy mock komponentu podanego typu. Zachowa on oryginalny interfejs (co obejmuje Inputy, Outputy, selektor, wsparcie dla transkluzji i kilka innych właściwości), ale cała jego implementacja będzie pusta.
MockModule
Analogicznie, z tym że tworzy mock wskazanego modułu. Podobnie jak w przypadku komponentu odwzoruje ona interfejs ale implementacja będzie pusta. Dodatkowo zmockuje wszystkie pośrednie zależności importowane przez podany moduł – w większości przypadków nie będziemy w ogóle musieli się nimi interesować.
MockProvider
Funkcja pozwala na zmockowanie providera, akceptuje Serwisy i InjectionTokeny. Pokrywa różne sposoby definiowania providera w standardowy sposób.
MockService, MockDirective i MockPipe
Te funkcje umożliwiają zmockowanie elementów o typach wskazanych w nazwie helpera. Dokładniejsze opisy wraz z przykładami znaleźć można w dokumentacji biblioteki (o tutaj).
Jak widać przeznaczenie helperów jest dość intuicyjne. Spójrzmy teraz na przykładowy kod poniżej. Nie używa on jeszcze ng-mocks. Cały prezentowany w tym artykule kod można pobrać z mojego githuba, do którego link umieściłem na końcu artykułu.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
await TestBed .configureTestingModule({ declarations: [ WeatherWidgetComponent, SpeedUnitPipe, TemperatureUnitPipe ], providers: [ { provide: Environment, useValue: {} }, { provide: WeatherService, useValue: { fetchCurrent: () => Promise.resolve(sampleWeather) }} ] }) .overridePipe(SpeedUnitPipe, {}) .overridePipe(TemperatureUnitPipe, {}) .compileComponents(); |
Zastosujmy opisane helpery do powyższego przykładu. Otrzymamy następujący kod:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
await TestBed .configureTestingModule({ declarations: [ WeatherWidgetComponent, MockPipe(SpeedUnitPipe), MockPipe(TemperatureUnitPipe) ], providers: [ MockProvider(Environment), MockProvider(WeatherService, { fetchCurrent: () => Promise.resolve(sampleWeather) }) ] }) .compileComponents(); |
Pierwsze co rzuca się w oczy to dużo bardziej przejrzysta składnia. Czy jest to wartość dodana? W bardzo małych projektach różnica będzie czysto akademicka, więc niekoniecznie. Natomiast wraz ze wzrostem skali i skomplikowania może być to już niezłym atutem. Zwłaszcza jeśli będziemy wracać do napisanych przez nas testów po dłuższym czasie lub pracować z testami napisanymi przez innego programistę. A to jeszcze nie koniec.
Użycie MockBuildera
Pozwolę sobie zacytować dokumentację biblioteki – w wolnym tłumaczeniu “MockBuilder to najłatwiejszy sposób na zmockowanie właściwie wszystkiego”. No to jedziemy z tematem.
MockBuilder
Jest to funkcja służąca do mockowania różnych elementów napisana w formie fluent API, tj łańcucha metod o – jakby to opisał Wujek Bob – znaczących nazwach (jeśli nie wiesz kim jest Uncle Bob koniecznie to sprawdź jeszcze dziś wieczorem!). Wracając jednak do omawianej funkcji. Sama z siebie może przyjąć dwa opcjonalne argumenty. Pierwszy określa element który nie powinien zostać zmockowany (może nim być komponent lub InjectionToken), zaś drugi – moduł do zmockowania wraz ze wszystkimi zależnościami. Argumenty te mogą być używane razem lub pojedynczo, można też nie podawać ich w ogóle.
Co więcej, funkcja ta tworzy wygodny i czytelny blok kodu, który w zasadzie wyczerpuje temat przygotowania środowiska i pozwala nam skoncentrować się na pisaniu konkretnych przypadków testowych. Zresztą, niech przemówi kod – spróbujmy przebudować poprzedni fragment:
1 2 3 4 |
await MockBuilder(WeatherWidgetComponent, WeatherModule) .mock(WeatherService, { fetchCurrent: () => Promise.resolve(sampleWeather) }); |
Jak widać MockBuilder dużą część roboty robi za nas. Sama funkcja jest dobrze opisana w dokumentacji, więc nie będę powielać tych informacji tutaj. Dość powiedzieć, że nawet gdy wszystko inne zawiedzie wciąż można odwołać się do starej, dobrej klasy TestBed:
1 2 3 4 5 |
await MockBuilder(WeatherWidgetComponent, WeatherModule) .mock(WeatherService, { fetchCurrent: () => Promise.resolve(sampleWeather) }) .beforeCompileComponents(testBed => { /* ... */ }); |
MockRender
Zgodnie z nazwą funkcja ta służy do renderowania komponentów. Używa wewnątrz angularowej metody TestBed.createComponent, ale co warto zaznaczyć zwraca inny typ od swojego pierwowzoru: MockedComponentFixture<Component>. Dla dociekliwych – cel stosowania takiej pośredniej konstrukcji twórcy biblioteki wyjaśniają tutaj.
MockInstance
Funkcja ta ułatwia przygotowywanie i dostosowanie kształtu instancji danej klasy, co jest szczególnie użyteczne w przypadku tworzenia obiektów typu spies.
Spójrzmy na przykład użycia takiej funkcji:
1 |
MockInstance(SampleService, 'fetch', () => { /* ... */ }) |
Zauważmy, że ostatni z argumentów wykorzystać można do łatwego stworzenia obiektu typu spy:
1 2 3 |
MockInstance(SampleService, 'fetch', () => jest.fn().mockImplementation(() => { /* ... */ }) ) |
Otrzymujemy więc kolejne narzędzie, które dla programisty może okazać się użyteczne. Po więcej odsyłam do dokumentacji.
ngMocks
Nazwa powyżej to namespace agregujący liczne helpery o różnym zastosowaniu. Aby nie przedłużać artykułu raz jeszcze odeślę Cię do dokumentacji biblioteki.
Korzyści
Spróbujmy podsumować omawianą bibliotekę. Otrzymujemy pakiet funkcji-helperów usprawniających “klasyczne” podejście oparte o klasę TestBed. Oprócz tego mamy do dyspozycji funkcję MockBuilder, która daje bardzo szerokie możliwości i ogranicza użycie TestBed jedynie do szczególnych przypadków. Ograniczyliśmy ilość kodu konfigurującego środowisko i zautomatyzowaliśmy tworzenie mocków, co pozwoli skupić się na kodzie testowym – a więc tym, co powinno być najbardziej istotne.
Odpowiedź nasuwa się sama i – o ile nasz projekt nie jest naprawdę mały – moja opinia będzie krótka. Warto.
Podsumowanie
Wszystkie prezentowane powyżej przykłady kodu były częścią przykładowej appki, przygotowanej z myślą o tym artykule. Całość kodu można pobrać z mojego githuba, tu pokażę jedynie ciało funkcji setup() – konkretne przypadki testowe są już bowiem identyczne w każdym przypadku.
Bez ng-mocks
Komponent
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
const sampleWeather = [ { timePoint: 1 } ]; await TestBed .configureTestingModule({ declarations: [ WeatherWidgetComponent, SpeedUnitPipe, TemperatureUnitPipe ], providers: [ { provide: Environment, useValue: {} }, { provide: WeatherService, useValue: {fetchCurrent: () => Promise.resolve(sampleWeather)} } ] }) .overridePipe(SpeedUnitPipe, {}) .overridePipe(TemperatureUnitPipe, {}) .compileComponents(); const fixture = TestBed.createComponent(WeatherWidgetComponent); const component = fixture.componentInstance; return { sampleWeather, fixture, component }; |
Pipe
1 2 3 4 5 6 7 8 9 10 11 12 |
TestBed .configureTestingModule({ providers: [ { provide: Environment, useValue: customEnvironment } ] }); const pipe = new SpeedUnitPipe( TestBed.inject(Environment) ); return { pipe }; |
Serwis
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
const environment: Environment = { apiUrl: 'SAMPLE', system: null }; TestBed .configureTestingModule({ providers: [ { provide: HttpClient, useValue: {get: (...args) => of(null)} }, { provide: Environment, useValue: environment }, WeatherService, ] }); const httpClient = TestBed.inject(HttpClient); const service = TestBed.inject(WeatherService); const httpGet: jest.SpyInstance = jest.spyOn(httpClient, 'get'); return { environment, httpClient, service, httpGet }; |
Ng-mocks – tylko helpery
Komponent
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
const sampleWeather = [ { timePoint: 1 } ]; await TestBed .configureTestingModule({ declarations: [ WeatherWidgetComponent, MockPipe(SpeedUnitPipe), MockPipe(TemperatureUnitPipe) ], providers: [ MockProvider(Environment), MockProvider(WeatherService, { fetchCurrent: () => Promise.resolve(sampleWeather) }) ] }) .compileComponents(); const fixture = MockRender(WeatherWidgetComponent); const component = fixture.point.componentInstance; return { sampleWeather, fixture, component }; |
Pipe
1 2 3 4 5 6 7 8 9 |
TestBed.configureTestingModule({ providers: [ MockProvider(Environment, customEnvironment) ] }); const pipe = new SpeedUnitPipe( TestBed.inject(Environment) ); return { pipe }; |
Serwis
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
const environment: Environment = { apiUrl: 'SAMPLE', system: null }; TestBed .configureTestingModule({ providers: [ MockProvider(HttpClient), MockProvider(Environment, environment), WeatherService ] }); const httpClient = TestBed.inject(HttpClient); const service = TestBed.inject(WeatherService); const httpGet: jest.SpyInstance = jest.spyOn(httpClient, 'get'); return { environment, httpClient, service, httpGet }; |
Ng-mocks z użyciem MockBuildera
Komponent
1 2 3 4 5 6 7 8 9 10 11 12 13 |
const sampleWeather = [ { timePoint: 1 } ]; await MockBuilder(WeatherWidgetComponent, WeatherModule) .mock(WeatherService, { fetchCurrent: () => Promise.resolve(sampleWeather) }); const fixture = MockRender(WeatherWidgetComponent, { current$: new BehaviorSubject(sampleWeather) }); const component = fixture.point.componentInstance; return { sampleWeather, fixture, component }; |
Pipe
1 2 3 4 5 6 7 8 9 10 11 |
const testingModule = MockBuilder() .mock(Environment, customEnvironment) .build(); TestBed.configureTestingModule(testingModule); const pipe = new SpeedUnitPipe( TestBed.inject(Environment) ); return { pipe }; |
Serwis
1 2 3 4 5 6 7 8 9 10 11 12 13 |
const environment = { apiUrl: 'SAMPLE', system: null }; const testingModule = MockBuilder(WeatherService, WeatherModule) .mock(Environment, environment) .build(); TestBed.configureTestingModule(testingModule); const httpClient = TestBed.inject(HttpClient); const service = TestBed.inject(WeatherService); const httpGet: jest.SpyInstance = jest.spyOn(httpClient, 'get'); return { environment, httpClient, service, httpGet }; |
Tu zostawiam Was drodzy Czytelnicy do wyrobienia własnej opinii. Jeżeli nie znacie tej biblioteki zachęcam gorąco do jej pobrania i wypróbowania. Miłego pisania!
Dodaj komentarz