Informacje o nowych artykułach oraz akcjach edukacyjnych prosto na Twojej skrzynce e-mail!

Ryszard MAKUCH o testowaniu oprogramowania

Od pożaru do pożaru, tak zazwyczaj upływają godziny spędzone na dyżurze w jednostce straży pożarnej. Jest to całkiem normalne i nikogo raczej dziwić nie powinno. Akcja, kolejna akcja i koło się toczy. Mimo że praca strażaka jest dość ciekawa i pełna codziennych wyzwań to mam nadzieję, że w żadnym aspekcie nie przypomina dnia roboczego testera albo programisty. Jeśli tak się dzieje to najwyższa pora, aby, jak najszybciej postawić diagnozę i wyleczyć „chorego pacjenta”. Warto jednak zadbać również o profilaktykę, czyli aspekt związany z szeroko pojętym testowaniem oprogramowania.

Jak to zrobić? Po więcej zapraszam do niezwykle merytorycznej i pełnej wiedzy, rozmowy z panem Ryszardem Makuchem, starszym inżynierem oprogramowania w Motorola Solutions.

Cześć, czy możesz się przedstawić, czym się zajmujesz, co należy do Twoich obowiązków?

Ryszard Makuch: Cześć, bardzo mi miło, że możemy porozmawiać na tematy związane z wytwarzaniem oprogramowania w kontekście jego jakości. Nazywam się Ryszard Makuch i pracuję w Motoroli Solutions od grudnia 2019 roku. Swoją komercyjną przygodę z programowaniem zacząłem w 2016 roku jako stażysta w ramach letniego program stażowego organizowanego przez krakowski oddział IBM. W międzyczasie stażując, a później i pracując na pełen etat, byłem zaangażowany w uczelniany interdyscyplinarny projekt, który dotyczył próby klasyfikacji emocji graczy podczas rozgrywki. Daleko mi jeszcze do eksperta, zwłaszcza w kontekście tematu rozmowy, ale myślę, że jestem na dobrej drodze zdobywania wiedzy i całkiem nieźle radzę sobie z programowaniem wykorzystując dobrostan ekosystemu JVM. Cały czas staram się budować wiedzę i zbierać doświadczenie. W ostatnim czasie zawodowo rozwijam kompetencje w obszarze wytwarzania reaktywnego oprogramowania (reactivemanifesto.org) zorientowanego na chmurę (cloud native). Interesuję się też obszarami informatyki takimi jak programowanie genetyczne czy testowanie mutacyjne, które wydają się być poza dzisiejszym głównym nurtem komercyjnego programowania. Moje odpowiedzi będą osadzone w kontekście programisty, który od początku swojej przygody zajmuje się częścią serwerową (backend) aplikacji typu enterprise oraz systemami rozproszonymi (distributed systems).

Zaczynając naszą rozmowę chciałbym na początku zapytać jak to się stało, że zainteresowałeś się tematyką testowania oprogramowania i w jaki sposób trafiłeś do Motoroli Solutions?

Szczerze? To nie pamiętam takiego jednego momentu, który mógłbym jednoznacznie wskazać, że „od tego wszystko się zaczęło”. Pierwsze zderzenie z automatycznym testowaniem oprogramowania miałem już na pierwszych latach swoich studiów. Na niektórych przedmiotach związanych z programowaniem funkcjonowały tak zwane automatyczne sprawdzarki. Polegało to na tym, że student po rozwiązaniu zadania, wysyłał je do prowadzącego lub publikował na dedykowanej stronie internetowej i oczekiwał na wyniki. Wszystko w sposób automatyczny. Rozwiązania albo spełniało kryteria akceptacyjne zadania albo ich nie spełniało. Po kilku takich rundach sami zaczęliśmy pisać własne testy i wymieniać się nimi. Dlaczego? Bo stawka była wysoka! Często w drugim terminie za nadesłanie poprawnego rozwiązania można było zdobyć tylko 60% punktów, a w trzecim 40% względem poprawnego rozwiązania nadesłanego w pierwszym terminie.

Jeśli chodzi o zainteresowanie, to bardzo fajnym momentem dla mnie było przeczytanie książki Growing Object-Oriented Software Guided by Tests autorstwa panów Steve’a Freemana oraz Nata Pryce’a, którą polecił mi bardziej doświadczony kolega. Jest w niej bardzo dużo praktycznej i przydatnej wiedzy. Dzięki treściom, jakie tam znalazłem poznałem technikę wytwarzania oprogramowania sterowanego testami (test-driven development) oraz uporządkowałem swoją wiedzę z zakresu projektowania obiektowego i iteracyjnego podejścia do rozwiązywania problemów. Jest to książka, do której zdarza mi się często wracać.

Co do mojej pracy w Motoroli Solutions, to trafiłem tutaj odpowiadając na ogłoszenie o pracę, które dotyczyło rozwoju nowego projektu typu mission-critical. Jego opis oraz problem, który rozwiązuje i stos technologiczny spotkały się z moimi oczekiwaniami.

Czy masz jakieś rady i wskazówki dla przyszłych testerów? Na co zwracać uwagę na początku swojej kariery? Jakich błędów unikać?

Myślę, że w kontekście rad dla przyszłych testerów zdecydowanie warto posłuchać wystąpienia pana Adama Romana z wydarzenia TestingCup 2016 pt. TDT – jedyne słuszne podejście do testów.

Jeśli chodzi o wskazówki, co do pracy z oprogramowaniem, to myślę, że warto być otwartym i dociekliwym. Warto również unikać kultywowania wiedzy plemiennej poprzez utrwalanie funkcjonalnych i niefunkcjonalnych wymagań dotyczących implementowanych funkcjonalności, które mają zostać przetestowane. Warto też proaktywnie dążyć do określania jasnych kryteriów akceptacyjnych.

Dobrze też nie bać się pytać. Jedno z moich ulubionych pytań to: „z czego to wynika?”. Takie pytanie pomaga skupić się na próbie zidentyfikowania przyczyny problemu, a tym samym przybliżyć się do jego skutecznego rozwiązania.

Myślę, że powyższe wskazówki mogą być również przydatne programistom. Pracuję teraz w zespole, w którym kładziemy bardzo mocny nacisk na stosowanie praktyk DevOps. Każdy z nas pisze nie tylko testy jednostkowe, ale i integracyjne oraz te typu end to end. Sami automatyzujemy wdrażanie naszych zmian na środowisko, eksperymentujemy z inżynierią chaosu oraz dbamy o monitoring naszego produktu. Innymi słowy, w zespole kładziemy nacisk na rozwój typu t-shaped – taki, kiedy każdy z nas posiada wiele umiejętności ogólnych i jedną głęboką specjalizację.

Czym statystycznie różni się kod, napisany przez „juniora” od kodu napisanego przez „seniora”?

Osobiście nie jestem fanem tych przedrostków i tytułów, bo mogą być one nadawane i różnie rozumiane w różnych organizacjach. Preferuję logikę rozmytą ze względu na stany: niedoświadczony, doświadczony w danym obszarze kompetencji.

Wracając do pytania, jeśli zespół praktykuje przeglądy kodu (code review), a częścią każdego przeglądu jest również jego statyczna analiza, to wydaje mi się, że na koniec dnia kod osoby mniej doświadczonej nie powinien różnić się od kodu czy rozwiązania osoby bardziej doświadczonej.

Przykładem niech będzie fragment rozwiązania, w którym zostało zastosowane programowanie wielowątkowe przez osobę niedoświadczoną w tym obszarze. Załóżmy, że w pewnym fragmencie rozwiązania została zastosowana synchronizacja za pomocą metod wait i notify.

Podczas takiego przeglądu kodu osoba bardziej doświadczona w tym obszarze wiedzy będzie potrafiła wskazać bezpieczniejsze rozwiązanie co do synchronizacji, które również znajduje się w bibliotece standardowej języka. Ewentualnie będzie mogła wskazać bibliotekę zewnętrzną, która realizuje implementowaną funkcjonalność lub alternatywnie, na podstawie wymagań i własnego doświadczenia, będzie mogła zaproponować inne rozwiązanie, które nie będzie wymagać użycia tego typu mechanizmu. Oczywiście ostatecznie rozwiązanie zostanie dostarczone później, ale każdy kij ma dwa końce. Takim drugim końcem jest to, że osoba niedoświadczona nabiera nowych kompetencji. Łatwiej jej będzie podjąć świadomą decyzję w realizacji kolejnego podobnego zadania w przyszłości. Dodatkowo maleje szansa na propagację niebezpiecznych wielowątkowych implementacji z wykorzystaniem metod wait i notify, jeśli potrzeba synchronizacji, bo: „przecież tak tam już było, ja tylko skopiowałem”. Przez to maleje szansa na katastrofę w środowisku produkcyjnym, która prędzej czy później musiałaby się wydarzyć.

Jeśli chodzi zaś o samą statyczną analizę kodu, to dla przykładu narzędzie Sonar ma wiele wypracowanych reguł, które w sposób nieantagonizujący  potrafią wskazać złe praktyki i wzorce pisania kodu w języku Java. Stosowanie statycznej analizy kodu ma również tę zaletę, że o wiele szybciej możemy skupić się na analizie poprawności rozwiązania względem przyjętych kryteriów akceptacyjnych dla danej funkcjonalności, ponieważ często grube błędy techniczne oraz złe praktyki, wypracowane w ramach reguł przez społeczność, zostaną wykryte automatycznie. Przez złe praktyki mam na myśli choćby takie jak wspomniana wcześniej synchronizacja przy pomocy metod wait i notify.

Dodatkowo, jeśli chodzi o narzędzie Sonar, to bardzo łatwo jest go zintegrować z narzędziami do ciągłej integracji. Co więcej, od dłuższego czasu w serwisie GitHub z pomocą narzędzi Github Actions jest to bardzo proste nawet dla własnych, domowych projektów. Sam używam usługi sonarcloud i szczególnie zachęcam te osoby, które dopiero na własną rękę zaczynają programować w języku Java oraz chciałby uniknąć powielania złych wzorców. Korzystanie z tego typu narzędzi jest przydatne szczególnie wtedy, kiedy nie mamy obok siebie kogoś bardziej doświadczonego, musimy nauczyć się czegoś nowego, a przy okazji nie chcemy nabywać złych wzorców. Usługa jest darmowa, jeśli dla swoich projektów używa się publicznych repozytoriów.

Jakie błędy doprowadzają do powstawania „długu technicznego”? Czy zawsze jest to wina programistów?

Na początek warto zdefiniować pojęcie długu technicznego. Interesującą definicję w swojej książce pt. Software Design X-Rays: Fix Technical Debt with Behavioral Code Analysis przedstawia pan Adam Tornhill. Pisze on, że „dług techniczny jest metaforą, która pozwala wytłumaczyć biznesowi potrzebę refaktoryzacji i komunikować techniczne kompromisy (trade-offs)”, które zostały zrobione w celu szybszego dostarczenia danej funkcjonalności ze względu na decyzje biznesowe. Chodzi o kompromisy czy to na poziomie architektury w skali makro, czy to na poziomie kodu w skali mikro. Autor pisze dalej, że „w tym sensie dług techniczny jest strategiczną decyzją biznesową, rzadziej strategiczną decyzją techniczną” oraz, że po prostu „dług techniczny, to kod, którego utrzymanie jest bardziej kosztowne niż być powinno”. Wydaje mi się, że dobrym przykładem takiej decyzji biznesowej powodującej powstanie długu technicznego może być odłożenie w czasie decyzji o testowaniu kontraktowym na poziomie całego produktu (choć pojedyncze zespoły w ramach składowych produktu mogą tak testować) ze względu na chęć szybszego prototypowania, reagowania na zmiany i dostarczania funkcjonalności dla klienta. Jednak biznes musi być świadomy, że to kompromis i jeśli nie zostanie on zastąpiony rozwiązaniem docelowym, to prędzej czy później wydarzy się produkcyjna katastrofa. Zwłaszcza, jeśli mówimy o świecie mikroserwisów z ciągłym wdrażaniem zmian. Z drugiej strony przykładem takiego długu, gdzie utrzymanie kodu jest bardziej kosztowne niż być powinno może być nieumiejętne wykorzystywanie narzędzi, które sami sobie wybraliśmy.

Dodatkowo autor wyżej wspomnianego tytułu odwołuje się do wpisu blogowego pana Martina Fowlera, który w kontekście długu technologicznego pisze między innymi o długu lekkomyślnym (reckless debt) zorientowanym na krótkotrwałe korzyści. W skrócie: zespół zna dobre praktyki, ale świadomie decyduje się je łamać, bo obawia się, że stosując je, nie odda najbliższego wydania na czas albo nie pokaże tyle ile zakładał podczas najbliższej demonstracji (jeśli przykładowo pracuje w metodyce Scrum). Podobnie jak w tym kawale o bieganiu z pustymi taczkami: jest tyle pracy że nie ma kiedy tych taczek załadować. Na temat samego długu technologicznego pan Fowler pisze jeszcze w innym swoim wpisie blogowym.

Odpowiadając na postawione przez Pana pytanie. Nie, nie zawsze jest to wina programistów i nie zawsze jest to wina decyzji biznesowych.

Jak tego uniknąć?

Na bazie mojego doświadczenia, nie wydaje mi się, żeby można było całkowicie uniknąć zaciągania długów, o ile te nie są lekkomyślne. Zmieniające się wymagania czy priorytety na pewno w tym nie pomagają. Z drugiej strony, myślę, że jeśli zaciągnięty dług nie powoduje, że w ramach telefonicznego dyżuru musimy codziennie budzić się w nocy, to może być to w porządku, o ile za chwilę taki dług spłacimy. Przez zaciągnięcie długu nie myślę też o takich ekstremach jak wyłączenie czy usuwanie testów, bo te nie przechodzą.

Na jakie przykre konsekwencje możemy trafić bagatelizując kwestię „długu technologicznego”?

Najmniejszy wymiar kary, to komplikacja kodu oraz wydłużenie czasu implementacji nowych funkcjonalności. Większy to wprowadzone podatności i dziury w bezpieczeństwie naszych systemów. Najbardziej przykre konsekwencje jakie widziałem to odpływ ludzi z zespołu.

Czy lepiej napisać coś w sposób prosty i mało skomplikowany – przykładowo, poprzez wykorzystanie zwykłych pętli zamiast wyrażeń funkcyjnych? Jak to się ma w późniejszym etapie do testów?

Obawiam się, że gdyby zadać to samo pytanie dwóm grupom programistów: tym z kompetencjami w używaniu paradygmatu obiektowego i tym z kompetencjami w używaniu paradygmatu funkcyjnego, to ich odpowiedzi byłyby ze sobą sprzeczne. Mogę przypuszczać, że programiści z obozu paradygmatu funkcyjnego uważaliby, że zwijanie lewo lub prawostronne jest dla nich prostsze i bardziej intuicyjne niż iterowanie i zliczanie wartości elementów w pętli. Ze względu na swoje kompetencje i znajomość paradygmatu mogą inaczej rozumieć zasadę KISS.

Można też spojrzeć na to pytanie z innej perspektywy. Pętla zamiast wyrażenia funkcyjnego, wyrażenie funkcyjne zamiast pętli, to detal implementacyjny. Niezbyt istotny z punktu widzenia interfejsu użytkownika, a więc publicznego API klasy, której odpowiedzialność modelujemy. Warto pamiętać, że pierwszymi osobami korzystającymi z rozwiązania są nasi koledzy z zespołu, bo to oni już w trakcie przeglądu kodu będą musieli zapoznać się z naszą propozycją.

Z mojego doświadczenia o wiele lepiej modeluje mi się rozwiązania, pracuje z kodem pisanym na modłę funkcyjną i zarazem pisze do niego testy. Dlaczego? Spodziewam się odpowiedniego wyjścia na odpowiednie wejście. Nie spodziewam się skutków ubocznych, ale te jeśli takie są, to wartość zwracana w postaci monady w klarowny sposób sygnalizuje mi pewne niebezpieczeństwo i podpowie metody, które mogę na niej wywołać. Spodziewam się również, że wszystkie obiekty, z którymi będę pracować, będą niezmienne (immutable). Przykładowo używając biblioteki Vavr.io i pisząc metodę do dzielenia liczb całkowitych, mogę zwrócić typ Either<Problem, Integer> lub Try<Integer> zamiast int lub Integer. W języku Scala typy Either oraz Try są typami standardowymi. Oczywiście mogę zasygnalizować niebezpieczeństwo dzielenia przez zero poprzez użycie wyjątku typu checked, który będę musiał obsłużyć w momencie wywołania metody. Jestem przekonany, że właśnie w tej chwili komuś kto z jakiś względów bardzo intensywnie używał klas z pakietu java.sql maluje się grymas na twarzy. Ewentualnie mogę użyć wyjątku typu unchecked, który udokumentuję w testach jednostkowych oraz w dokumentacji. Jednak bloki try catch trudno komponować, składać, a już na pewno trudno analizować i utrzymywać kod, który używa wyjątków jako wyrażeń sterujących w stylu GOTO. Osobiście wyjątki wolę zostawić do obsługi katastrof takie jak te, kiedy nie mogę utworzyć nowego połączenia do bazy danych, bo ktoś wyjął wtyczkę sieciową z mojego serwera bazodanowego. Zdaję sobie jednak sprawę, że Java jest jaka jest i pomimo deklaracji zwracania obiektów typu Try<Integer> nadal mogę rzucić choćby wyjątkiem RuntimeException z ciała metody. Zakładam jednak, że ja i moi koledzy nie są na tyle złośliwi i nie zwracają też wartości null, kiedy zadeklarowali metodę, która przykładowo zwraca obiekty typu Optional<Device>.

Trochę abstrahując, ale jeśli kogoś zainteresowałem biblioteką Vavr.io i podejściem funkcyjnym, to polecam obejrzeć pragmatyczną i bardzo praktyczną prezentację pana Jarosława Ratajskiego pt. Niepokalany kod (pure code) z konferencji JDD2019.

Czy poziom skomplikowania kodu – w sensie użytych konstrukcji – ma wpływ na testy i jakość oprogramowania?

Statyczne, prywatne zależności nieprzekazywane przez konstruktor, prywatne i długie metody, duża złożoność cyklomatyczna, duże klasy z utils czy manager w nazwie to zdecydowanie konstrukcje, które mają bezpośrednio wpływ na jakość i testy, a raczej ich brak.

W jaki sposób ocenić jakość oprogramowania? Jakimi metrykami warto się posługiwać w dużych komercyjnych projektach? Co się sprawdza, a co nie?

Bardzo ciekawe i otwarte pytanie.

Z jednej strony na poziomie kodu możemy śledzić techniczne podstawowe, miary takie jak: miara spójności (cohesion), sprzężenia (coupling), złożoności cyklomatycznej czy miara pokrycia kodu testami. To jest bez problemu mierzalne i chyba każde środowisko programistyczne (IDE) jest wyposażone w mechanizmy, które potrafią takie rzeczy policzyć. Wymieniłem tylko te podstawowe, ale takich technicznych miar jest zdecydowanie więcej i są szalenie interesujące. Przykładowo metryki proponowane przez panów Lorenza-Kidda czy Lanza-Marinescu, które skądinąd są dostępne w IntelliJ IDEA za pomocą dodatku MetricsTree.

Zawsze pojawia się pytanie co mierzyć, po co mierzyć i jak interpretować otrzymane dane. Moim zdaniem w dużych korporacyjnych projektach warto śledzić te najbardziej podstawowe, ale nie dlatego, żeby prezentować je na slajdach podczas korporacyjnego wydarzenia, ale żeby pomóc sobie pisać i wymyślać trochę lepsze i prostsze rozwiązania. Takie pomiary mają służyć zespołowi.

Na przykład przyjmuje się, że spójność powinna być wysoka, a sprzężenie niskie, ale co z tego wynika? Pragmatycznie, jeśli zamodelowaliśmy rozwiązanie i okazuje się, że spójność dla poszczególnych klas czy modułów jest niska, oznacza to, że wymieszaliśmy odpowiedzialności. Jeśli sprzężenie jest wysokie oznacza to, że elementy naszego rozwiązania są ze sobą ściśle związane, wiedzą o swoich detalach implementacyjnych i bardzo mocno od siebie zależą. Złożoność cyklomatyczna zaś, im większa, tym kod trudniejszy do zrozumienia. Mnie w głównej mierze zależy na tym, żeby mieć punkt odniesienia. Łatwiej też mi jest na podstawie wyników takich miar dyskutować jakie to moje rozwiązanie jest. Nie będę przecież kłócił się z komputerem o to, że moje rozwiązanie jest proste, jeśli właśnie wybuchła wartość miary złożoności cyklomatycznej. Wydaje mi się, że takie informacje mogą między innymi pomagać w trakcie przeglądu kodu.

Co do miary pokrycia kodu testami, to ta miara jest na ogół kontrowersyjna i jest negatywnie nacechowana ze względu na różne historie, kiedy to na poziomie organizacji takie mierzenie bywało po prostu bezrefleksyjnie narzucone. Czy kod pokryty w 100% testami to taki kod, w którym nie będzie błędów, a może lepiej, żeby był pokryty w 80%? W jaki sposób pokryty? Względem pokrycia gałęzi, pokrycia instrukcji kodu, a może linii? Ile mamy tych linii? Jakimi testami? Im więcej testów tym lepiej? Pytań jest wiele i dobrze, aby to sam zespół odpowiedział sobie na pytanie co będzie mu najlepiej służyć, z czego będzie mieć zysk. Co do tego pytania czy kod pokryty w 100% testami będzie bezbłędny, to na wstępie naszej rozmowy wspomniałem, że interesuję się też testami mutacyjnymi, a przypominam to dlatego, że jestem sobie w stanie wyobrazić sytuację, w której mam testy jednostkowe, pokryłem kod w 100% mierząc pokrycie w liniach, a jednocześnie posiadanie tych testów nic mi nie daje, bo przykładowo zmieniając w kodzie jakiś warunek na przeciwny, wszystkie testy nadal są zielone. Jak to możliwe? Mogę bardzo lubić bibliotekę Mockito, ale nie rozumieć jak jej poprawnie używać. Jak się mają do tego testy mutacyjne? Wprowadzają nową miarę – miarę ocalałych mutacji. O co chodzi? W skrócie: deterministycznie modyfikujemy kod, uruchamiamy testy i jeśli testy nadal przechodzą, to znaczy, że taka mutacja przetrwała. Im więcej mutacji, które ocalały, tym gorzej dla nas, bo wynika z tego, że jeśli my sami wprowadzimy przez przypadek taką zmianę, to nie będziemy jej świadomi i o jej efektach prawdopodobnie dowiemy się o wiele później. Jeśli ktoś byłby zainteresowany tematem szerzej, to polecam tutaj artykuł – Test-driven development with mutation testing – an experimental study – wspomnianego wcześniej pana Adama Romana i pana Michała Mnicha.

Jeśli chodzi o mniej techniczne miary, to jakiś czas temu miałem okazję przeczytać bardzo interesujący artykuł pracowników Microsoft Research i Uniwersystetu Merylandu pt. The Influence of Organizational Structure On Software Quality: An Empirical Case Study. Jest to badanie, w którym autorzy starali się empirycznie zbadać prawo Conwaya i odpowiedzieć na pytanie jak złożoność organizacyjna wpłynęła na jakość procesu wytwarzania systemu operacyjnego Windows Vista. Autorzy zdefiniowali między innymi miary takie jak ilość inżynierów pracujących nad danym fragmentem kodu, ilość inżynierów, która pracowała nad danym fragmentem kodu, ale zdecydowała się opuścić organizację, częstotliwość zmian w danym fragmencie kodu, poziom odpowiedzialności, poziom hierarchii, decyzyjności i tak dalej. W oparciu o tak usystematyzowane dane zbudowali model statystyczny. Gdyby artykuł był opublikowany dziś, to jego autorzy dla zwiększenia poczytności, mogliby napisać, że zastosowali algorytmy uczenia maszynowego, bo przecież wykorzystali w tym celu metodę regresji, analizę głównych składowych czy korelację rang Spearmana. Do brzegu. Co się okazało? Okazało się, że na podstawie nietechnicznych danych, które zebrali, otrzymali następujące wyniki:

  • średnia precyzja (precision) wskazania kodu, który był podatny na awarię wyniosła 87%. Precyzja jest miarą mówiąca o tym jaka część przypadków zakwalifikowanych jako podatne na awarie były w rzeczywistości podatne na awarie,
  • średnia czułość (recall) wskazania kodu, który był podatny na awarię wyniosła 84%. Czułość jest miarą skuteczności poprawnego zidentyfikowania kodu zakwalifikowanego jako podatny na awarie.

Można zatem powiedzieć, że gdyby jeszcze raz pisać system operacyjny, to bylibyśmy w stanie na bazie takich nietechnicznych miar przewidzieć z dużym prawdopodobieństwem czy kod będzie podatny na awarie czy nie.

Autorzy w artykule konkludują, że na podstawie swoich wyników nie mogą a priori założyć, że ich model może być równie skuteczny dla innych organizacji, proponują prowadzenie dalszych badań oraz przyznają, że wyniki wskazań dla kodu Windows Vista są dla nich w pełni zadowalające.

Bardzo ciekawą perspektywę na analizę behawioralną rzuca też pan Adam Tornhill we wspomnianej przeze mnie wcześniej książce, której jest autorem.

Skoro już mowa o „dużych komercyjnych projektach” to do domyślam się, że raczej nikt manualnie nie jest w stanie wszystkiego sprawdzić. Przydało by się trochę automatyzacji. Jak to wygląda w praktyce?

Ma Pan rację. W praktyce wygląda to tak, że najczęściej w procesie ciągłej integracji używa się narzędzia, które potrafi zmierzyć podstawowe metryki. Dla przykładu takim narzędziem jest wcześniej wspomniany Sonar.

Jaki jest główna wada testów automatycznych?

Kolejne ciekawe pytanie!

Testy automatyczne trzeba napisać, co na ogół wcale nie jest prostym zadaniem. Trzeba mieć trochę wprawy, żeby wymyślić dobre przypadki testowe, które uwzględnią przypadki brzegowe lub pokryją sytuacje wyjątkowe. Dodatkowo takie testy trzeba utrzymywać oraz analizować takie, które są niestabilne. Być może trzeba będzie również rozszerzać istniejące narzędzia testowe ze względu na nasze potrzeby. Dla mnie testy automatyczne to inwestycja długoterminowa. Powodów, dla którego chcę je pisać jest kilka. Lubię spać spokojnie i wiedzieć, że moje zmiany niczego nie zepsują, nie wprowadzą regresji. Mam też dowód na to, że oprogramowanie, do którego kontrybuuję, działa zgodnie z założeniami.

Dodatkowo z perspektywy codziennej pracy, wydaje mi się, że zdarza nam się różnie rozumieć rodzaje testów. Przykładowo jeden zespół testy jednostkowe postrzega jako podejście pisania jednego testu na jedną klasę, inny zespół może rozumieć testy jednostkowe jako testy modułowe lub komponentowe, a jeszcze inny dzieli swoje testy tylko na małe, średnie i duże. Dlatego też warto ustalić w swoim zespole zasady co do pisania testów i ujednolicić nomenklaturę.

Wadą i problemem testów automatycznych może być też to, że wywróciliśmy piramidę testów lub ją zdeformowaliśmy choć nie mieliśmy ku temu powodów. To z kolei powoduje, że w naszym inwentarzu posiadamy bardzo dużo testów, które zazwyczaj trwają długo i mogą być niestabilne (testing.googleblog.com/2008/04/tott-avoiding-flakey-tests.html). Co to jest ta wspomniana wyżej piramida testów? To model, którego głównym założeniem jest to, aby posiadać taką proporcję testów (martinfowler.com/bliki/TestPyramid.html, martinfowler.com/articles/practical-test-pyramid.html), w której:

  • najwięcej mamy tych, które są najtańsze w utrzymaniu i ich czas wykonania jest bardzo krótki. Są uruchamiane bardzo często (najczęściej po każdej zmianie w kodzie) przez co pętla zwrotna jest niemal natychmiastowa (np. testy jednostkowe),
  • o wiele mniej tych o średnim czasie wykonania (np. testy integracyjne),
  • niewiele tych, których wykonanie trwa długo (np. testy typu end to end), a utrzymanie kosztuje dużo.

Co to znaczy, że piramida testów została zdeformowana? Pomiędzy testami jednostkowymi, a testami typu end to end (E2E) mamy dużą dziurę i o błędach tak prostych jak przykładowo źle skonfigurowany adres kolejki na danym środowisku dowiemy się po bardzo długim czasie (testing.googleblog.com/2020/11/fixing-test-hourglass.html).

Dlaczego wspomniałem o dobrych powodach wywracania piramidy testów? Wyobraźmy sobie sytuację, kiedy z różnych względów przejęliśmy rozwój jakiegoś działającego produkcyjnie systemu i okazało się, że w jego repozytorium nie ma ani jednego testu jednostkowego, jest kilka integracyjnych i kilka testów typu end to end. Jeśli taki system działa, ma swoich użytkowników, których jest wiele, to prawdopodobnie najczęstsze i najprostsze błędy zostały poprawione. Do czego zmierzam? W takiej sytuacji kurczowe trzymanie się modelu piramidy testów i rzucenie się na pisanie testów jednostkowych byłoby chyba najgorszym z możliwych rozwiązań. Te mogłyby być trudne do napisania bez wcześniejszej jego refaktoryzacji, a ta zwykle pociąga za sobą zmiany w kodzie. Kwestia odwracania piramidy testów była też poruszona podczas wspomnianego przez mnie wystąpienia TDT – jedyne słuszne podejście do testów. Dodatkowo ostatnio śledziłem bardzo ciekawą i żywą dyskusję nawiązującą do tego samego obszaru na forum 4programmers.net. Polecam ją prześledzić.

Jaka jest ich największa zaleta?

Komputer lubi wykonywać powtarzalne czynności. Nie czuje znudzenia i prezentuje wyniki takie jakie są. Testy automatyczne to też żyjąca dokumentacja dla zaimplementowanych funkcjonalności. Warto nie zapominać, że kod testów będziemy czytać i analizować równie często co kod produkcyjny. Wydaje mi się, że powinniśmy dbać o czytelność oraz utrzymanie testów tak samo jak robimy to z kodem produkcyjnym. Dlatego, jeżeli uruchamiamy statyczną analizę kodu to zróbmy to także dla testów. Podobnie zróbmy z automatycznymi regułami do formatowania kodu, jeśli takie mamy.

Jeszcze co do czytelności kodu testowego. Zauważyłem, że nie tylko łatwiej mi się czyta i analizuje kod testowy, ale i pisze testy, kiedy strukturyzuję kod w sekcje: dane i warunki początkowe (given), akcja (when), spodziewana reakcja (then)martinfowler.com/bliki/GivenWhenThen.html – oraz nazwam to, co zamierzam przetestować jako SUT (software under test).

W których momentach sprawdzają się one najlepiej, a których najgorzej?

Może nie najgorzej, ale zdecydowanie nie najlepiej sprawdzają się wtedy, kiedy są niestabilne (flaky). Co to znaczy? Takie testy potrafią cyklicznie, co jakiś czas dawać wyniki fałszywie dodatnie mimo braku wdrożonych zmian w danym środowisku. Niech pierwszy rzuci kamieniem ten, kto w pracy nie słyszał “ten test integracyjny, co z nim jest zawsze problem nie przeszedł. Co zrobić? Puść jeszcze raz!”, a potem takich testów jeszcze ze dwa razy nie puszczał i marnował nie tylko swój czas, ale i współdzielone zasoby. Pytanie jak zinterpretować wynik takiego testu, kiedy jednak wdrożyliśmy nowe zmiany, a ten znowu przechodzi dopiero za trzecim razem. Może właśnie ktoś wprowadził defekt polegający na tym, że dany serwis z jakiegoś względu odpowiada na rządzenie dopiero za trzecim razem? Jaki poziom zaufania mamy do tego testu? Czy czujemy się z nim bezpieczniej wdrażając na produkcję? Dlatego zamiast zamiatać problem pod dywan, najlepiej poświęcić czas na zidentyfikowanie źródła problemu i taki test po prostu poprawić. Kilka praktycznych rad jak ugryźć problem niestabilnych testów opisał pan Jason Palmer z firmy Spotify w swoim wpisie blogowym pt. Test Flakiness – Methods for identifying and dealing with flaky tests. Co ciekawe, w moim zespole dla testów integracyjnych i tych typu end to end zaimplementowaliśmy podobny mechanizm dla wspomnianego w powyższym wpisie Flakybota. Nazwaliśmy go kwarantanną. Wspomniane wyżej testy są uruchamiane równolegle w sposób ciągły, a podczas wdrażania nowej wersji danego serwisu taki test musi, dajmy na to, przejść minimum 10 razy. Kiedy takie wdrożenie się nie powiedzie, dostajemy odpowiednie powiadomienie na czacie. Dla każdego z wdrażanych serwisów zestaw testów może być inny, a wartości co do liczby przejść są w pełni konfigurowalne. Dodatkowo, monitorujemy te testy i bardzo szybko za pomocą przygotowanych przez nas wizualizacji możemy prześledzić ich przebiegi.

Wydaje mi się również, że testy automatyczne też nie sprawdzają się najlepiej wtedy, gdy są mocno sprzężone z detalami implementacyjnymi. Przychodzi mi do głowy taka sytuacja, w której chcielibyśmy w miarę bezboleśnie przejść z jednego rozwiązania na inne. Na przykład ze względu na nowe wymagania niefunkcjonalne odnośnie przechowywania danych, zdecydowaliśmy, że lepszym rozwiązaniem będzie CosmosDB niż Azure Cache for Redis. Jeśli wszystkie nasze testy integracyjne lub jednostkowe zamiast zależeć od implementacji interfejsu pobieracza danych, zależą od specyficznego klienta Redisa to taka zmiana zazwyczaj będzie czasochłonna i bolesna, ale do przejścia.

Kiedy testy automatyczne sprawdzają się najlepiej? Taki pierwszy przykład z brzegu. W monitoringu naszego systemu widzimy, że jakiś serwis ma problem z przetworzeniem pewnych danych wejściowych. Możemy wziąć te dane, otworzyć odpowiedni test, który sprawdza tę funkcjonalność i rozszerzyć go o te dane, które właśnie skopiowaliśmy. W ten sposób mamy szybką pętla zwrotną. Jeśli rozszerzany test to test jednostkowy lub integracyjny, który dla tych danych nie przechodzi, to tak jakbyśmy tego problemu nie mieli, bo powinniśmy go w miarę szybko zidentyfikować i poprawić.

Jakich narzędzi warto używać do automatyzacji testów? Mówimy tutaj oczywiście o środowisku Java.

Standardowo do pisania testów mogę polecić JUnita w wersji 5 z rozszerzeniem AssertJ. Popularny jest też Spock, ale nie mogę o nim nic więcej powiedzieć, bo z niego nie korzystałem, choć wygląda bardzo podobnie do ScalaTest, w którym zdarzyło mi się pisać testy. Jeśli chodzi o takie testy opisowe, w stylu BDD, to jest Cucumber i on też dobrze integruje się z JUnitem. Stare dobre JaCoCo może posłużyć za narzędzie, które policzy nam pokrycie kodu testami i jest ono przy okazji bardzo konfigurowalne w tym względzie.

Co do testów wydajnościowych, to ostatnio w zespole zaczynamy eksperymentować z Locustem. Pomimo, że jest to narzędzie zbudowane przy użyciu języka Python, to daje możliwości pisania scenariuszy w języku Java. Alternatywą do Locusta może być JMeter, z którym sam mam doświadczenie. O tyle jest on ciekawy, że ma wersję graficzną, gdzie w miarę proste scenariusze testowe można wyklikać chociaż sam wynikowy format może kogoś przestraszyć, bo to stary i poczciwy XML. Bardziej skomplikowane rzeczy można napisać przy pomocy języka Groovy.

Jeśli chodzi o testy mutacyjne, to polecam bibliotekę Pitest, która działa bardzo sprawnie i stabilnie. Bezproblemowo integruje się z JUnitem 4 i 5.

Dodatkowo, ostatnim moim odkryciem jest biblioteka TestContainers, która również ma wsparcie dla narzędzia JUnit. Ubolewam tylko, że dla usług chmury Azure nie ma jeszcze zbyt wielu dostępnych symulatorów usług tak jak jest to dla chmury AWS czy GCP. W prywatnych projektach przestałem korzystać z bazy H2, która jest bazą typu in-memory. Zamieniłem ją na rzecz takiej, której rzeczywiście używam (najczęściej jest to Postgres).

Do pisania symulatorów usług zewnętrznych, które do komunikacji wykorzystują protokół HTTP korzystam z narzędzia WireMock.

Na testach GUI nie znam się, ale wydaje się, że takim popularnym narzędziem jest Selenium.

Jaka jest skala trudności w ich używaniu?

Te narzędzia są wbrew pozorom bardzo proste w użyciu. Właściwie działają od tak. Jedyna trudność to dodanie zależności do projektu, a potem interpretacja wyników, wyciąganie odpowiednich wniosków i poznanie pełnego wachlarza ich możliwości jak i dobrych praktyk korzystania z nich.

Jaka wygląda „droga” od wykrycia błędu do jego usunięcia na środowisku produkcyjnym?

Jeśli zmiany, które powodują błąd przeniosły się na środowisko produkcyjne pomimo testów na środowisku lustrzanym do produkcyjnego (stage), to możliwości są zasadniczo dwie. Albo nasz monitoring pokaże nam problem, albo ten problem wskaże nam użytkownik końcowy. Przykładowo, jeśli z danych monitoringu wynika, że jest to ewidentnie problem z nową wersją danego serwisu i zmiany na interfejsach są kompatybilne wstecz, to możemy szybko wdrożyć jeszcze raz ostatnią dobrze działającą wersję tego serwisu i w cieplarnianych warunkach wziąć ten problem na tapet, spróbować go zreprodukować i o ile się da, dopisać testy, które udowodnią, że problem został rozwiązany. Osobna kwestia, to analiza tego, dlaczego nasze testy zawiodły i defekt został wykryty dopiero w środowisku produkcyjnym. Schody zaczynają się wtedy, kiedy zmiany nie są kompatybilne wstecz.

Jeszcze co do monitoringu to jeśli nasz serwis rozmawia z serwisem, który dostarczany jest przez inny zespół, a nie mamy wdrożonych testów kontraktowych, to może się okazać, że powodem błędu w naszym serwisie jest zmiana schematu wysyłanej wiadomości przez inny serwis i w takim wypadku naprawa takiego defektu może wyglądać bardzo różnie. Byłaby najpewniej bardziej skomplikowana.

Trudne do rozwiązywania są dla mnie błędy, które występują losowo lub ciężko jest je zreprodukować lokalnie lub w środowisku, do którego mam dostęp. Zauważyłem, że nad wyraz często takie losowe błędy zdarzają się wtedy, gdy programujemy wielowątkowo dzieląc stan między wieloma wątkami dodatkowo go modyfikując. Co więcej, często takie niedetermistyczne błędy mogą być związane nie tylko z działaniem naszego kodu, ale również działaniem lub chwilową niestabilnością infrastruktury, której używamy. Może również się tak zdarzyć, że jako programista nie będę mieć bezpośredniego dostępu do środowiska produkcyjnego, choćby ze względu na politykę podziału obowiązków (segregation of duties) lub ze względu na bardzo wrażliwe dane, które przetwarzamy.

Inną, równie problematyczną klasą błędów są dla mnie błędy typu Heisenbug. Są o tyle niewdzięczne, że znikają podczas próby ich diagnozy. Nie obserwujesz, występują. Obserwujesz, jak na złość ich nie ma.

Jak szybko wykryte błędy powinny zostać usunięte z produkcji?

Na to pytanie nie ma innej i dobrej odpowiedzi niż “jak najszybciej”. Jednak najlepiej, żeby błędy zostały wykryte dużo wcześniej niż na produkcji, a jeśli już koniecznie w środowisku produkcyjnym, to u specjalnej grupy użytkowników, których będziemy obsługiwać jako pierwszych w ramach wdrożenia kanarkowego.

Jeśli chodzi zaś o same defekty to te mogą mieć różne priorytety (priority) i ważności (severity). W słowniku pojęć ISTQB znajdziemy, że ważność definiowana jest jako “stopień wpływu defektu na rozwój lub działanie modułu lub systemu”, a priorytet, to “poziom (biznesowej) ważności określony dla elementu, np. defektu.”. W mojej praktyce spotkałem się z różną skalą wartości zarówno dla priorytetu jak i ważności problemów. Częściej jednak posługiwałem się albo priorytetem albo ważnością.

Warto zdawać sobie sprawę z tego, że krytyczne defekty, kiedy przykładowo użytkownik nie może zalogować się do systemu nie mogą czekać. Może się zdarzyć, że do jego naprawy i inwestygacji zostaniemy obudzeni w środku nocy w ramach dyżuru pod telefonem (on call support). Błędy takie jak literówka w stopce wygenerowanego raportu na potrzeby wewnętrzne zazwyczaj mogą poczekać.

Dodatkowo, dla różnych produktów i dla różnych organizacji ten czas rozwiązywania defektów może być różny choćby ze względu na wyżej wspomniane błędy o różnych priorytetach i ważnościach. Ten czas jest częścią umowy o gwarantowanym poziomie świadczenia usług (SLA).

Jakie działania warto podjąć aby w późniejszej sytuacji uniknąć podobnego problemu?

Zrobić rachunek sumienia i solidną dokumentację postmortem na bazie której zdecydujemy na przykład o zwiększeniu obserwowalności (observability) naszego produktu, aby doprowadzić do sytuacji, w której, gdyby podobny problem miał się zdarzyć, to bez zaglądania do logów będziemy potrafili go w miarę szybko zdiagnozować. Dodatkowo, jeśli dla przykładu nie mieliśmy testu dla scenariusza, który spowodował dany problem, to taki test trzeba napisać.

Na zakończenie warto pamiętać, że za błędy odpowiedzialny jest zespół jako kolektyw, nigdy pojedynczy jego członek. Ktoś pisze kod, inni robią jego przegląd, akceptują zmiany, dyskutują wcześniej kryteria akceptacyjne.

Co więcej, może ze wspomnianego rachunku sumienia wynikać, że problem kłopotów produkcyjnych jest systemowy, a nie jednostkowy.

Dzięki za rozmowę.

Spodobało się?

Jeśli tak, to zarejestruj się do newslettera aby otrzymywać informacje nowych artykułach oraz akcjach edukacyjnych. Gwarantuję 100% satysfakcji i żadnego spamowania!

, , , , , , , , , , , , , , , , , , ,

Dodaj komentarz

Komentarze (2)

  • Radek pisze:

    Czy to prawda że w Kotlinie i Rust jest dużo mniej do testowania niż w Javie? Co ze Scala, też ma więcej zabezpieczeń ?

    • Ryszard Makuch pisze:

      Cześć! Ciekawe pytanie. Zaznaczam, że żaden ze mnie kotlinowy wyjadacz. Napisałem w nim mało produkcyjnego kodu, więc nie traktuj tego komentarz jako wyrocznię. Wydaje mi się, że z punktu widzenia pisania testów funkcjonalnych to nie. Tych przypadków testowych pewnie będzie tyle samo. Jednak jeśli popatrzymy choćby na kotlinowe wsparcie 'null-safety’ (https://kotlinlang.org/docs/null-safety.html#safe-calls) to w testach jednostkowych mogą odpaść przypadki typu: „a co, jeśli przekażę tam nulla?”. Dlaczego? Tego „nulla” możesz przekazać tylko wtedy, kiedy się go spodziewasz, to znaczy, że po typie musisz dodać pytajnik (np. String?).

Odpowiedz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *

Pin It on Pinterest