02 cze 2022
5 min

ng-mocks – z czym to się je?

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!

Podziel się artykułem

Zapisz się na nasz newsletter

Dołącz do community Angular.love i bądź na bieżąco z trendami.