Testy jednostkowe¶
Idea testowania
Wiele osób zapewne zastanawia po co w ogóle powinniśmy testować (niemanualnie) kod. Wyobraźmy sobie zatem scenariusz w którym edytujemy kod naszego kolegi z zespołu po czym musimy jednoznacznie określić czy nowy kod spełnia wymagania które miał spełniać stary. W takim przypadku jeżeli przy każdej edycji kodu dodajemy również testy jednostkowe nie musimy się tym martwić - jeżeli natomiast tego nie robimy to jedyne co nam pozostaje to manualnie testować wszystkie poprzednie wymagania (co z czasem robi się co raz bardziej czasochłonne ze względu na stale dochodzące funkcjonalności). Oczywiście testowanie oprogramowania nie daje nam stuprocentowej pewności braku błędu, ale pomaga w ich identyfikacji.
Jak napisać dobry test
Dobre testy jednostkowe powinny być odseparowane od źródeł zewnętrznych (komunikacji z internetem, obecnego czasu itp.) oraz pokrywać jak najwięcej możliwych scenariuszy. Rozsądnym podejściem jest również napisanie testów funkcjonalności którą dopiero chcemy napisać - pomaga to w zrozumieniu kodu który musimy napisać i ułatwia jego testowanie w czasie pisania.
Testy jednostkowe w Pythonie
Przede wszystkim zanim zaczniemy pisanie testów musimy nauczyć się je uruchamiać. W tym celu utwórzmy sobie pusty plik tests.py
. Następnie aby uruchomić wszystkie testy znajdujące się w tym pliku wywołujemy komendę:
Note
Zauważmy brak rozszerzenia .py
w komendzie
python -m unittest tests
powinniśmy wtedy uzyskać taki rezultat:
----------------------------------------------------------------------
Ran 0 tests in 0.000s
OK
Kolejnym krokiem będzie napisanie samego testu. Napiszmy sobie zatem prostą funkcję, która będzie zwracać sumę dwóch liczb:
1 2 | def add(a, b):
return a + b
|
następnie dopiszmy do niej prosty test:
1 2 3 4 5 6 7 8 9 | import unittest
def add(a, b):
return a + b
class PyPilaTestCase(unittest.TestCase):
def test_add(self):
self.assertEqual(add(2, 3), 5)
|
Zwróćmy uwagę, że klasa zawierająca testy jednostkowe musi dziedziczyć z unittest.TestCase
oraz funkcje, które są testami, muszą zaczynać się na test
. W podanym przykładzie użyliśmy funkcji assertEqual
, która porównuje ze sobą 2 podane argumenty (add(2,3)
oraz 5
) i rzuca wyjątek jeżeli nie są one równe.
Jeżeli ponownie uruchomimy testy to powinniśmy otrzymać taki rezultat:
.
----------------------------------------------------------------------
Ran 1 test in 0.000s
OK
Kolejny przykład:
1 2 3 4 5 6 7 8 9 10 11 12 | import unittest
def is_positive(number):
return number > 0
class PyPilaTestCase(unittest.TestCase):
def test_is_positive_true(self):
self.assertTrue(is_positive(2))
def test_is_positive_false(self):
self.assertFalse(is_positive(-2))
|
Mamy w nim funkcję sprawdzającą, czy liczba jest dodatnia (większa od 0). Zauważymy jednak wykorzystane w testach funkcje porównujące assertTrue
oraz assertFalse
- są to skróty od funkcji assertEqual(..., True)
oraz assertEqual(..., False)
Wróćmy jeszcze na chwilę do pierwszej funkcji i przeanalizujmy taki przykład:
1 2 3 4 5 6 7 8 9 | import unittest
def add(a, b):
return a + b
class PyPilaTestCase(unittest.TestCase):
def test_add(self):
self.assertEqual(add(0.1, 0.2), 0.3)
|
Po uruchomieniu dostajemy:
F
======================================================================
FAIL: test_add (tests.PyPilaTestCase)
----------------------------------------------------------------------
Traceback (most recent call last):
File "tests.py", line 10, in test_add
self.assertEqual(0.1 + 0.2, 0.3)
AssertionError: 0.30000000000000004 != 0.3
----------------------------------------------------------------------
Ran 1 test in 0.000s
FAILED (failures=1)
Co na pierwszy rzut oka nie ma sensu (bo przecież 0.1 + 0.2 = 0.3), ale wynika to z ograniczeń arytmetyki liczb zmiennoprzecinkowych. W przypadku porównywaniu takich wartości powinniśmy wykorzystać metodę assertAlmostEqual
(standardowo porównuje liczby do 7 miejsc po przecinku):
1 2 3 4 5 6 7 8 9 | import unittest
def add(a, b):
return a + b
class PyPilaTestCase(unittest.TestCase):
def test_add(self):
self.assertAlmostEqual(add(0.1, 0.2), 0.3)
|
Czasami możemy mieć potrzebę wykonania jakiegoś kodu przed każdym testem tak aby go nie powtarzać, np:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | import unittest
class Person:
def __init__(self, name, age):
self.name = name
self.age = age
class PyPilaTestCase(unittest.TestCase):
def test_person_age(self):
artur = Person("Artur", 26)
self.assertEqual(artur.age, 26)
def test_person_name(self):
artur = Person("Artur", 26)
self.assertEqual(artur.name, "Artur")
|
Możemy uprościć nasz kod wykorzystując funkcję setUp
(która wykonuje się przed każdym testem):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | import unittest
class Person:
def __init__(self, name, age):
self.name = name
self.age = age
class PyPilaTestCase(unittest.TestCase):
def setUp(self):
self.artur = Person("Artur", 26)
def test_person_age(self):
self.assertEqual(self.artur.age, 26)
def test_person_name(self):
self.assertEqual(self.artur.name, "Artur")
|
Istnieje również odpowiednik, który wykonuje się po każdym teście tearDown
(w przypadku gdybyśmy mieli potrzebę wyczyszczenia jakiś danych)
A co jeżeli nasza funkcja korzysta z zewnętrznych API lub porównuje w jakiś sposób czas?
W takim przypadku jesteśmy zmuszeni do wykorzystania mocków - https://docs.python.org/3/library/unittest.mock.html z czym zdecydowanie warto się zapoznać, jednakże jest to nieco bardziej skomplikowany materiał, więc nie będzie realizowany.
Zadanie domowe
W ramach ćwiczeń spróbujemy napisać efektywne testy (czyli takie które pokryją jak najwięcej potencjalnych bugów) do takich funkcji (załóżmy, że w tej wersji są bezbłędne):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | def is_even(number):
return not number % 2
def square(number):
return number**2
def is_palindrom(word):
return word == word[::-1]
def is_prime(number):
for i in range(2, number):
if not number % i:
break
else:
return number > 1
return False
def add_dot(words_list=None):
if words_list is None:
words_list = ['example', 'sentence']
words_list += ['.']
return ' '.join(words_list)
def count_letters(words_list):
return {word: len(word) for word in words_list}
def multiply_by_2(number):
return number * 2
|
Po napisaniu testów podmieńmy nasze funkcje na zmodyfikowane przez “kolegę z zespołu”:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 | def is_even(number):
return not number & 1
def square(number):
return number * number
def is_palindrom(word):
if len(word) == 1:
return True
else:
if word[0] == word[-1]:
return is_palindrom(word[1:-1])
return False
def is_prime(number):
for i in range(3, number):
if not number % i:
return False
return True
def add_dot(words_list=['example', 'sentence']):
words_list += ['.']
return ' '.join(words_list)
def count_letters(words_list):
result = {}
for word in words_list:
result[word] = len(word)
return result
def multiply_by_2(number):
return number << 2
|
Jeżeli nasze testy były efektywne to powinny znaleźć błędy w 4-ch funkcjach :).