Odwrócona piramida testów

15

Jako że @Afish striggerował mnie pewnym postem, postanowowiłem wyjaśnić swoją krytykę mocków, tj. czemu uważam że Mockito i podobne biblioteki(jak Moq w C#) są stosowane nadmiernie i nic ne wnoszą.
Teraz żeby być bardziej precyzyjnym, pisząc o mockach mam na myśli rzeczy typu:

ValueGenerator vgMock = Mockito.mock(ValueGenerator.class);
when(vgMock.getValue()).thenReturn(7)

Jest jeszcze Wiremock czyli narzędzie do mockowania http, ale to już będe nazywał po prostu Wiremockiem, podobnie, jak z bazą danych w pamięci (nie będę H2 określał mockiem w tym poście).

A teraz wyjaśnię czemu sądze że mocki są nadmiernie stosowane i co zamiast nich, oraz dlaczego często lepsza jest odwrócona piramida testów, tj. podstawą są testy integracyjne(mówię o tych z poziomu aplikacji), a nie jednostkowego (cokolwiek tą jednostką) jest:

  1. Możemy przetestować całość aplikacji, cześć tej aplikacji to infrastruktura. Dodatkowo niektóre funkcje sa bardzo CRUDOwe.
    Założmy że mamy do czynienia ze sklepem internetowym w którym mamy możliwość dostarczenia komentarza do produktu jeśli kupiliśmy go wczesniej. Co w takim przypadku jest ważne? Żeby użytkownik mógł dodać komentarz tylko jako on, a nie ktoś inny/niezalogowany, żeby sprawdzić czy może dodac komentarz(iloczyn warunków logicznych - czy kupił produkt oraz czy nie dodał komentarza) a na koniec zapisać ten komentarz. Jak takie coś można przetestowac z Mockito? No nijak, bo najważniejsze to przetesowanie security i SQL, bo przecież on jest raczej bardziej złożony niż if z dwoma warunkami które mogliśmy zamockować z Mockito. Najlepiej uruchomić baze aplikacje z testowa bazą danych, wykonać odpowienie requesty i zrobić asercję np. na status HTTP/ stan bazy testowej. Tak czy owak należy testować takie rzeczy jak security, zapytania SQL itp. No można by teoretycznie obiekty dostępu do baz danych testować oddzielnie, tylko po co skoro już uruchamiamy test? Możemy przetestować aplikację a nie to czy mockito nam dobrze zamockowało dane.
  2. Testy integracyjne nie betonują tak kodu. Problem z testami "niższego rzędu" jest taki że bardzo są blisko kodu, a więc każda mała zmiana powoduje że trzeba zmieniać też również testy. Jak ludzie testują na poziomie klas to jest tragedia komplentna, trochę lepiej jest jak mamy jakieś fasady ( ciekawa prezentacja o tym
  3. Biblioteki do mockowania sa często nie dokońca intuicyjne i często się można pomylić przez to w testach. Dodatkowo gdy testujemy bardzo nisko poziomowo mamy cały zarypany kod jakimiś inicjacjami mocków, i nie sądze żeby to powodowało że takie testy da się pisać/modyfikować szybko, ani nie dodają czytelności.
  4. Szybkość/koszt testów - mówi się że testy integracyjne są wolne. Otóż nie do końca. Jesli korzystamy z takiego TestCointaners i startujemy testy to sam start jest rzeczywiście powolniejszy, ale po starcie testy bedą się raczej wykonywac szybko. Za to zaoszczędzimy czas na tym że testy nie są tak zabetonowane. Dlatego jeśli dla danej funkcji programu mamy 5 przypadków testowych, testy integracyjne mają sens.

Dodatkowo, zamiast korzystać z mocków możemy robic testowe implementacje interfejsów. Możemy mieć interface CurrencyRatesProvider, implementacje produkcyjną jak jakiś RestCurrencyRatesProvider i jakieś faki. Takie rozwiązania są często bardziej czytelne i zwięzłe niż jakieś Mockito when then/thenReturn.

Co w przypadku integracji z innymi systemami? Na JVM jest taka biblioteka jak Wiremock która umożliwia postawienie stuba z zamockowanym api. Oczywiście jest to mock, ale mock o wiele bardziej inteligenty gdzie my wywołujemy zapytanie HTTP z poziomu testu i pewnie podobne są dostepne dla innych środowisk.

Kiedy tak naprawde sens testowania jednostkowego? W przypadku gdy chcemy wyodrębnić jakiś bardziej złożony algorytm który może mieć tez wiele różnych warunków, na przykład może to być kalkulator promocji z których część włącza się do innych, a część nie, a łącznie dla danego zamówienia aktywnych będzie na przykład 3 albo i 4 (jak dwie ksiązki w cenie jednej, promocja z okazji black friday, zniższka powyżej iluś tam złotych i jakiś kod rabatowy do tego).

Gdybym miał napisać TL;DR - po co tak naprawde stosowac mocki skoro i tak chcemy sprawdzić działanie aplikacji, w tym na przykład security, zapytania SQL, itp.

1

Super podsumowanie. Staram się stosować i propagować podobny styl.

Od siebie jeszcze dorzucę:

  • Często przy tego typu tematach wychodzą rozmyte pojęcia typu test jednostkowy vs integracyjny. Świetnym wytłumaczeniem jest wtedy, że naszą jednostką jest usecase (operacja biznesowa) i nasze testy to testy per usecase.

  • Pomocą przy takim podejściu może być architektura heksagonalna w której, zarówno kod domenowy jak i kod testów abstrahują szczegóły techniczne (DB, HTTP, JMS etc.). Nic nie stoi na przeszkodzie (przynajmniej w JUnit5), by stworzyć BaseSalaryCalculatorTest który wymaga podania w konstruktorze konkretnych repozytoriów. Wówczas mogę w module domenowym dostarczyć repozytoria oparte na jakąś in memory mapę, a w module z adapterami opartą na magicznych frameworkach. Zaletą jest to, że w bazowych testach pojawia się słownictwo czysto biznesowe.

  • Startując bazę lub framework raz per test, pojawia się argument, że testy powinny być niezależne. Można to osiągnąć, czyszcząc taką bazę. Co do frameworka to zwykle na produkcji nikt nam nie restartuje serwera po każdej operacji, więc może to być nawet pożądane przy wykrywaniu pewnych haisenbugów (pomijam serverless / lambdy, tam raczej nikt nie bawi się w wolno startujące frameworki)

  • Ludzie czesto piszą testy mockując wybrane metody, które były akurat wykorzystywane. Nagle ktoś dodaje prostą walidację, a tu wszystkie testy wybuchają nie dlatego, że nowa walidacja coś psuje, a dlatego że te testy nie mają ustawionego np. Repository::count(). Oczywiście można to naprawić robiąc metodę typu givenAnimals(List.of(dog, cat, hippo)), ale nie jest to standard i wymaga samokontroli.

4

W temacie.
Co do odwróconej piramidy testów to jest takie pojęcie. Jakkolwiek... wróćmy do tematu za 4-5 lat - mainstream jeszcze nie jest gotowy aby słuchać, że testy to code smell.
Testy to testy. Od dawna uważam podział na jednostkowe, integracyjne itd. za sztuczny - jakiś relikt z frameworków enterprise ediszion.
Mocki... w sumie to nie mam nic przeciwko, o ile się nie nadużywa, a nadużywa się prawie wszędzie. Jak trafia do mnie gotowy serwis i ma metodę typu
Status sendMail() , to pewnie użyję Mockito. W praktyce jednak jest to przypadek dość nieczęsty - w większości projektów Mockito nigdy nie podłączam nawet jako zależność. Uważam za dziwne mockowanie w testach klas, które są w tym samym module, pakiecie. Co się stało, że nie można ich normalnie użyć?
Btw. kiedyś to robiłem :/ Londyńska szkoła TDD (co za kupa - wstyd mi za te testy teraz, ale 100% pokrycia było).

Często mocksturbacja to skutek choroby - frameworków DI.
W 30 minucie Tomer Gabel porusza ten temat:

2

@scibi92: Generalnie się zgadzam :)

Testy jednostkowe piszę nadal, jesli testuję jakiś algorytm wyliczania czegoś - mimo, że jest to składowa jakiejś większej funkcjonalności - niemniej czasem takie zabetonowanie jakiejś małej cząstki ma dla mnie sens - np. algorytmu wyliczania czegoś lub metody walidującej (mimo, że klasa owa ma zakres per package), chcę zabetonować tę konkretną metodę a nie tylko testować całość. Niemniej jak testuję całość to nie mockuję tego :)

Tak czy owak należy testować takie rzeczy jak security

Tak, ale tu akurat robisz "mocka", jakąś dummy implementację, że nie musi być konto testUser z uprawnieniami do aplikacji na platformie zewnętrznej x. Tylko mockujesz metodę pobrania użytkownika/tokenu z zenętrznego serwisu.

5

Tak, ale tu akurat robisz "mocka", jakąś dummy implementację, że nie musi być konto testUser z uprawnieniami do aplikacji na platformie zewnętrznej x. Tylko mockujesz metodę pobrania użytkownika/tokenu z zenętrznego serwisu.

Chodzi o to zeby mockować API serwisu i go "udawać" a nie wyłącząć cała komunikację spod testu. Tzn nie mockujesz obiektu X i jego metody getToken tylko startujesz embedded http server który wystawia takie API jak ten serwis od tokenów i wie że na request /dejMjeTenToken ma odpowiedzieć przygotowym przez nas tokenem :) Patrz testy w: https://github.com/Pharisaeus/almost-s3

0

a co z czasem wykonania? jezeli klasa A wola klase B a klasa B jest juz przetestowana w innym tescie? nie mockujac klasy B w tescie klasy A, test A bedzie sie 2 razy dluzej wykonywal

4

@lambdadziara: pomijasz czas stworzenia mocka, który jest często dłuższy niż wykonanie tej metody

2

Testuje tak sporo serwisów, takich testów jest po kilkadziesiąt na serwis i czas wykonania to raptem kilka sekund. Z jakimś testcontainters byłoby pewnie trochę dłużej, ale mimo wszystko nigdy nie czułem żeby mnie to jakoś specjalnie spowalniało albo przeszkadzało.

5

Mi się idea odwrócenia piramidy testów i zniknięcia sztywnego podziału na "jednostkowe" i "integracyjne" nawet podoba.

Tak z perspektywy czasu, przy takiej nie-odwróconej piramidzie, silnym podziale na to co "jednostkowe" i "integracyjne", najlepiej testach-per-klasa i CRUDowych aplikacjach robi się po prostu kuriozalnie

  • Piszesz test "integracyjny", gdzie śmiga spory kawałek lub całość aplikacji, sekórity, pod spodem H2 / TestContainers / cokolwiek - i coś tam testujesz. Raczej niewiele, bo to CRUD i wielkiej logiki ani tym bardziej algorytmów w takim cudzie nadwiślańskiej technologii nie uświadczysz.
  • Teraz "musisz" napisać test "jednostkowy", najlepiej dla każdej warstwy swojej lasagne po kolei bo czemu nie - no to siup, mockujesz wszystko dookoła i testujesz praktycznie to samo, co wyżej, tyle że na mockach i jest jeszcze bardziej rozrzedzone
  • Dowolna zmiana wymaga przepisania od jednego do kilkudziesięciu testów, bo są tak zabetonowane że nawet po niewielkich zmianach nawet "integracyjne" się nie kompilują (xD), a jak już je "naprawisz" żeby gruz i beton wpasować do nowej formy, to nie masz w sumie 100% pewności, czy nie napsułeś czegoś przy okazji, a testy teraz sprawdzają czy jest poprawnie zepsute :D

Jako side-effect, po takich zmianach wszystko jest na ogół mocno przeorane w wielu miejscach, więc

  • jak ktoś wrzucił swoje zmiany i chcesz sobie walnąć rebase na najnowszą wersję, to może i obaj robiliście niewiele zmian w kodzie aplikacyjnym za to w testach są teraz TAAAAAKIE konflikty
  • bardzo możliwe że nikt nie wyłapie jak narobisz dziadostwa, bo dokładność code review jest często odwrotnie proporcjonalna do ilości zmian

A już zupełnie najgorzej, jak samemu przyłożyłeś łapę do tego stanu rzeczy i nie możesz wytykać paluchami, że to przez innych :/

4

@lambdadziara: czy wszedłbys do samochodu który miał oddzielnie testowane silniki, oddzielnie poduszki powietrzne i oddzielnie wycieraczki i światła ale po zamontowaniu nikt takiego modelu nie testował w całości?

1 użytkowników online, w tym zalogowanych: 0, gości: 1, botów: 0