Jak testować I/O

Jak testować I/O

... i nie dać się zniszczyć? Poradnik o tworzeniu deterministycznych testów i przewidywalnego środowiska testowego.

piątek, 20 kwietnia 2018

Informatyka

Dzisiejszy wpis przeznaczony jest dla każdego, kto kiedyś musiał zmierzyć się z rozwikłaniem zagadki, dlaczego jakieś zadanie na Jenkinsie zmieniło się w generator liczb losowych za sprawą testów automatycznych... czyli w zasadzie dla wszystkich, bo nie wierzę, że istnieje programista, który pracując z czyimś kodem się z tym nie zetknął. W swojej karierze miałem takich przypadków co najmniej kilka i zacznę od przedstawienia pewnej historyjki "z życia wziętej". Był sobie kiedyś moduł, który był takim oto generatorem liczb losowych i wszystkich wkurzał, a najbardziej tego, na kogo przypadło niewdzięczne zadanie zrelease'owania projektu. Podczas jednej zespołowej rozmowy powiedziałem, że mogę sprawdzić, co jest nie tak i spróbować to naprawić. Nie zaglądałem nigdy w tamten kawałek kodu, więc był on dla mnie nowością. Zabrałem się do dzieła i zacząłem przeglądać, co też tam w tych testach siedzi. Następnego dnia obwieściłem menedżerowi sukces:

  • OK, znalazłem około dwadzieścia problematycznych pseudotestów.
  • O, i co z nimi zrobiłeś?
  • Skasowałem je i pomogło.
  • CO?! Skasowałeś testy?! Mówiłeś, że je naprawisz!

W tym momencie do dialogu włączył się kolega: nie, on skasował pseudotesty, a nie testy, a to istotna różnica. Mój ówczesny menedżer sam wcześniej był programistą i bardzo dobrze orientował się w zagadnieniach technicznych, miał również otwarty umysł. Ten dialog miał raczej humorystyczny charakter i był tak naprawdę początkiem drogi do zmiany naszego podejścia w testowaniu. Sam moduł w dużym skrócie zajmował się niskopoziomową komunikacją sieciową przy pomocy protokołu zbudowanego na bazie UDP i jego "testy jednostkowe" wyglądały tak:

  1. stwórz serwis odpowiedzialny za komunikację i rozpocznij nasłuchiwanie na datagramy UDP na ściśle określonym porcie,
  2. odpal 10 wątków,
  3. wyślij z każdego wątku po kilka tysięcy datagramów UDP do serwisu z punktu 1,
  4. sprawdź czy przyszło tyle ramek UDP, ile powinno, ich zawartość i zmierz czas komunikacji.

Jest co najmniej kilka powodów, dla których to po prostu nie miało szans działać. UDP to prosty protokół bezpołączeniowy, gdzie dostarczenie datagramu z danymi nie jest gwarantowane. Nawet w komunikacji w obrębie jednej maszyny jądro może odrzucić niektóre z nich w przypadku przepełnienia bufora danych czekających na odczyt. Jeśli tak się stanie, doliczymy się mniejszej liczby ramek niż oczekiwania i test się wysypie na asercji. Kolejnym problematycznym punktem jest uruchomienie dziesięciu (!) wątków. Będą się one wykonywać z różną prędkością uzależnioną od parametrów maszyny oraz aktualnego obciążenia. Tymczasem skoro zliczamy odbierane dane, musimy przyjąć sobie jakiś maksymalny czas oczekiwania, który musimy wyznaczyć doświadczalnie. Ale właśnie - wynik doświadczalny zależy od maszyny, a nasza asercja na liczbę ramek zależy już od dwóch niewiadomych, na które nie mamy żadnego wpływu. Wreszcie, port do komunikacji był narzucony z góry przez protokół, więc nie dało się go przekonfigurować. A teraz wyobraźmy sobie, że Jenkins zaczął na tym samym executorze budować dwie instancje tego samego zadania, np. z różnych gałązek... jedna z nich wygrywała, a druga odbijała się od zajętego portu. Nie wspomnę już o tym, że taki test ciężko jest nazwać "jednostkowym".

Trochę o testach jednostkowych

Testy jednostkowe, jak wskazuje nazwa, mają weryfikować działanie poszczególnych fragmentów aplikacji (najczęściej poszczególnych klas) w oderwaniu od siebie nawzajem. Można to porównać do testów poszczególnych urządzeń, z których dopiero później będzie składana sonda kosmiczna. Mój kod charakteryzuje się tym, że mocno pilnuję tego, by klasy nie robiły zbyt dużo, by nie zależały zbyt mocno od siebie nawzajem i by miały dobrze zdefiniowany kontrakt, kiedy i w jaki sposób powinny być używane. Tworzenie aplikacji polega na tym, by poszczególne kontrakty złożone razem "pasowały do siebie". Mogę bardzo łatwo wymienić jedną implementację na inną czy wstawić pomiędzy dwie klasy jakąś nową pod warunkiem, że zachowam kontrakt i do weryfikowania, że w trakcie dokonywania zmian nie zmieniłem przypadkiem jakiegoś założenia służą mi właśnie testy jednostkowe. Dlatego też oczekuję od nich, by było ich dużo, by wykonywały się szybko i by były deterministyczne. To jak powiedzenie: jeśli w danej sytuacji zrobię rzecz X, to oczekuję, że stanie się rzecz Y. Oto przykład:

@Test
public void shouldDoSomethingInGivenSituation() {
    // Given
    SomePeer peer = mock(SomePeer.class);
    when(peer.doesSomething()).thenReturn(MAGIC_VALUE);
    Foo testedUnit = new Foo(peer);

    // When
    int result = testedUnit.performsOperation();

    // Then
    assertEquals(2 * MAGIC_VALUE, result);
    verify(peer).doesSomething();
}

Stosuję konwencję given-when-then, która pozwala elegancko wyrazić napisane powyżej zdanie oraz ułatwia zrozumienie sytuacji, gdy wracamy do kodu po jakimś czasie. Powyższy przykład mówi nam, że jeśli wywołamy metodę performsOperation() na klasie Foo, to możemy się spodziewać wywołania doesSomething() na podrzuconej jej implementacji interfejsu SomePeer, a wynik działania będzie miał coś wspólnego z tym, co tamto wywołanie zwróciło. Dopóki ten test przechodzi, wszystkie elementy systemu, które świadomie (bądź nie) na tym zachowaniu polegają, z dużą dozą prawdopodobieństwa po zmianach wciąż będą działać prawidłowo.

Jak to się ma do I/O i wątków? Kluczowy jest zwrot z dużą dozą prawdopodobieństwa. Dla klas, które sobie coś tam po prostu liczą, rzeczywiście ta doza prawdopodobieństwa jest wysoka. Możliwe jest nawet uzyskanie pewności na poziomie 100%, jeśli uda nam się pokryć wszystkie możliwe przypadki, ale w praktyce prawie zawsze coś się mimo wszystko przeoczy. Jednak w każdym projekcie pojawiają się jakieś klasy, które będą wywoływać jakieś operacje I/O, odpalać wątki czy robić inne niedeterministyczne rzeczy. Smutna prawda jest taka, że jeśli znajdą się one w naszym kodzie, to zaczynają one tę dozę prawdopodobieństwa obniżać, niekiedy wręcz drastycznie. Im więcej takich klas mamy, tym ciężej nam napisać stabilny zestaw testów jednostkowych oraz przetestować kontrakty.

Problemem modułu z historyjki było to, że właściwie wszystkie kluczowe komponenty w jakiś sposób zajmowały się obsługą I/O. Poprawności działania po prostu nie dało się przetestować bez otwarcia socketu UDP, więc wynik działania niemal każdego testu mógł być zmieniony przez czynniki zewnętrzne, a same testy musiały wyglądać mniej więcej tak:

@Test
public void shouldReceiveDataAndSanitizeInput() {
    // Given
    SomePeer peer = mock(SomePeer.class);
    when(peer.doesSomething()).thenReturn(MAGIC_VALUE);
    FooConnector testedUnit = new FooConnector(peer, "127.0.0.1", TEST_PORT);

    // When
    testedUnit.listen();
    Thread thr = new Thread(() -> {
        sendDataAndReceiveConfirmation("127.0.0.1", TEST_PORT, "payload");
    });
    thr.start();
    thr.join();
    int result = testedUnit.getResult();
    testedUnit.close();    

    // Then
    assertEquals(2 * MAGIC_VALUE, result);
    verify(peer).doesSomething();
}

Popatrzmy z perspektywy SOLID

W pewnym momencie sam musiałem napisać kod, który realizował jakieś operacje I/O oraz stworzyć testy do niego, których wygląd zaczął niebezpiecznie zmierzać w kierunku przedstawionego przed chwilą przykładu. Jednak wtedy przyszła refleksja: czy to nie jest znak, że coś jest nie tak ze stworzonym przeze mnie kodem? Przypomniałem sobie Zasadę Jednej Odpowiedzialności i zapytałem sam siebie czy z jej punktu widzenia to właściwe, że jakaś klasa zajmuje się JEDNOCZEŚNIE obsługą I/O oraz przetwarzaniem danych. Moją odpowiedzią było: nie.

Co jest największą wartością aplikacji, którą tworzę? Jest nią logika działania. Kod realizujący I/O pozwala nam wprawdzie zdobyć dane do nakarmienia logiki i wypchnięcia wyniku w świat, ale sam w sobie nie ma zbyt dużej wartości. Można mieć świetnie zrobione I/O, ale bez logiki rozwiązującej określony problem nasz program będzie bezużyteczny. W tym miejscu chciałbym przypomnieć kultowy filmik "Baśka robi API":

Wiedziałem, że testy automatyczne z założenia nie są w stanie pokryć wszystkich możliwych scenariuszy. Uświadomiwszy sobie, co jest największą wartością mojego kodu pomyślałem, że może by w takim razie zmienić podejście i tak pisać aplikację, by dało się jak najłatwiej testować te najcenniejsze rzeczy? W efekcie zacząłem dzielić każdą klasę na dwie mniejsze tak, by jedna z nich zawierała czystą logikę (IF-y, pętle, strumienie, struktury danych itd.), a druga - wyłącznie kod I/O. Zysk był niemal natychmiastowy. Napisanie dobrego, stabilnego testu do logiki stało się bardzo proste, ponieważ zamiast I/O miałem po prostu kontrakt, który mogłem zwyczajnie zamockować. Testy szybko zaczęły opisywać faktyczne scenariusze działania: "jeśli I/O w danej sytuacji przyśle X, to powinno stać się Y":

@Test
public void shouldReceiveDataAndSanitizeInput() {
    // Given
    SomePeer peer = mock(SomePeer.class);
    FooIO io = mock(FooIO.class);

   when(peer.doesSomething()).thenReturn(MAGIC_VALUE);
   when(io.fetchData()).thenReturn("payload");

    FooLogic testedUnit = new FooLogic(peer, io);

    // When
    int result = testedUnit.fetchAndCompute();

    // Then
    assertEquals(2 * MAGIC_VALUE, result);
    verify(io, once()).fetchData();
    verify(peer).doesSomething();
}

Pojawia się jednak pytanie, co w takim razie z testami do FooIO? Jeśli chodzi o mnie, to przeważnie... nie pisałem ich wcale. Brzmi to jak herezja, ale powód jest banalny. W powyższym podejściu kod realizujący I/O staje się po prostu zwykłą, głupkowatą fasadą na jakieś istniejące API. Jej poziom zaawansowania technicznego jest bliski getterom i setterom, więc tam po prostu ciężko jest cokolwiek zepsuć, a ponadto nie będziemy musieli robić tam już zbyt wiele zmian. W ostateczności poprawność działania moglibyśmy nawet sprawdzić ręcznie, choć lepszym pomysłem jest wykorzystanie w tym celu dwóch, trzech testów integracyjnych, uruchamianych w innym środowisku. Jeśli mimo wszystko wystąpi jakiś problem, koszt znalezienia przyczyny wciąż będzie niższy:

  1. patrzymy na scenariusz reprodukcji,
  2. sprawdzamy czy nie pokrywa się on z którymś z testów logiki,
  3. jeśli się pokrywa, to możemy wykluczyć logikę i sprawdzamy nasz głupkowaty kod I/O.

Jeszcze raz podkreślę: najcenniejsza jest logika i to na niej powinniśmy się skupić.

Dlaczego to działa?

Ano właśnie, dlaczego warto olać testy jednostkowe operacji I/O? Oto trzy prawdy o testowaniu automatycznym:

  1. nigdy nie pokryjemy wszystkich sytuacji, ani wszystkich możliwych interakcji,
  2. przeważnie daną funkcjonalność da się przetestować na wielu różnych poziomach, w mniej lub bardziej kosztowny sposób,
  3. istnieją funkcjonalności, gdzie koszt automatyzacji przewyższa wszelkie zyski.

Powinniśmy dążyć do tego, by testować jak najniższym kosztem (czyli wybierać najtańszy możliwy poziom, gdzie daną rzecz da się przetestować) oraz umieć patrzeć krytycznie na to, co opłaca się automatyzować, a co nie. Przedstawione w artykule podejście to sposób na wdrożenie w naszym projekcie prawdziwej piramidy testów. Gdy decyzję o podziale klasy na mniejsze zaczynamy podejmować również na bazie tego, dla jakich fragmentów jest łatwo napisać stabilny test, a dla jakich trudno, to w pewnym momencie uzyskamy całkiem pokaźną liczbę klas łatwych w testowaniu i oddzielonych od tych, które testuje się trudno. Wtedy okaże się, że testów integracyjnych nie potrzeba wcale aż tak dużo lub też że nie muszą być one tak szczegółowe. Będą one po prostu odpowiadać za sprawdzenie, że po złożeniu wszystkiego w całość aplikacja działa.

Na bardzo podobnych założeniach bazują popularne zalecenia odnośnie automatyzacji testów: oddzielenie testów GUI od reszty aplikacji czy testowanie logiki biznesowej niezależnie od kontrolerów. Właściwie można je uznać za szczególne przypadki przedstawionego tutaj sposobu. Jeśli wdrożymy pierwsze zalecenie, to w naturalny sposób będziemy dążyć do tego, by jak najwięcej funkcji dało się przetestować bez użycia GUI; drugie doprowadzi nas do wydzielenia logiki z kontrolerów, których inicjalizacja jest skomplikowana. Testy automatyczne są kosztowne oraz ich utrzymanie jest kosztowne - jest to rodzaj inwestycji, dlatego inwestujmy tam, gdzie będziemy mieć z tego jak największą korzyść przy jak największej redukcji kosztów utrzymania.

Moim największym sukcesem w zastosowaniu tej techniki było zamockowanie interakcji między wątkami. Miałem do przetestowania algorytm przetwarzania danych z wielu różnych, piszących asynchronicznie źródeł, i porządkowania ich dla następnych filtrów w łańcuchu z zachowaniem kilku ściśle określonych reguł. Od razu zauważyłem, że odpalanie wątków w testach to zły pomysł i nic tym nie osiągnę. Zamiast tego tak napisałem kod, aby wątki dało się zastąpić mockami i na potrzeby testów stworzyłem całkowicie deterministyczny symulator wątków, w którym mogłem po prostu określić sobie z góry sekwencję zgłaszanych danych i napisać asercje, jak ma wyglądać wynik działania algorytmu. Przez kilka lat pamiętam tylko jeden strzał w tym obszarze związany z tym, że do jednego ze scenariuszy zabrakło testu. Po jego dopisaniu znalezienie przyczyny i stworzenie poprawki było już tylko formalnością.

Kiedy się nie wysilam?

Oczywiście są również sytuacje, kiedy I/O nie jest aż tak nieprzewidywalne. Dobrym przykładem jest czytanie danych z plików - w Javie pliki tekstowe możemy umieścić nawet jako zasoby testowe, a interfejs InputStream sam w sobie jest całkiem niezłą fasadą. Wszystko sprowadza się do rachunku zysków i kosztów.

Zakończenie

Cóż... odpowiedź na postawione w tytule pytanie "jak testować I/O" jest zgoła zaskakująca: niezbyt intensywnie. Jeśli chodzi o historyjkę z początku artykułu, to moja decyzja o skasowaniu kilkunastu testów może być widziana jako wycofanie się z nietrafionej inwestycji, która kosztowała nas wtedy ogromnie dużo, a nie niosła ze sobą żadnej wartości. Jako ciekawostkę dodam, że później popytałem się starszych członków zespołu co tamte testy dokładnie weryfikowały (jakieś założenia, wymagania, cokolwiek... ) i okazało się, że nikt tego nie wiedział. Prawda o inwestowaniu była lekcją, którą wtedy wynieśliśmy i dzięki której później zbudowaliśmy świetne środowisko testowe. Wyzbyliśmy się też chorego przywiązania do niestabilnych testów i nikomu niepotrzebnego kodu. Mam nadzieję, że nakieruje ona też i innych na właściwy trop.

Tomasz Jędrzejewski

Programista Javy, lider techniczny. W wolnych chwilach podróżuje, realizując od kilku lat projekty długodystansowych wypraw pieszych.

Komentarze (0)

Skomentuj

Od 3 do 40 znaków.

Wymagany, nie będzie publikowany.

Odpowiedz na pytanie.

Edycja Podgląd

Od 10 do 8000 znaków.

Wszystkie komentarze są moderowane i muszą być zatwierdzone przed publikacją.