Zaoranie mockowania w 20 linijkach kodu

– Co znowu?
– To duży błąd, że ludzi uczy się pisania testów, każąc im używać mocków. Przydatność mocków jest bardzo ograniczona. Zazwyczaj powodują jedynie, że test przepuszcza niedziałający kod. Większość tworzonych na co dzień testów w ogóle nie powinna używać mocków.
– Co za wymysły, to jakaś kompletna bzdura! Pokaż dowody!
– Proszę bardzo.

Kod

Typowy mockowy test:

@Test
void emergencySignalClosesAllRoadsSoThatAmbulanceCanPass() {
    var lights = (Map<String, LightColour>) mock(Map.class);
    var control = new TrafficLightsControl(lights);

    control.signalEmergency();

    verify(lights).put("road1", RED);
    verify(lights).put("road2", RED);
}

Co może być nie tak?

Test zielony, czytelny, coverage 100%, wszystkie metryki wzorowe. Nie ma co się zastanawiać. Push, approve, merge, deploy na produkcję, następny task, szybko, szybciej.

Tak mnie uczyli, tak było, jest i będzie. Jest test, to musi być mock. Nie da się inaczej.

To teraz kod produkcyjny.

void signalEmergency() {
    lights.put("road1", LightColour.RED);
    lights.put("road1", LightColour.AMBER);
    lights.put("road1", LightColour.GREEN);

    lights.put("road2", LightColour.RED);
    lights.put("road2", LightColour.AMBER);
    lights.put("road2", LightColour.GREEN);
}

Jak dobrze działa ten kod?

samochód zmiażdżony w wypadku

Problem

Największe nieporozumienie to traktowanie podobnie sknoconych testów jako przypadkowej wpadki, złego dnia, złego człowieka itp. Nie zgadzam się. To nie jest odosobniony przypadek. To jest błędne podejście do testów, które rodzi powtarzające się problemy. Będziesz ciągle pakować wszędzie mocki – będą ciągle pojawiać się bugi.

Eksperyment: w teście wyżej zastosuj zamiast mocka po prostu HashMap. Teraz spróbuj znaleźć sposób, żeby coś popsuć. Trudno, prawda?

Warto tu się zatrzymać i zwrócić uwagę, że zmiana testu z badziewnego na porządny nie wymagała ani specjalnych umiejętności, ani dużo pracy. Test nie stał się mniej czytelny przez usunięcie mocków. Barierą nie jest wiedza ani wysiłek, na drodze do lepszych testów stoi przede wszystkim zły nawyk i błędne przekonania.

Prawdziwy kod

„Przykład nic nie dowodzi, nikt by nie mockował typu Map! Światła drogowe, 20 linijek kodu, łatwo się wymyśla przykłady. W moim kodzie są prawdziwe, skomplikowane problemy, muszę tam użyć mocków.”

To nie jest przykład oderwany od rzeczywistości. Od kilku lat raz za razem trafiam w różnych projektach na testy, gdzie ludzie mockują HTTP response, żeby testować, że ich kod ustawia w odpowiedzi do klienta dobre nagłówki.

Jest to równie nierozsądne, jak mój zmyślony kod ze światłami drogowymi. Ponieważ to testowanie bardzo podobnego problemu. HTTP response to w pewnym sensie Map z dekoracjami.

Tak naprawdę ogromna większość użycia mocków sprowadza się do czegoś, co cierpi na te same wady jak światła drogowe wyżej. Czyli branie pewnych narzędzi i stosowanie ich tam, gdzie nie pasują.

Robimy w prawdziwym kodzie równie głupie rzeczy jak nadpisywanie dobrego puta złym putem w fikcyjnych światłach drogowych. Tyle że nie dostrzegamy naszych pomyłek, bo mamy długie metody, niekontrolowane mutacje zamiast immutable data, JPA, SQL, transakcje i lazy loading. Wszystkie komplikacje, które zaciemniają głupoty, jakie robimy.

Złe narzędzie

dziurawe wiadro

Opiszę teraz, co leży u podstaw niedopasowania mocków do testowania świateł drogowych, przetwarzania zapytań HTTP i ogromnej większości najbardziej życiowych, codziennych i typowych zastosowań.

Używając mocków skupiamy się na interakcjach. Biblioteki do mocków pozwalają łatwo zapisać, że zawołanie takiej a takiej metody w taki a taki sposób jest oczekiwane, albo że zawołanie innej metody jest niepożądane. Programista będzie się więc skupiał w teście na tym, co jest łatwo zakodzić: jakie metody zostały zawołane i z jakimi parametrami.

W twierdzeniu, że mocki są przydatne w pisaniu testów, jest ukryte założenie: że myślenie w kategoriach wywołań metod jest dobrą drogą do zaprojektowania testu. Czyli założenie, że liczenie interakcji to dobry model mentalny (tu pisałem co to model mentalny) do kontroli, czy kod spełnia specyfikację.

Jestem przekonany, że to błędne założenie. W ogromnej większości kodu, jakim zajmują się współcześni programiści, schodzenie w teście na poziom konkretnych metod prowadzi na manowce. Programista ciężko pracuje nad kontrolowaniem w teście rzeczy, które nie są istotne (która metoda ile razy), a zapomina o kontrolowaniu rzeczy mających fundamentalne znaczenie (jaki jest wynik końcowy). Mock jest jak dziurawe wiadro: możesz wkładać mnóstwo pracy w pisanie testu, a i tak nie ogarniesz sytuacji. Bo tak jak dziurawe wiadro jest nieodpowiednim narzędziem do wylewania wody z szalupy (jest odpowiednie chyba tylko na rośliny nielubiące nadmiaru wilgoci), tak mocki są nieodpowiednim narzędziem do pisania typowych testów – przez to, że to model niedopasowany do typowego kodu.

Jest to założenie błędne niezależne od warstwy testu. Błędne w testach bardzo niskopoziomowych: mock to nie jest dobry model czegoś, co ma headery HTTP. Też błędne w testach wysokopoziomowych: mock to nie jest dobry model bazy danych, serwisu typu AWS S3.

Nieprzydatność mocków

Założenie, że mocki dobrze opisują kod w działaniu, wytrzymuje zderzenie z rzeczywistością tylko o ile nasz kod ściśle trzyma się stylu opisywanego przez Martina Fowlera jako Minimal Interface. Czyli, upraszczając, że interfejs ma mało metod, a jeśli chcemy coś zrobić, to wiadomo, której metody użyć: pewnie jednej konkretnej, a nie żadnej z pozostałych.

W świecie, gdzie panuje Minimal Interface, mocki faktycznie pozwalają dobrze kontrolować, co robi kod. W takim świecie testy używające mocków są w stanie odpowiedzieć, czy testowany kod działa, czy też nie.

Tyle że taki świat nie istnieje. Dominuje kod, gdzie jest więcej niż jedna droga do uzyskania celu. Minimalistyczna nie jest już biblioteka standardowa Javy. Jeśli chcę ustawić czerwone światło, mogę zrobić put("road1", RED), ale też mogę zrobić put("road1", AMBER); put("road1", RED) – inny ciąg wywołań metod, ten sam efekt. To nie koniec: są inne metody, mamy putIfAbsent, możemy wstawiać hurtem przez putAll. Mogę zrobić replace.

W kodzie z prawdziwego świata jest więcej metod, niż pamiętamy w momencie pisania testu. Logika działania tych metod może być dość skomplikowana, bo zależna od poprzednich wywołań (putIfAbsent). Brak wywołania oczekiwanej w teście metody niekoniecznie oznacza, że wynik jest zły (co z tego, że nigdy nie było zawołane put, kod poprawnie wstawił wołając putIfAbsent). Wywołanie oczekiwanej w teście metody wcale nie oznacza, że wynik jest dobry (co z tego, że kod dobrze zawołał put, skoro potem zawołał inny put, albo popsuł wszystko wołając replace, o istnieniu którego test zapomniał).

Możemy się pogubić w najbardziej oklepanych, niskopoziomowych typach z biblioteki standardowej, w klasach używanych dzień w dzień, od firmy do firmy i od projektu do projektu (jak pokazywany tu interfejs Map). Skoro tak, to tym bardziej będziemy się gubić w wysokopoziomowych interfejsach pisanych na potrzeby aktualnego projektu albo interfejsach jakiejś nowej biblioteki. Nie zamockujemy ich dobrze, bo nie przewidzimy możliwego użycia.

Koronkowa robota idzie na śmietnik, czyli krótkie życie mocków

Mocki nie tylko utrudniają pisanie testów poprawnych. Powodują też marnowanie czasu i pieniędzy, bo programista pisze testy, które ciągle trzeba będzie poprawiać.

Mocki wymuszają skupiania się na metodach, czyli najbardziej niskopoziomowej warstwie kodu. Wiążą test bardzo ściśle z implementacją. Każda, nawet najbardziej niewinna zmiana w nagłówku metody będzie powodowała konieczność aktualizacji testu. Pół biedy, jeśli robimy operację typu zmiana kolejności parametrów metody, wtedy IDE zmieni test za nas. Gorzej jeśli zmieniamy znaczenie parametru (na przykład, czy null daje błąd czy powoduje użycie domyślnej wartości). Wtedy musimy chodzić po testach i ręcznie zmieniać użycie po użyciu.

Albo zamiast jednej metody wołamy inną (putAll zamiast put) – trzeba biegać po testach i poprawiać. Czy test wykryje, że zrobiliśmy błąd używając putAll? Oczywiście że nie wykryje, przecież wyrzucamy go do śmieci i piszemy od nowa. Niezmienione zostają nazwy metod testowych, więc wydaje się, że test jest stary. Nie, to nowy test pod starą nazwą.

Lepszy model

Bardziej produktywne jest skupianie się w teście nie na interakcjach, ale ich efektach. Światło ma być na koniec czerwone, nieważne, jakimi środkami to kod osiągnął: czy wołał put, czy putIfAbsent. Do bazy ma być zapisany pracownik z odebranym dostępem, nieważne, czy kod zawołał ORM-owy save z całą encją raz, czy dwa; czy zamiast tego wykonał update z prepared statement, czy ze sklejonym ręcznie SQL-em; nieważne, w bazie danych na koniec ma być odebrany dostęp. Może dziś kod jest napisany strasznie, ale osiąga cel, a jutro zrobi to na porządnie; ważne, że test i dziś i jutro pilnuje, że efekt końcowy jest jak trzeba.

Jak to zrobić? Można użyć implementacji działającej jak HashMap: gdzie pola są otwarte, możemy zajrzeć do nich i porównać z wartością oczekiwaną. Można też nie użyć mocków. Stawiamy testową bazę danych, albo testowe REST-owe API.

Rozwijając pierwsze podejście: dobre biblioteki dostarczają gotowe do użycia implementacje do użycia w testach. Na przykład Spring oferuje MockHttpServletResponse. Zamiast próbować mockować ponad 30 metod interfejsu HttpServletResponse (co jest równie głupie jak próba napisania mocku dla klasy Map), bierzemy gotową, sprawdzoną implementację i skupiamy się na logice testu. Jeśli z interfejsem nie przychodzi implementacja do użycia w testach, piszemy własną, trzymając się podobnej filozofii: żeby wszystko było otwarte do wglądu w teście, jak w HashMap.

Co do nieużywania mocków. Istnieją do tego wygodne narzędzia, jak WireMock do REST-a, albo Testcontainers do baz danych (też kolejek itp. itd.). Da się z ich użyciem pisać testy, które wciąż będą bardzo szybkie.

Edukacja

Myślę, że światek programistyczny musi trochę dojrzeć w podejściu do mocków. Tak jak dojrzał w podejściu do wzorców projektowych i innych „odkryć”.

Pamiętam, jak na studiach uczyli mnie książki Gammy, jakby to była prawda objawiona, jakby inaczej kompetentny programista pisać nie miał prawa. Strategia, flyweight, wizytor – kto da więcej. Na rozmowach kwalifikacyjnych przepytywali ze znajomości wzorców, potem siadał taki nowy pracownik przy biurku i od razu chciał udowodnić, że zatrudniono go słusznie – dawaj, kto zastosuje więcej wzorców w pisanym kodzie (przykład pożyczyłem z mojej prezentacji Problem sprytnego programisty). Bo będzie zgodnie z zasadami i przygotowane na zmieniające się wymagania. Potem przychodzisz do projektu, a tam overengineering taki, że nie widać już nic, i jedyny sposób dojścia do tego, po co kod został napisany, to odpytywanie najstarszej w projekcie osoby.

Skończyło się. Nikt już nie wsadza strategii do kodu na wszelki wypadek, jakby trzeba było za rok zrobić coś konfigurowalne. Kod ma być prosty, są rzeczy ważniejsze niż wzorce projektowe.

Podobnie świat Javy dojrzał z podejściem do concurrency. Jeśli wrzucasz do kodu produkcyjnego new Thread i synchronized, dostaniesz na code review pytanie: halo, co ty robisz? Nie schodzi się na taki niski poziom, weź użyj java.util.concurrent albo Reactora (albo innej biblioteki wybranej do concurrency w projekcie). Mam nadzieję, że podobną reakcję na code review będzie wywoływało używanie mocków w testach.

To nie tak, że niskopoziomowa praca z wątkami jest wykluczona. W specjalistycznym oprogramowaniu (piszesz bibliotekę concurrency) zachodzi potrzeba pisania tylko na takim niskim poziomie. Nawet w normalnych projektach są pojedyncze miejsca, gdzie trzeba tak pisać. Podobnie z mockami. Będą specyficzne projekty (ponownie, pewnie biblioteki), gdzie mocki będą naturalnie pasować. W normalnych projektach zdarzy się, że przetestowanie czegoś bez mocków będzie zbyt niewygodne. Ale niech wszyscy mają świadomość, że takie użycie jest na drodze wyjątku dyktowanego przez okoliczności, a nie domyślnym sposobem pracy z kodem.

Niech mocki na code review budzą nieufność i wątpliwości. Niech przestaną być traktowane jako coś normalnego. Niech większość pracy odbywa się bez nich. Niech juniorzy będą uczeni na przykładzie testów, gdzie nie podstawia się żadnych mockowych implementacji, albo jeśli już, to używa się tylko klas w stylu MockHttpServletResponse, żadnego Mockito.

Nihil novi

Nie odkrywam tu nic nowego. Podobne tematy poruszał Ian Cooper w głośnym wystąpieniu TDD, where did it all go wrong. Minęło już 10 lat, a sytuacja nie uległa dużej poprawie!

Zdarza się jednak, że o starych rzeczach mówimy w nowy, może bardziej zrozumiały sposób. Przykład z Map wymyśliłem w tym miesiącu, odpowiadając na pytania po mojej prezentacji na temat problemów z mockowaniem. Myślę, że przykład jest ciekawy, stąd ten post. Warto więc dyskutować, nawet na tematy, które wydają się już obgadane sto razy.

Dyskutować warto też przyjaźnie. Dialogi w poście są w pełni zmyślone i nie były inspirowane prawdziwymi wydarzeniami.

Podsumowanie

  • sprawdzanie interakcji (zawołań konkretnych metod) to złe podejście do pisania testów
  • nie jest łatwo zauważyć, że test używający mocków jest błędny; mocki całkiem dobrze udają, że coś sprawdzają
  • dla poprawności kodu nie ma znaczenia, jakie metody wołamy i w jakiej kolejności; liczy się efekt końcowy
  • mocki odwracają uwagę od efektu końcowego, programista skupia się na niskopoziomowych wywołaniach metod
  • świat jest zbyt skomplikowany, żeby dało się go zamockować i zweryfikować: dla każdej metody zazwyczaj istnieje metoda nazwana inaczej, ale robiąca to samo; a nawet jedna i ta sama metoda może robić różne rzeczy w zależności od poprzednich zawołań
  • używaj narzędzi, które umożliwiają patrzenie na efekt końcowy, bez zależenia od nazw i wywołań metod
  • testuj z prawdziwymi zależnościami albo używaj fake’ów, do których można zaglądać jak do obiektu typu List albo Map

Z cyklu „krytyka rzeczy wydawało by się oczywistych” (nie mam takiej kategorii na blogu, a może powinienem) polecam też artykuł o Mavenie.


Prawa do zdjęć: Богдан Митронов-Слободской na licencji CC BY 3.0, Tony Webster na licencji CC BY 2.0, Andy Beales, The Silverdalex.

Creative Commons License
Except where otherwise noted, the content by Piotr Kubowicz is licensed under a Creative Commons Attribution 4.0 International License.