Dlaczego akurat te dwa frameworki? Unittest i Pytest to najczęściej wykorzystywane frameworki do automatyzacji testów napisane w języku Python.
Unittest jest wzorowany na frameworku JUnit (napisanym w Javie) i jest zawarty w bibliotece standardowej Pythona, co czyni go łatwo dostępnym oraz pewnym rozwiązaniem. Natomiast Pytest jest obecnie jednym z najbardziej popularnych open - sourcowych frameworków na rynku. Obydwa dostarczają kompletne rozwiązania w kontekście testowania oprogramowania, jak.: runnery testów, klasy reprezentujące zbiory testów, rozbudowane konfiguratory, możliwość parametryzacji testów.
Czym się zatem różnią? Jaki framework najlepiej wybrać do testów automatycznych dla złożonego, komercyjnego projektu? Który z nich będzie lepszym wyborem dla małego, prywatnego przedsięwzięcia? Postaram się odpowiedzieć na powyższe pytania poprzez porównanie wspomnianych frameworków z uwagi na następujące cechy:
Przejdźmy zatem do porównania.
Unittest wymusza stałą strukturę testów. Zbiory testów, czyli zbiory zawierające testy i mające na celu dogłębne przetestowanie produktu - reprezentowane są poprzez klasy dziedziczące po klasie unittest.TestCase
. Pojedyncze testy reprezentowane są poprzez metody klas będących zbiorami testów. Klasa unittest.TestCase
dostarcza między innymi metody setUp
, tearDown
(przygotowujące środowisko przed i po teście) oraz metody setUpClass
, tearDownClass
(przygotowujące środowisko przed i po zbiorze testów).
Ponadto, jeśli użytkownik zaimplementuje funkcje setUpModule
i tearDownModule
, to zostaną one uruchomione kolejno po załadowaniu modułu z testami oraz przed jego zakończeniem.
import unittest
class ExampleTestSuite(unittest.TestCase):
def setUp(self): # Prepare test environment
pass
def test_addition(self):
self.assertEqual(1 + 2, 3)
def tearDown(self): # Cleanup after test
pass
Unittest posiada funkcję autowykrywania testów w plikach, których nazwa zaczyna się od prefixu test. Dzięki tej funkcji użytkownik nie musi ręcznie podawać ścieżek do plików tekstowych. Zbiory testów mogą mieć dowolną nazwę, jeśli dziedziczą po klasie unittest.TestCase
. Aby metody zostały rozpoznane jako testy, muszą zaczynać się prefiksem test, np. metoda o nazwie check_compatibility
nie zostanie rozpoznana jako test, ale metoda test_compatibility
już tak.
Wynik pojedynczego testu determinowany jest przez sprawdzenie, czy podczas jego wykonania pojawił się nieobsłużony wyjątek:
AssertionError
, Unittest ustawia wynik testu na Fail
unittest.SkipTest
, Unittest ustawia wynik testu na Skipped
AssertionError
ani unittest.SkipTest
, Unittest ustawia wynik testu na Error
Unittest dostarcza razem z klasą unittest.TestCase
zbiór metod z prefixem assert, które służą do porównywania wyników (np.: assertTrue
, assertEqual
, assertRaises
):
def test_addition(self):
self.assertEqual(1 + 2, 3) # Will pass
self.assertEqual(1 + 2, 4) # Will fail
def test_addition_2(self):
raise RuntimeError("Something happened") # Will raise error
Pytest daje użytkownikowi więcej swobody. Testy reprezentowane są przez funkcje lub metody - w zależności od implementacji. Framework ten dzieli testy na “zakresy” (ang. scopes): sesja testowa, moduł testowy, klasa testowa, test. Sesja testowa jest, innymi słowy, zbiorem wszystkich modułów, które zostały rozpoznane jako moduły testowe. Moduł zaś to plik napisany w języku Python. Pytest nie wymusza definiowania zbiorów testów w formie klas, ale daje użytkownikowi taką możliwość:
import pytest
def test_addition():
assert 1 + 2 == 3
class ExampleTestSuite:
def test_addition(self):
assert 1 + 2 == 3
Pytest dostarcza także funkcjonalność nazwaną fiksturą. Jest to funkcja, której celem jest przygotowanie środowiska testowego dla danego testu, zbioru testów, modułu testowego, czy całej sesji i/lub posprzątanie po ich zakończeniu. Dodatkowo obiekty zwracane przez fikstury mogą być wstrzykiwane do wybranych testów. Dzięki temu rozwiązaniu fikstury mogą także przygotowywać testowane obiekty, np.:
@pytest.fixture(scope="function")
def connector():
conn = Connector("login", "passwd")
yield conn
del conn
def test_connection(connector):
assert connector.send_msg("Hello")
W tym przykładzie fikstura connector tworzy obiekt klasy Connector
, zatrzymuje swoje działanie i uruchamia test (poprzez wykorzystanie słowa yield
). Referencja do obiektu zostaje wstrzyknięta do testu poprzez argument o tej samej nazwie, co fikstura. Po wykonaniu testu sterowanie wraca do fikstury, która usuwa obiekt klasy Connector
. Dodatkowo Pytest dostarcza gotowe fikstury, które ułatwiają zarządzanie testami.
Pytest rozpoznaje elementy kodu po prefiksie test. Jeśli dany plik, klasa, czy funkcja spełniają to założenie, to zostaną zebrane przez framework. Istnieje także możliwość zmiany warunków zbierania testów - więcej na ten temat w sekcji Konfiguracja.
Definiowanie i reprezentacja wyników przez Pytest jest bardzo zbliżona do rozwiązań z frameworku Unittest. Jedyne różnice jakie znajdziecie, to:
assert
do porównywania wynikówUnittest nie dostarcza gotowych rozwiązań w kontekście konfiguracji. Wymagane jest użycie innych rozwiązań z biblioteki standardowej Pythona (np. argparse) lub rozwiązań 3rd party (np. click).
Pytest akceptuje różne formaty plików konfiguracyjnych, m.in.: pytest.ini, pyproject.toml, tox.ini, setup.cfg, za pomocą których można zdefiniować np.: wersję testów, ścieżki do modułów testowych. Pytest dostarcza jeszcze jedną formę konfiguracji, a mianowicie plik conftest.py. Jeżeli plik ten istnieje w danym folderze zawierającym moduły testowe, jest on ładowany w trakcie zbierania testów.
Plik ten może modyfikować wiele funkcji: od dodawania dodatkowych flag poprzez zmianę sposobu zbierania testów (np. poprzez filtrowanie nazw za pomocą regexów) aż po modyfikację konkretnych etapów wykonywania procedury testowej. Możliwości wykorzystania pliku conftest.py jest za wiele, aby opisywać je tutaj - uważam, że zasługuje on na własny artykuł.
Unittest nie dostarcza natywnej możliwości parametryzacji testów. Wymagane jest użycie innych rozwiązań 3rd party (np. parameterized).
Pytest dostarcza moduł mark
pozwalający na oznaczanie testów. Jednym z tych oznaczeń jest dekorator parametrize
, którego celem jest wygenerowanie określonej ilości testów na podstawie parametrów podanych przez użytkownika, np.:
@pytest.mark.parametrize("a,b", [(1, 2), (3, 4)])
def test_addition(a, b):
print(a + b)
W ten sposób pytest wygeneruje dwa testy, gdzie argumenty a i b będą parami liczb (1, 2), (3, 4).
$ python -m pytest -vvv
...
collected 2 items
test_param.py::test_addition[1-2] PASSED [ 50%]
test_param.py::test_addition[3-4] PASSED [100%]
Dekorator ten może także generować testy, tworząc kombinacje parametrów.
@pytest.mark.parametrize("a", [1, 2])
@pytest.mark.parametrize("b", [3, 4])
def test_addition(a, b):
print(a + b)
W tym wypadku wygenerowane zostaną cztery testy, gdzie argumenty a i b będą parami liczb (3, 1), (3, 2), (4, 1), (4, 2).
$ python -m pytest -vvv
...
collected 4 items
test_param.py::test_addition[3-1] PASSED [ 25%]
test_param.py::test_addition[3-2] PASSED [ 50%]
test_param.py::test_addition[4-1] PASSED [ 75%]
test_param.py::test_addition[4-2] PASSED [100%]
Dostarcza cztery dekoratory używane do pomijania testów: skip
, skipIf
, skipUnless
, expectedFailure
oraz jedną metodę: TestCase.skipTest
. Dekoratory są wykorzystywane do kompletnego pominięcia pojedynczego testu lub klasy reprezentującej zbiór testów. Jeśli wspomniana metoda zostanie wywołana podczas wykonywania testu, to zostanie on przerwany i oznaczony jako pominięty.
@unittest.skipIf(condition=sys.platform("win32", reason="Can't run on Windows"))
def test_addition_2(self):
self.assertEqual(1 + 2, 4)
@unittest.expectedFailure
def test_addition_3(self):
raise RuntimeError("ERROR HAPPENED")
Dostarcza trzy dekoratory do pomijania testów: mark.skip
, mark.skipif
, mark.xfail
oraz trzy funkcje: importskip
, skip
, xfail
. Dekoratory są wykorzystywane do kompletnego pominięcia pojedynczego testu lub klasy reprezentującej zbiór testów. Jeśli funkcja skip zostanie użyta podczas wykonywania testu, zostanie on przerwany. Funkcje skip
oraz importskip
mogą zostać użyte do pominięcia całego modułu testowego. Dekorator mark.xfail
i funkcja xfail
oznaczają test jako test, który powinien zakończyć się niepowodzeniem.
os = pytest.importorskip(modname="os")
@pytest.mark.skipif(condition=sys.platform("win32"), reason="Can't run on Windows")
def test_addition():
self.assertEqual(1 + 2, 4)
@pytest.mark.xfail
def test_addition_3():
raise RuntimeError("ERROR HAPPENED")
Pytest dostarcza także możliwość dodawania własnych oznaczeń oraz możliwość zarządzania nimi na różnych etapach pracy frameworku poprzez plik conftest.py.
W tej części chciałbym przeanalizować dane związane z popularnością porównywanych frameworków wśród społeczności testerów, np.: liczba zapytań na StackOverflow, ilość zgłoszonych problemów i forków na GitHubie.
stackoverflow:
github (Niestety framework Unittest nie ma indywidualnego repozytorium, jest zawarty bezpośrednio w repozytorium Pythona):
stackoverflow:
github:
Dodatkowo, warto nadmienić fakt, iż Pytest wspiera pluginy, które tworzone są w większości przez społeczność. Pluginy te pozwalają na poszerzenie funkcjonalności, np.: plugin pytest-xdist pozwala na uruchamianie testów w równoległych procesach, plugin pytest-aws pozwala na testowanie rozwiązań związanych z Amazon Web Services. Na oficjalnej stronie frameworku Pytest została zamieszczona lista dostępnych pluginów, według której jest ich aktualnie 868.
Na podstawie przedstawionych danych ze stackoverflow można wywnioskować, iż obydwa frameworki są porównywalne pod względem wsparcia społeczności, dane z github nie są jednak w pełni obiektywne, ponieważ Unittest jest zawarty w bibliotece standardowej Pythona.
Obydwa frameworki są dojrzałymi i docenianymi narzędziami aktualnie dostępnymi na rynku. Część funkcji jest bardzo podobna, co sprawia, że migracja między nimi nie wymaga dużego nakładu pracy ze strony użytkownika. Jednakże mogę z czystym sumieniem stwierdzić, że Pytest jest bardziej kompletnym frameworkiem z lepszym wsparciem autorów oraz większą społecznością rozwijającą to narzędzie (poprzez pluginy, czy zgłaszanie błędów). Z drugiej strony, w porównaniu do frameworku Unittest, wymaga on od użytkownika lepszej znajomości Pythona, co czyni go trudniejszym do opanowania przez osoby początkujące.
Podsumowując, gdybym miał ocenić, który z nich jest lepszy - odpowiedziałbym, że wybór zależy od tego, jaki problem chcemy rozwiązać. Jeśli jesteś początkującym programistą, zacznij od frameworku Unittest. Jeżeli znasz język Python i potrzebujesz dojrzałego, skalowalnego rozwiązania, z którym będziesz pracował wraz z zespołem - wybierz Pytest.
W naszej firmie korzystamy z obu frameworków, jednakże do tworzenia nowych testów używamy obecnie wyłącznie Pytest. Ze względu na dużą ilość platform, na których testujemy nasze produkty najważniejszymi atutami były: parametryzacja testów, wsparcie konfiguracji środowiska testowego oraz zarządzanie testami.
Unittest wymagał od deweloperów i QA-ów większej ilości czasu poświęconego na utrzymanie i rozwój testów automatycznych ze względu na potrzebę implementacji wyżej wymienionych rozwiązań, bądź używanie dodatkowych bibliotek. Oczywiście pluginy wspomniane w tym artykule nie są tworzone przez autorów frameworku Pytest, ale ze względu na jego architekturę, ich działanie jest bardziej przewidywalne.
Mateusz Rechnio pracuje jako QA Engineer w Vewd. Od ponad 3 lat będąc w branży IT stoi na straży jakości i bezawaryjności wytwarzanego oprogramowania. We wszystkich działaniach kieruje się zasadą logicznego myślenia, które jak twierdzi: "jest kluczem do sprawnego rozwiązywania problemów i tworzenia dobrze działającego software'u". W Vewd najbardziej ceni sobie pracę z doświadczonymi ludźmi w świetnej (“przekozak”) atmosferze.