Czym różnią się od siebie podstawowe rodzaje testów i kiedy po który sięgnąć? Sprawdź w jakich sytuacjach warto napisać test jednostkowy, integracyjny, komponentowy czy e2e – bo odpowiednio napisany test, to oszczędność czasu i pieniędzy.
Słowem wstępu…
W ostatnim wpisie poruszałem tematykę piramidy testów. Przedstawiłem wtedy trzy podstawowe rodzaje testów niskiego poziomu, czyli tych, które zazwyczaj piszą programiści na etapie development-u. Są to: testy jednostkowe (units), integracyjne oraz end to end. Umiejętność dobierania odpowiedniego rodzaju testu do funkcjonalności, potrzeb projektu i zasobów, to bardzo ważna cecha każdego programisty. Z artykułu o piramidzie testów już wiesz, jak to robić, a dzisiaj dowiesz się bardziej w szczegółach, kiedy warto napisać test danego typu w kontekście logiki, a także, jak to zrobić. Zapraszam do lektury!
Unit vs. komponentowy vs. integracyjny vs. e2e
Czym różnią się od siebie testy jednostkowe, komponentowe, integracyjne oraz e2e? Różnice wynikają w głównej mierze z tego, co chcemy przetestować. Jedne nadają się do testowania logiki naszego systemu, a inne do sprawdzenia czy dobrze zaimplementowaliśmy zewnętrznego dostawcę.
Dla tych, którzy ostatniego wpisu nie czytali, to zanim przejdziemy do przykładów, przypomnę pokrótce różnice pomiędzy rodzajami testów.
Test jednostkowy
Zacznijmy od unit-ów, które, jak sama nazwa wskazuje, służą do sprawdzania jednostek logiki. Ową jednostką nie musi być pojedyncza funkcja. W ten sposób możemy testować nawet flow składające się z wielu obiektów i metod. Warunkiem, aby test był jednostkowy jest testowanie bez wychodzenia poza kontekst aplikacji, np. nie wykonywanie zapytań http, testowanie bez połączenia z bazą, nie operowanie na plikach i inne tego typu operacje. Przyjmuje się, że unity nie powinny wychodzić również poza kontekst modułu/komponentu, ponieważ staną się wtedy testami komponentowymi, o czym zaraz powiem.
Jeżeli idzie o testy jednostkowe, to warto zauważyć, że istnieją dwie szkoły pisania tych testów: londyńska oraz detroitowska (lub, jak kto woli, w wersji polskiej: krakowska i poznańska :D). Londyńska szkoła pisania unitów zakłada mock-owanie każdego zew. elementu testowanej klasy / funkcji. Z kolei w wersji Detroit (zalecanej np. przez Uncle Bob-a), powinniśmy pozwolić, aby flow poszło na niższe elementy, a zmockować powinniśmy jedynie te fragmenty, które wychodzą poza lokalny kontekst, np. call do bazy danych.
Test integracyjny
Testy integracyjne służą do sprawdzania połączenia pomiędzy dwoma bytami. Testujemy tutaj faktyczną integrację, jej reguły oraz zachowania. W zamyśle, ten rodzaj testów wychodzi poza lokalny kontekst czy środowisko aplikacyjne. Wykonujemy tutaj zazwyczaj połączenie do elementu, z którym chcemy się integrować i sprawdzamy czy wysłane żądanie otrzymało spodziewany rezultat. Również w tym przypadku mamy kilka podrodzajów. Najpopularniejsze dwa to:
- Testowanie fragmentów systemu / modułu / modułów i zaślepianie nie interesujących nas części. Przykładowo, podejście top – down, gdzie testujemy górne warstwy naszego systemu, zaś dolne stub-ujemy. Test kończy się w momencie, gdy całe flow przejdzie. Np. call po REST API ze zmockowanym WSZYSTKIM, oprócz samego zapytania http i walidacji, aby sprawdzić czy działa ona poprawnie. Inne podejście, to Bottom – Up, sytuacja odwrotna do sposobu top – down. Sprawdzamy tutaj dolne partie systemu, zaś górne zaślepiamy. Przykład również może być ten sam, co przed chwilą, ale mockujemy walidację i inne elementy warstwy ui, a sprawdzamy czy system prawidłowo zareagował np. na brak połączenia z bazą danych. Oprócz tych dwóch sposobów możemy wyróżnić jeszcze np. mieszany albo big bang. Bajka jest ta sama, coś zaślepiamy, coś sprawdzamy, z jednoczesnym wyjściem poza lokalny kontekst.
- Testowanie integracji z dostawcami zewnętrznymi. Tym razem pomijamy nasz kod i logikę aplikacyjną, a sprawdzamy jedynie reguły komunikacji, kontrakt, pomiędzy nami, a zewnętrznym dostawcą. Możemy sprawdzić np. odpowiedź http od dostawcy usług kalendarza, kiedy wysyłamy konkretny zestaw danych.
Test e2e
Od końca, do końca czyli testy typu end to end. Służą one do przetestowania całego flow, od momentu przyjęcia żądania, aż po sprawdzenie czy wszystkie jego elementy zostały zrealizowane. Są one o wiele szersze, aniżeli testy integracyjne, ponieważ nie testujemy tutaj tylko jednej integracji, ale kompletność danego zadania, którego realizacja może wymagać połączenia z wieloma zewnętrznymi zasobami. Zasoby te mogą posiadać osobne testy integracyjne.
Test komponentowy
Często wyróżnia się również jeszcze jeden rodzaj testu: komponentowy. Jest to coś pomiędzy testem jednostkowym, a integracyjnym. W tym rodzaju sprawdzania wciąż znajdujemy się w obrębie środowiska aplikacyjnego, ale testujemy relacje pomiędzy komponentami naszego systemu. Można rzec, że jest to test integracyjny dwóch modułów, bądź komponentów naszej aplikacji, ale bez wychodzenia poza jej kontekst.
Test jednostkowy (unit): przykład
Wyobraźmy sobie, że posiadamy taki oto Value Object, o nazwie Reward, który reprezentuje nagrodę po stoczonej walce:
class Reward {
static readonly rewardableUnits = [
UnitName.combatProbe,
UnitName.havyFighter,
UnitName.cruiser,
];
private constructor(readonly money: Money, readonly units: UnitName[]) {}
static fromBattleScore(score: number): Reward {
return new Reward(
Money.create(score < 0 ? 0 : score),
[ this.rewardableUnits[Math.floor(score)] ]
);
}
}
Nie wnikając zbytnio w logikę, spróbujmy napisać test jednostkowy dla metody wytwórczej fromBattleScore, wiedząc, że Money jest klasycznym VO tego typu (sprawdź). Najpierw pójdziemy drogą londyńskiej szkoły i zmockujemy zew. elementy klasy Reward, czyli w tym przypadku, samo Money.
describe('Reward', () => {
describe('fromBattleScore', () => {
it('returns properly created Reward instance', () => {
const battleScore = 2.1;
const expectedMoney = { amount: battleScore };
jest.spyOn(Money, 'create').mockReturnValueOnce(expectedMoney);
const result = Reward.fromBattleScore(battleScore);
expect(result.money).toEqual(expectedMoney);
expect(result.units).toEqual([ Reward.rewardableUnits[2] ]);
});
});
});
Nie pozwalamy tutaj, aby unit wyszedł poza testowaną klasę, dlatego nie chcemy, aby logika zawarta w metodzie Money.create() miała wpływ na nasz test. Ten sam test w detroit-owskiej szkole testowania wyglądał będzie następująco. W tym przypadku wyszliśmy poza testowany temat i pozwoliliśmy, aby nasz unit miał nieco szerszy zasięg.
describe('Reward', () => {
describe('fromBattleScore', () => {
it('returns properly created Reward instance', () => {
const battleScore = 2.1;
const result = Reward.fromBattleScore(battleScore);
expect(result.money.amount).toEqual(battleScore);
expect(result.units).toEqual([ Reward.rewardableUnits[2] ]);
});
});
});
Teraz, zasadnicze pytanie, która opcja jest lepsza? Testować po londyńsku czy ditroitowsku? Na to pytanie nie mam niestety jednoznacznej odpowiedzi. Oba te sposoby mają swoje wady i zalety, a także szerokie grono entuzjastów. Osobiście, częściej zdarza mi się pisać testy w stylu Detroit, jednak w mojej skromnej opinii, to zależy od logiki, którą mamy do przetestowania. Gdybym miał napisać unit do klasy Reward w prawdziwym życiu, to skorzystałbym z opcji numer dwa, gdyż w tym przypadku możemy w łatwy sposób rozszerzyć testowany zakres i napisać nasz test „naturalnie”, tak, jak używałby Reward-a zwykły Kowalski. W sytuacji, kiedy interesowałoby nas sprawdzenie np. ile razy wykonała się jakaś metoda (pętla wywołująca zew. funkcję), to bardziej skłaniałbym się do zmockowania i pójścia drogą londyńską.
Daj znać w komentarzu, co Ty myślisz na ten temat. Jak wyglądają Twoje unity?
Test integracyjny: przykład
Kolej na test integracyjny. Zastanawiałem się, z czym najczęściej muszę się integrować w swojej pracy i wydaje mi się, że są to systemy notyfikacyjne, czy to mail-owo, sms-owo czy push notifications. Spróbujmy napisać przykład testu sprawdzającego integrację z takim właśnie serwisem, z którym porozumiewamy się za pomocą zapytań po protokole http.
Chcemy mieć pewność, że znane nam reguły komunikacji z zew. dostawcą są poprawne, a jeśli się zmienią, to nasz test integracyjny przestanie działać. Sprawdzanie takiego stanu rzeczy może wyglądać następująco:
describe('Notifications 3rd party', () => {
test('incorrect email', async () => {
const result = await HttpService.post(uri, {
body: {
to: 'not-an-email%com.pl',
message: 'Hello world!'
},
headers: { /** auth header */}
});
expect(result.body.code).toEqual(400);
});
});
Do takiego testu integracyjnego możemy dodać otoczkę aplikacyjną. Wywołać metodę na klasie kontrolującej lub nawet wykonać zapytanie http do naszego API, następnie zmockować inne elementy, np. bazę danych i sprawdzić czy nasz system odpowiednio zareaguje na błąd zewnętrznego dostawcy.
Test e2e: przykład
Jak wcześniej wspomniałem, w przypadku testów e2e sprawdzamy całe flow. Przykładem może być chęć przetestowania, czy po call-u http, z poprawnie wysłanymi dynami, nasze flow przeszło zgodnie ze wszystkimi wymaganiami. Możemy sprawdzić czy do bazy danych dodał się odpowiedni rekord, czy w serwisie notyfikacyjnym zostało zarejestrowane powiadomienie do wysyłki, a także czy odpowiedź z naszego interfejsu jest poprawna.
describe('Create user', () => {
test('happy flow', async () => {
const email = 'john@kowalski.example';
const result = await HttpService.post(uri, {
body: {
name: 'John Kowalski',
email
},
headers: { /** auth header */}
});
const notificationResult = await HttpService.get(notificationServiceUri, {
params: {
to: email,
type: 'hello_world'
},
headers: { /** auth header */}
});
expect(await database.findOne('users', { email })).toBeDefined();
expect(notificationResult.body.code).toEqual(200);
expect(result.responseCode).toEqual(200);
});
});
Podsumowanie
- Testy jednostkowe służą do sprawdzania logiki elementów wew. aplikacji.
- Pisząc test integracyjny staraj się nie testować więcej niż jednego rodzaju komunikacji.
- E2E to test kombajn. Sprawdza wszystko, od góry, do dołu.
Dodaj komentarz