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 :).