Poprawne pisanie oprogramowania jest formą sztuki i jako takie wymaga stosowania odpowiednich narzędzi w odpowiedniej ilości. Bardzo ważnym elementem pisania dowolnego oprogramowania jest testowanie tego rozwiązania.
Testowanie dowolnego oprogramowania nie jest prostym zadaniem. Projekty są złożone, skomplikowane, zawierają wiele przecinających się funkcjonalności. Często może się wydawać iż niemożliwym jest pełne ich przetestowanie. Z tego też powodu w wielu projektach na testy przeznacza się zbyt mało czasu i uwagi a błędy naprawia się po ich wystąpieniu na produkcji.
Aby zrozumieć różnicę między różnymi rodzajami testów warto ustalić słownik pojęć, gdyż terminy te, niestety często są rozumiane różnie.
- Testy jednostkowe – testują jednostkę kodu: klasę, obiekt czy funkcję w izolacji od pozostałych klas w systemie; jest testowany kod wyłącznie w danej jednostce kodu a wszelkie zależności są symulowane przez mocki lub stuby
- Testy integracyjne – testują wiele jednostek kodu w różnym zakresie: od kilku połączonych klas aż do używania różnych systemów (baza danych, serwer aplikacyjny) lub nawet usług zewnętrznych takich jak np. brama usługi oraz interfejsów użytkownika. Często na testy integracyjne opierające się o systemy, czyli takie które wykorzystują np. system bazy danych lub serwer aplikacyjny, mówi się testy systemowe. Testy integracyjne, które wykorzystują także interfejs użytkownika nazywamy inaczej testami funkcjonalnymi lub end-to-end (e2e) np. Selenium czy SoapUI. Inną stosowaną nazwą są testy akceptacyjne, które zakładają testowanie na pełnym środowisku, w celu finalnej akceptacji przez decydentów i najczęściej są jakąś formą testów e2e.
- Testy wydajnościowe – cała grupa testów, której celem nie jest sprawdzenie poprawności rozwiązania a wydajności; Apache Bench, JMeter, JMH czy LoadUI to przykłady narzędzi często stosowanych do takich testów
- Testy bezpieczeństwa – cała grupa testów które mają na celu analizę oprogramowania pod kątem bezpieczeństwa. Są to zarówno narzędzia skupiające się na całych środowiskach uruchomieniowych np. OpenVAS, Nessus, Kali, Metasploit (często mówi się o takich testach Pentesty) oraz narzędzia skupiające się na analizie kodu np. Red Hat Victims, BlackDuck
Poniższy graf przedstawia relację testów jednostkowych i integracyjnych.
W dalszej treści skupimy się wyłączenie na testowaniu poprawności oprogramowania pod kątem logiki tzn. testami jednostkowymi oraz integracyjnymi.
Poniżej przedstawiam porównanie testów jednostkowych i integracyjnych:
Jednostkowe | Integracyjne (same klasy) | Systemowe (np. Spring-Test) | Funkcjonalne/e2e (np. Selenium) | |
---|---|---|---|---|
Czas wykonania per test | ~ 0,02 sec | do ~ 0,5 sec | ~ 2-10 sec + czas inicjacji (min. 30 sec) | ~ 30-300 sec + czas inicjacji (min. 30 sec) |
Wymagane zasoby | minimalne | minimalne | od 500 MB RAM do 4 GB i więcej | od 500 MB RAM do 4GB i więcej + przeglądarka |
Izolacja | pełna | częściowa | ograniczona | brak |
Czas pisania testu | 1-2 h | 1-4 h | duża inwestycja na początku pisania takich testów (~ 40 man/day) i większa niż w jednostkowych w późniejszym okresie (2-6 h) | w zależności od sposobu i od technologii aplikacji waha się od średniego (~4h) do bardzo dużego (~40h) |
Możliwość testowania wszelkich warunków | ✅ | ❌ | ❌ | ❌ |
Testowanie przekrojowe | ❌ | ❌ | ✅ | ✅ |
Warto zwrócić uwagę na problem złożoności oprogramowania w kontekście testowania. Aby o tym powiedzieć trzeba wyjaśnić krótko czym jest złożoność cyklomatyczna. Jest to w uproszczeniu miara złożoności danego fragmentu kodu, wprost oznacza ilość możliwych ścieżek jakimi procesor może wykonywać kod. Im większa wartość tym większa złożoność. Każdy warunek lub pętla w kodzie zwiększa tą złożoność. Więcej informacji tutaj: https://en.wikipedia.org/wiki/Cyclomatic_complexity
Weźmy pod lupę następujący przykład. Mamy 4 bloki kodu z niewielką złożonością: 16, 8, 12 i 8, gdzie blok 1 wywołuje 2, 2 wywołuje 3, 3 wywołuje 4 oraz 1 dodatkowo wywołuje 4.
Jeżeli taki układ klas chcemy przetestować integracyjnie, będziemy musieli przygotować 12 296 testów 😧 z różnymi danymi wejściowymi! Dopiero w takim wypadku testujemy wszystkie możliwości w jaki nasza aplikacja może się zachowywać. Dzieje się tak, ponieważ przy wywołaniu kodu złożoność bloków kodu się mnoży jako, że złożoność wprost reprezentuje różne drogi przejścia przez nasz system. Prowadzi to bardzo szybko do eksplozji ilości wymaganych testów, co nigdy nie jest akceptowalne do wykonania.
W przypadku gdy ten sam układ klas będziemy testować jednostkowo, będziemy zamiast rzeczywistych zależnych bloków kodu używać symulacji tkz. mocków oraz stubów. Dzięki temu testujemy wyłącznie kod znajdujący się w jednym bloku kodu. Dlatego też potrzebujemy wyłącznie tyle testów ile wynosi złożoność kodu w danych blokach, ale sumując ją a nie mnożąc. W powyższym przypadku daje to wynik 44 wymaganych testów, co jest jak najbardziej akceptowalne.
Oba sposoby prowadzą do osiągnięcia tego samego efektu – całkowicie przetestowanej aplikacji – 100% pokrycie kodu testami. Ale różnią się diametralnie pod kątem wysiłku na przygotowanie, utrzymanie oraz samego czasu wywołania.
- Jednostkowe
- 44 testy jednostkowe powinny się wykonać w okolicy 3 – 4 sekund
- napisanie 44 testów jednostkowych to koszt ~66 h pracy
- Integracyjne (np. Arquillian)
- 12296 testy integracyjne wywoływały by się ~17h
- napisanie tych testów to koszt ~6188 man/day -> 24 lata dla jednej osoby
- Integracyjne (np. SoupUI lub Selenium) ale tylko dla wybranych ścieżek dla porównania z jednostkowymi
- 44 testy integracyjne wywołują się ~ 5min
- napisanie ich to koszt ~22 dni plus wstępny czas napisania narzędzi ~40 dni
Jest bardzo duża różnica w projekcie jeżeli testy wykonają się w kilka sekund a kiedy wykonują się kilka minut lub nawet godzin.
💡Warto zwrócić uwagę, że testy jednostkowe na ogół posiadają bardzo dobrą izolację. Dzięki temu można uruchomić ich testowanie równolegle i jeszcze bardziej skrócić czas testów!
W tym momencie nasuwa się myśl:
Ok, to powinniśmy pisać wyłącznie testy jednostkowe
Niestety takie podejście może się szybko zemścić. Pisząc wyłącznie testy jednostkowe, symulujemy pozostałą część systemu i często możemy w tej symulacji poczynić błędne założenia. Może to doprowadzić iż nasze testy jednostkowe dają wynik pozytywny, tymczasem nasza aplikacja dalej nie działa. Dlatego niezbędne jest używanie również testów integracyjnych, które mogą potwierdzić prawidłowość założeń, które poczyniliśmy pisząc testy jednostkowe. Warto też powiedzieć, że najlepszymi kandydatami na testy integracyjne są przypadki pozytywne, gdyż mają one tendencję do przechodzenia największej części systemu. Przypadki pozytywnie przechodzą przez większość kodu w naszym systemie – są najbardziej przekrojowe.
Jak pisałem na samym początku, tworzenie oprogramowania to forma sztuki. Tak jak z malarstwem, stolarstwem czy nawet gotowaniem. Trzeba użyć odpowiedniego narzędzia, składnika do tego w odpowiedniej ilości aby osiągnąć sukces. Czy jakikolwiek mistrz stolski powie kiedykolwiek:
To wszystko jest zbyt skomplikowane! Trzeba to uprościć! Nie będę używał tych wszystkich narzędzi, a zamiast tego będę produkował wszystkie meble tak samo przy użyciu młotka!
Postarajmy się być specjalistami i dobierać optymalnie narzędzia do potrzeb.
O dokładne proporcję testów jednostkowych i integracyjnych zawsze można się spierać. Ja osobiście uważam, że najlepiej działa zasada jeden test integracyjny na dany proces biznesowy, ale też stosunek 1 : 20, czyli jeden test integracyjny na 20 testów jednostkowych jest dobrą aproksymacją.