Automatyzacja testów w Pythonie
Zaktualizowaliśmy ten tekst dla Ciebie!
Data aktualizacji: 31.12.2024
Autor aktualizacji: Piotr Merynda
Kod Pythona, tak jak w każdym innym języku, wymaga testowania. Unittest jest dedykowanym do tego frameworkiem Pythona. Pod względem struktury i zachowania kodu wywodzi się z Junit. W tym artykule postaram się nieco naświetlić temat testowania w Pythonie i przedstawić kilka dobrych praktyk.
Struktura
Zanim zaczniemy, omówmy trochę prawidłową (lub raczej najbardziej preferowaną) strukturę projektu zawierającego testy. Aby mieć czysty i efektywny kod, główny folder testów powinien znajdować się na tym samym poziomie co główny folder kodu:
sample_
| ??? core.py
project:
??? jlabs
| ??? __init__.py
| ??? functions.py
??? tests
| ??? __init__.py
| ??? test_core.py
| ??? test_functions.py
??? README.rst
??? setup.py
Oczywiście folder test jest preferowany, gdy nasz zestaw testów jest większy niż jeden plik – w przeciwnym razie wystarczy prosty test.py. Z drugiej strony trzymanie dwucyfrowej liczby testów w jednym pliku również nie jest dobrym pomysłem, więc sugeruję utworzenie folderu testów i logiczne podzielenie testów na osobne pliki.
Pierwszy test
Jak wspomniałem na początku tego artykułu, unittest jest wbudowaną biblioteką Pythona, więc nie musimy podejmować żadnych działań przed rozpoczęciem tworzenia testu. Każda klasa testowa w unittest musi dziedziczyć z klasy unittest.TestCase. Każda metoda w tej klasie z nazwą zaczynającą się od 'test’ będzie traktowana jako metoda reprezentująca test – jest to konwencja nazewnictwa informująca runnera co ma wykonać. Napiszmy nasz pierwszy test z podstawową asercją:
import unittest
class TestFunctions(unittest.TestCase):
def test_basic_01(self):
self.assertEqual(20 + 20, 40)
self.assertEqual(20 + 20, 41, '20+20 is not 41')
W drugiej asercji dodałem niestandardowy komunikat, który pojawi się w przypadku niepowodzenia (w moim teście zawsze).
Ran 1 test in 0.003s
FAILED (failures=1)
20+20 is not 41
41 != 40
Expected :40
Actual :41
To są wszystkie podstawy wymagane do stworzenia testu dla kodu. Oczywiście jest to trywialny przykład – rzeczywiste scenariusze będą testować kod, który musi zostać zaimportowany do klasy testowej, a jego funkcjonalność zostanie sprawdzona:
import unittest
from jlabs.core import Person
class TestCore(unittest.TestCase):
def test_core_01(self):
me = Person('me')
self.assertTrue(me.is_handsome, 'I am not handsome')
Jak uruchomić?
Teraz wiemy jak napisać kod do testowania, a w tym rozdziale dowiemy się jak go wykonać. Oczywiście jest na to więcej niż jeden sposób. Pierwszym z nich jest uruchomienie go z wiersza poleceń za pomocą:
python -m unittest discover
Uruchomi on wszystkie pliki testów, które są modułami – w naszym przypadku TestFunctions i TestCore i wygeneruje raport z wykonania:
FF
======================================================================
FAIL: test_basic_01 (tests.test_basic.TestBasic)
----------------------------------------------------------------------
Traceback (most recent call last ):
File "C:\dev\sample_project\tests\test_functions.py", line 7, in test_basic_01
self.assertEqual(20 + 20, 41, '20+20 is not 41')
AssertionError: 20+20 is not 41
======================================================================
FAIL: test_core_01 (tests.test_core.TestCore)
----------------------------------------------------------------------
Traceback (most recent call last):
File "C:\dev\sample_project\tests\test_core.py", line 9, in test_core_01
self.assertTrue(me.is_handsome, 'I am not handsome')
AssertionError: I am not handsome
----------------------------------------------------------------------
Ran 2 tests in 0.001s
FAILED (failures=2)
Możemy zawęzić testy, które chcemy wykonać, dodając wzorzec do polecenia:
python -m unittest discover -p *_core.py
Co spowoduje wykonanie testów z modułów pasujących do podanego wzorca. Możemy również dodać opcję katalogu startowego (co ma sens gdy mamy podkatalogi w katalogu tests):
python -m unittest discover –s tests/subfolder
Innym sposobem na uruchomienie testów z lepszym poziomem kontroli jest stworzenie własnego skryptu (run_tests.py), w którym możemy dokładnie określić, co ma zostać wykonane:
from unittest import TestLoader, TestSuite, TextTestRunner
from tests.test_functions import TestBasic
modules_to_run = [TestBasic]
suites = list(TestLoader().loadTestsFromTestCase(t) for t in modules_to_run)
TextTestRunner().run(TestSuite(suites))
następnie możemy go wykonać po prostu przez:
python run_tests.py
Wygenerowany raport będzie podobny. Największą zaletą drugiej opcji, oprócz pełnej kontroli nad uruchamianymi testami, jest możliwość stworzenia niestandardowego programu uruchamiającego testy z różnymi danymi wyjściowymi. Jest to bardzo wygodne, gdy potrzebujemy raportu np. w formacie HTML lub innym pasującym do jakiegoś narzędzia ciągłej integracji (np. TeamCity).
Asercje
Wiemy już jak pisać testy i jak je wykonywać. Następnym krokiem będzie dowiedzenie się, co możemy zweryfikować w teście i w jaki sposób. Aby sprawdzić konkretną funkcjonalność, potrzebujemy asercji – wyrażenia otaczającego pewną testowalną logikę, co w zasadzie upraszcza się do sprawdzenia, czy dane wyjściowe są prawdziwe. Oczywiście istnieje możliwość użycia tylko jednego typu asercji w naszym kodzie testowym, ale nie jest to eleganckie i zalecane rozwiązanie. Unittest dostarcza nam wiele różnych typów asercji:
- porównujące – grupa asercji, które mogą oceniać 2 wartości względem siebie (czy są równe, czy jedna jest większa od drugiej itd.)
def test_basic_02(self):
self.assertEqual(20 + 20, 40)
self.assertGreater(20 + 20, 39)
self.assertLessEqual(20 + 20, 40)
self.assertTrue(20 + 20 == 40)
self.assertIsNotNone(20)
- iterujące – asercje operujące na sekwencjach
def test_basic_03(self):
self.assertEqual([1, 2, 3], [1, 2, 3])
self.assertIn(2, [1, 2, 3])
self.assertDictEqual({1: 'a', 2: 'b'}, {2: 'b', 1: 'a'})
self.assertDictContainsSubset({1: 'a'}, {1: 'a', 2: 'b'})
- oparte na wyjątkach – asercje zajmujące się wyjątkami
def test_basic_04(self):
with self.assertRaises(Exception):
raise Exception('ERROR')
with self.assertRaisesRegexp(Exception, 'ERR.+'):
raise Exception('ERROR')
- instancyjne – porównywanie obiektów i sprawdzanie, czy są instancjami klasy
def test_basic_05(self):
self.assertIsInstance('str', str)
self.assertIs(None, None)
- oparte na błędach – celowe niepowodzenie testu, gdy wystąpi określona sytuacja
def test_basic_06(self):
a = 5
if a < 6:
self.fail('a < 6')
Organizacja kodu
Kiedy mamy już wszystko pokryte pod względem funkcjonalności – wszystkie asercje są na swoim miejscu, możemy zacząć myśleć o przejrzystości i efektywności naszego kodu. Dobrą praktyką jest nie pisanie długich testów, a raczej krótkich obejmujących mały fragment logiki, ponieważ każde niepowodzenie asercji powoduje zatrzymanie testów i niewykonywanie ich dalej (więc wszystko po nieudanej asercji zostanie pominięte). Kiedy mamy taką szczegółowość, jest bardzo prawdopodobne, że możemy mieć duplikacje kodu i aby tego uniknąć, możemy użyć bloków przed i po przetwarzaniu zwanych setUp() i tearDown(). Metody te definiują działania, które będą podejmowane przed i po każdym teście. Jeśli setup się nie powiedzie, test nie zostanie wykonany i zakończy się niepowodzeniem. Jeśli setup się powiedzie, teardown zostanie wykonany bez względu na status testu. Również cały TestSuite (moduł ze zdefiniowanymi testami) może mieć zdefiniowane metody setUpClass() i tearDownClass() – będą one wykonywane przed pierwszym testem w TestSuite i po ostatnim i mogą być adnotowane jako @classmethod. Poniżej znajduje się prosty przykład użycia tych metod:
import unittest
from jlabs.core import PersonDbFactory
class TestCore(unittest.TestCase):
factory = None
@classmethod
def setUpClass(cls):
cls.factory = PersonDbFactory()
cls.factory.connect()
def setUp(self):
self.me = self.factory.get_person('me')
def tearDown(self):
self.me.destroy()
def test_core_handsome(self):
self.assertFalse(self.me.is_handsome, 'I am not handsome')
def test_core_smart(self):
self.assertTrue(self.me.is_smart, 'I am not smart')
Uruchomienie tego pakietu da nam wynik taki jak:
Ran 2 tests in 0.002s
OK
Connecting to DB
Gathering person from DB
Destroying person object
Gathering person from DB
Destroying person object
Disconnecting from DB
To pokazuje, że setup() i tearDown() są wykonywane przed każdym testem, podczas gdy setUpClass() i tearDownClass() tylko raz.
Istnieją również 3 przydatne adnotacje pozwalające nam kontrolować wykonywanie i pomijanie testów.
- @skip – po prostu pomijanie testu
- @skipIf – dodanie warunku, kiedy test nie powinien zostać wykonany
- @skipUnless – dodanie warunku, kiedy test nie powinien zostać pominięty.
Podsumowanie
Dotarliśmy do końca artykułu. Mam nadzieję, że po tym wykładzie będzie można z powodzeniem pisać i wykonywać testy swojego kodu. Należy pamiętać, że unittest jest bardzo uniwersalną biblioteką i nie jest dedykowana tylko do testów na poziomie jednostkowym. Udostępnia ona szablon testu, a to co jest przedmiotem testu zależy od nas.
Poznaj mageek of j‑labs i daj się zadziwić, jak może wyglądać praca z j‑People!
Skontaktuj się z nami


