Ta strona wykorzystuje pliki cookie w celu prezentacji dopasowanych dla Ciebie treści. Możesz włączyć/wyłączyć obsługę plików cookies w swojej przeglądarce.

Dowiedz się więcej

PO CO TESTOWAĆ

Nikogo nie trzeba przekonywać, że błędy w oprogramowaniu potrafią być w najlepszym razie irytujące, a wielu sytuacjach niezwykle kosztowne, czy wręcz tragiczne w skutkach.

Bezbłędny kod

Nie wiemy czy masz podobne odczucia, ale nam, kiedy z rzadka udaje się napisać za jednym zamachem kilka wierszy poprawnie działającego algorytmu, ogarnia niepokój. Dlaczego? Przede wszystkim dlatego, że zazwyczaj jest inaczej. Konieczne jest kilka mniejszych lub większych poprawek, czasem okazuje się wręcz, że cały pomysł na dany fragment trzeba od nowa przemyśleć. Uruchamiamy swój kod kilka razy i często dopiero wtedy zauważamy literówki, nie mówiąc już o problemach większego kalibru. Najczęściej jednak naprawdę poważne niedociągnięcia ujawniają się dopiero później.

Zastanów się co oznacza, że kod jest bezbłędny?

Czy znaczy to, że:

Stosunkowo łatwo jest udowodnić występowanie błędu. Nie da się jednak wyeliminować ryzyka, że błąd jest, tylko dotąd nikt go jeszcze nie zauważył. Coś może pójść nie tak na bardzo wiele sposobów, podczas gdy prawidłowy wynik jest bardzo często tylko jeden.
Czy to znaczy, że jesteśmy bezradni?

Wystarczająco dobry kod

Jesteśmy ludźmi praktycznymi. Nawet początkujący programista, pisząc najprostszy skrypt, uruchamia go wielokrotnie na różnych etapach, z różnymi wymyślonymi danymi, aby sprawdzić "czy działa" i zamiast martwić się wszystkimi możliwymi problemami, uznaje kod za akceptowalnie poprawny, jeśli przejdzie on pomyślnie te wyrywkowe próby.

Zastosowanie podobnego podejścia w odpowiednio systematyczny sposób pozwala nam powiedzieć, że co prawda dalej nie mamy gwarancji, że błędów nie ma gdzie indziej, ale "w zakresie tego, co sprawdzaliśmy", kod jest bezbłędny. Pozostaje zatem skupić się na tym, aby "to, co sprawdzaliśmy" było wystarczające.

Ćwiczenie #1

Napisz funkcję tic_tac_toe_winner która sprawdza kto wygrał klasyczny wariant gry w kółko i krzyżyk na podstawie widoku planszy 3x3 pola. Plansza podawana jest jako ciąg 9 znaków X lub O oraz spacji. Funkcja powinna zwracać X (Y), gdy wygrał grający odpowiednio X (Y), oraz None, kiedy nie można tego rozstrzygnąć.

Skup się bardziej na wymyśleniu jak najszerszego spektrum możliwych danych testowych i na tym jak sprawdzać zachowanie funkcji niż na faktycznej jej implementacji.


Wygraną daje umieszczenie trzech symboli w jednym rzędzie, w jednej kolumnie lub na jednej z przekątnych. Zastanów się, jakie są wszystkie możliwe konfiguracje, które dają wygraną jednej ze stron. Ile ich jest? Czy łatwiej jest udowodnić czyjąś wygraną czy raczej remis?

Możesz wykorzystać poniższy szablon:

 def tic_tac_toe_winner(board):
   return ...

test_cases = {
   'XO X O X': 'X',
   'OX O X O': 'O',
   'XXOOXXXOO': None,
   ...: ...
}

for board, expectation in test_cases.items():
   response = tic_tac_toe_winner(board)
   assert response == expectation, \
      f'Expected {expectation!r} for {board!r} got {response!r}
Asercje

Instrukcja assert pozwala na sprawdzenie dowolnego warunku a w przypadku gdy nie jest on spełniony rzuca wyjątek AssertionError z opcjonalnym komunikatem. Powyższy przykład użycia assert można traktować jako równoważny konstrukcji:

 response = tic_tac_toe_winner(board)
if not response == expectation:
   raise AssertionError(
      f'Expected {expectation!r} for {board!r} got {response!r}'
   )

Ważną cechą assert jest to, że instrukcja nie jest wykonywana jeśli interpreter został uruchomiony z flagą -O, -OO lub ustawiona jest zmienna środowiskowa PYTHONOPTIMIZE. Pozwala to na umieszczanie asercji jako elementu kodu z zachowaniem możliwości łatwego ich wyłączenia (np. ze względu na poprawę wydajności).

Zapoznaj się z dokumentacją assert.

Możesz użyć znanych Ci frameworków do testowania, ale na tym etapie kursu bardziej pouczające będzie jeśli zabierzesz się za testowanie samodzielnie, od zera.

Jak Ci poszło? Zwróć uwagę, że rozwiązanie kwestii jak skonstruować przypadki testowe często pozwala na lepsze zrozumienie, a dzięki temu także zaimplementowanie rozwiązania problemu właściwego. Pisząc testy zazwyczaj grupujemy je w różne klasy. Osobno przypadki wygranej, dzięki ustawieniu symboli w kolumnie, osobno w rzędzie, osobno po przekątnej, a osobno sytuacje, kiedy następuje remis. Pozwala to też na etapie projektowania uwzględnić i świadomie obsłużyć przypadki szczególne (pusta plansza, niewłaściwe symbole, czy niemożliwa do uzyskania podczas rozgrywki sytuacja na planszy).

Na czacie podziel się wymyślonymi przez siebie przypadkami testowymi!

Ciekawe czy inni wpadli na to, aby przetestować to samo, co Ty?

Coraz lepszy kod

Poszukiwanie odpowiedzi na pytania z zakresu programowania na Stack Overflow jest tak popularne, że powstało określenie Stack Overflow Driven Development, a na PyPi można odnaleźć sporo modułów przyspieszających proces wyszukiwania informacji na tej stronie.

Aby uzyskać odpowiedź, trzeba wiedzieć jak pytać. Jednym z zaleceń jest przygotowanie minimalnego, działającego przykładu (MRE), ilustrującego problem, z którym się borykamy, tak aby uniknąć nieporozumień i nie pozbawiać innych pojęcia kontekstu. Taki przykład powinien też trafić na naszą listę testów, aby problem więcej się nie powtórzył.

Spróbuj przygotować MRE błędu, który ostatnio zdarzyło Ci się poprawiać w swoim kodzie.

Przykład

Jeśli Twoja implementacja tic_tac_toe_winner nie uwzględniała, że dane wejściowe są pustym stringiem, to MRE będzie po prostu wywołanie tic_tac_toe_winner(' '), wraz z implementacją tej funkcji. Do tego dochodzi informacja gdzie i jaki pojawia się wyjątek (na przykład IndexError) lub czego oczekiwaliśmy (na przykład None), a co otrzymaliśmy (na przykład ' '). Jeśli implementacja jest zbyt długa (w praktyce więcej niż kilka linijek kodu to za dużo, kod musi dać się ogarnąć na pierwszy rzut oka), należy ją okroić tak, aby "objawy" pozostały bez zmian (np. często można usunąć kod po wierszu, w którym rzucany jest wyjątek).

Jeśli to coś bardziej skomplikowanego (albo wymagającego spostrzegawczości) możesz pochwalić się na czacie. Ciekawe czy ktoś będzie potrafił znaleźć rozwiązanie?

Nie jest to bezpośrednio tematem tego kursu, ale zwłaszcza w większych projektach cenna jest umiejętność takiego wyizolowania problemu, aby dało się go przedstawić w formie pytania na Stack Overflow. Bardzo często, dzięki takiemu przygotowaniu, nie będzie w ogóle potrzeby go zadawać. Rozwiązanie problemu stanie się dla Ciebie oczywiste już w trakcie przygotowywania MRE lub kiedy zaczniesz go opisywać.

Ćwiczenie #2

Wróćmy do pierwszego ćwiczenia, do wskazywania zwycięzcy gry w kółko i krzyżyk.

Zastanów się, jak odróżnić sytuację, gdy nie ma zwycięzcy (bo np. gra się jeszcze nie skończyła albo nie ma już możliwości jej wygrania przez żadną ze stron) od sytuacji niemożliwych do uzyskania w wyniku prowadzenia rozgrywki, zgodnie z regułami i błędnie podanego wejścia.

Jak zachowywał się Twój kod, jeśli plansza była pełna symboli jednego rodzaju lub miała rozmiar inny niż 9 pól, co jeśli była pustym stringiem? Czy przypadkiem nie został zinterpretowany jako zwycięstwo X układ XXXXXXXXX? Co zwróciła funkcja tic_tac_toe_winner w takim przypadku? Remis? Uznajmy, że takie przeoczenie jest z punktu widzenia logiki gry błędem, który musimy wyeliminować.

Rozszerzmy zatem wymagania o warunek, że funkcja tic_tac_toe_winner winna rzucić wyjątek ValueError, jeśli wejście jest nieprawidłowe dla odróżnienia od nierozstrzygniętego wyniku rozgrywki, gdzie wciąż zwracany będzie None.

Przygotuj przypadki testowe, w których dodatkowo przewidzisz możliwość wystąpienia wyjątku ValueError, jako normalnego (spodziewanego) zachowania funkcji.

Zwróć uwagę, że cechą charakterystyczną uczciwej rozgrywki jest to, że gracze mają na planszy zawsze podobną liczbę symboli – zależnie od tego, w którym momencie popatrzymy na planszę i od tego, kto rozpoczął grę, różnica nie będzie większa niż 1.

Zastanów się też czy np. sytuacja XXXOOO jest możliwa do osiągnięcia?


Możesz wykorzystać poniższy szablon:

 def tic_tac_toe_winner(board):
   if ...:
      raise ValueError()
   return ...

test_cases = {
   'XO X O X': 'X',
   'OX O X O': 'O',
   'XXOOXXXOO': None,
   'XXXXXXXXX': ValueError
   ...: ...
}

for board, expectation in test_cases.items():
   ...
   if isinstance(expectation, Exception):
      try:
         response = tic_tac_toe_winner(board)
         print(f'Expected {expectation!r} for {board!r} got {response!r}')
      except expectation:
         pass

Możesz użyć znanych Ci frameworków do testowania, ale na tym etapie kursu bardziej pouczające będzie jeśli zabierzesz się za testowanie samodzielnie, od zera.

Udało się? Jeśli masz wątpliwości, skonsultuj się z Mentorem lub podpytaj na czacie.

Identyfikacja klas możliwych przypadków pozwala lepiej zrozumieć, jak funkcja może zostać użyta, a przez to lepiej zaprojektować spójny interfejs. Kiedy zwracamy wartość i jaka jest to wartość, a kiedy rzucamy wyjątek i jaki jest to wyjątek. Dzięki temu wywołujący tak przetestowaną funkcję wie czego może się spodziewać!

Przewidywalny kod

Zdefiniowanie kompletnego zestawu przypadków testowych to tak naprawdę sposób zapisu wymagań stawianych danemu komponentowi. Na poziomie całego projektu można dzięki temu zbudować kryteria jego akceptacji. Dzięki spojrzeniu na problem od tej strony można uniknąć wielu nieporozumień. Kod spełniający wymagania to kod przechodzący zaakceptowane wcześniej testy. W praktyce oczywiście nie jest to aż tak proste i ujęcie wymagań biznesowych w sztywne ramy testów może być dużym wyzwaniem. Jednak jest to wysiłek, który się opłaci. Dzięki niemu reguły bedą uporządkowane a proces jednoznaczny.

Zabaw się w analityka!

Wyobraź sobie, że implementujesz funkcjonalność automatycznego "sędziego" w swojej ulubionej grze planszowej lub dyscyplinie sportu. Postaraj się zapisać reguły wygranej lub przegranej w formie zdań warunkowych pozwalających na napisanie testów. Możesz pokusić się też o implementację.

Koniecznie pochwal się wynikiem na czacie!

Kod łatwy do utrzymania

Kod, który jest używany, musi być też rozwijany. Świat idzie do przodu, potrzebne są nowe funkcjonalności, na które nie było zapotrzebowania w momencie projektowania. Inne funkcjonalności wymagają przeróbek ze względu na wymagania użytkowników, konieczność poprawienia wydajności albo bezpieczeństwa, czy wreszcie z powodu starzenia się technologii, w której zostały napisane oryginalnie. Bardzo często okazuje się, że rozwojem zajmą się zupełnie inni ludzie niż ci, którzy zaprojektowali oryginalny kod. Owszem, jest dokumentacja, mogą być komentarze tu i ówdzie, dobrze napisany kod jest też zwykle czytelny, ale nic tak nie pomaga go zrozumieć, jak przykłady jego użycia. Testy są właśnie takimi przykładami, jasno pokazują czego należy się spodziewać i w jakich granicach można przeprowadzić refaktoryzację kodu, aby nie "zepsuć czegoś gdzie indziej".

Ćwiczenie #3

Spróbuj przeprowadzić refaktoryzację implementacji tic_tac_toe_winner. Na pewno można uprościć niektóre warunki, sprawić aby kod stał się bardziej czytelny, nadać lepsze nazwy zmiennych. Uruchom testy aby upewnić się, że takie kosmetyczne w gruncie rzeczy zmiany nie sprawiły, że w kod wkradł się błąd.

Pamiętaj!
Dobre praktyki cały czas obowiązują, wersjonuj swój kod i często commituj.

Czas na zadanie! Po nim zabierzemy się za usystematyzowanie wiedzy o testowaniu.

Zadanie: palindromy

Napisz funkcję is_palindrome, która sprawdza czy argument jest palindromem, a następnie przetestuj na odpowiednio dobranych przypadkach. Postaraj się uwzględnić sytuacje graniczne i nieprawidłowe dane wejściowe. Liczy się kreatywność, psuj śmiało!

 def is_palindrome(data):
   ...

Nie bój się sukcesywnie dodawać nowych warunków. Sformułuj kryteria akceptacji (zakres funkcjonalności). Jeśli w trakcie pracy naprawisz błąd, dodaj ilustrujący go przypadek.

Zastanów się, jak (i czy) uwzględniać:

  • puste wejście
  • różną wielkość znaków
  • całe zdania z interpunkcją
  • liczby całkowite i zmiennoprzecinkowe
  • inne typy danych wejściowych (listy, daty, ...)

Jeśli już masz napisany wcześniej kod realizujący to zadanie, to świetna okazja aby spróbować podejść do znanego Ci problemu w nowy sposób. Zastanów się też czy łatwiej jest napisać nową funkcjonalność i testy od początku, czy raczej dopisać testy do istniejącego kodu? Czy odpowiedź na to pytanie zależy od stopnia skomplikowania istniejącego kodu? Od jego "jakości"? Wrócimy jeszcze do tej kwestii.

Możesz użyć znanych Ci frameworków do testowania ale na tym etapie kursu bardziej pouczające będzie jeśli zabierzesz się za testowanie samodzielnie, od zera.