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:
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.
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:
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:
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:
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:
MockInstance(SampleService, 'fetch', () => { /* ... */ })
Zauważmy, że ostatni z argumentów wykorzystać można do łatwego stworzenia obiektu typu spy:
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
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
TestBed
.configureTestingModule({
providers: [
{ provide: Environment, useValue: customEnvironment }
]
});
const pipe = new SpeedUnitPipe(
TestBed.inject(Environment)
);
return { pipe };
Serwis
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
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
TestBed.configureTestingModule({
providers: [ MockProvider(Environment, customEnvironment) ]
});
const pipe = new SpeedUnitPipe(
TestBed.inject(Environment)
);
return { pipe };
Serwis
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
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
const testingModule = MockBuilder()
.mock(Environment, customEnvironment)
.build();
TestBed.configureTestingModule(testingModule);
const pipe = new SpeedUnitPipe(
TestBed.inject(Environment)
);
return { pipe };
Serwis
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!