Pytest - Pierwsze kroki

Pytest to nowoczesny framework do uruchamiania testów automatycznych w języku Python. Może być stosowany do testów jednostkowych, ale sprawdza się bardzo dobrze również przy tworzeniu rozbudowanych testów wyższego poziomu (integracyjnych, end-to-end) dla całych aplikacji czy bibliotek.

Jego przemyślana konstrukcja ułatwia uzyskanie pożądanych efektów w sytuacjach o wysokim poziomie skomplikowania, a mnogość dostępnych rozszerzeń oraz łatwość tworzenia własnych bibliotek pozwala zastosować go do testowania produktów działających w praktycznie dowolnej technologii. Nie trzeba dodawać, że Pytest radzi sobie bardzo dobrze również z testowaniem aplikacji webowych i usług sieciowych, a to dzięki wykorzystaniu bibliotek takich jak Selenium i requests.

Dokumentacja Pytest

Obszerna dokumentacja Pytest, zawierająca liczne i bardzo dobrze opisane przykłady, jest dostępna na oficjalnej stronie projektu Pytest. Tworząc swój projekt automatyzacji oparty o ten framework, warto tę dokumentację przeczytać w całości (lub przynajmniej przejrzeć), żeby nabrać wyczucia odnośnie licznych funkcjonalności, oferujących gotowe rozwiązania dla najczęściej występujących problemów.

Przygotowanie środowiska

Do pracy z projektem opartym o Pytest konieczne jest środowisko z zainstalowanym interpreterem języka Python, przy czym zdecydowanie zalecam Python 3 i na tej wersji będę bazował wszystkie przykłady. Aby sprawdzić, czy masz zainstalowany interpreter i czy jest on gotowy do użycia, z wiersza poleceń możesz wykonać komendę:

python --version

…co powinno wypisać na ekran numer zainstalowanej wersji interpretera (powinien zaczynać się od 3), na przykład:

Python 3.6.4

Oprócz tego przyda się zintegrowane środowisko programistyczne (Pycharm Community lub Professional) oraz opcjonalnie system kontroli wersji Git.

Tip: Szczegółowe kroki instalacji Git w systemie Windows zostały opisane w artykule Narzędzia - system kontroli wersji Git i emulator konsoli Cmder.

Przykładowy projekt

W dalszej części artykułu omówię po kolei kroki tworzenia prostego przykładowego projektu, ale już teraz możesz pobrać i sprawdzić jak działa jego końcowa wersja. Jeśli masz zainstalowany system kontroli wersji Git, wystarczy że sklonujesz repozytorium:

git clone https://gitlab.com/qalabs/blog/pytest-parametrize-example.git

Tip: W repozytorium jest kilka commitów zawierających kolejne wersje plików opisanych w artykule. Jeśli chcesz przejść do konkretnego kroku i sprawdzić, jak działa skrypt na tym etapie, wystarczy jeśli zrobisz checkout odpowiedniego commita.

Jeśli nie masz Gita, możesz pobrać zip ze strony projektu https://gitlab.com/qalabs/blog/pytest-parametrize-example i rozpakować w dowolnym katalogu.

Tip: Chcesz szybko i skutecznie wystartować z Git? Proponujemy tekst, który przedstawia najbardziej istotne komendy na bazie prostego przykładowego projektu: https://blog.qalabs.pl/narzedzia/git-pierwsze-kroki/

Po sklonowaniu lub rozpakowaniu plików projektu musisz utworzyć i skonfigurować środowisko wirtualne virtualenv. Aby to zrobić, najpierw z wiersza poleceń w katalogu projektu wykonaj komendę:

python -m venv venv

Następnie, aby aktywować środowisko wirtualne, wykonaj komendę:

(Windows) venv\Scripts\activate.bat
(Linux, MacOS X) source bin/activate

Zainstaluj zależności zdefiniowane w pliku requirements.txt przy pomocy komendy:

pip install -r requirements.txt

Jeśli wszystko przebiegło pomyślnie, możesz uruchomić testy wpisując:

python -m pytest

Testowana funkcjonalność

Celem projektu, którym będę posługiwał się na potrzeby tego artykułu, jest automatyczne zweryfikowanie pliku eksportu w formacie CSV (ang. comma-separated values), wygenerowanego przez testowany system. Plik zawiera dane zapisane w formacie tekstowym, gdzie pierwszy wiersz stanowi nagłówek, pozostałe wiersze zawierają dane, a wartości są od siebie oddzielonyme przecinkami. Do zweryfikowania mamy następujący plik:

book.csv

1
2
3
4
ID,AUTHOR,TITLE,PAGES,CREATED,UPDATED
101,David Kahn,The Codebreakers,1200,2018-03-15,2018-03-17
102,Donald Knuth,The Art of Computer Programming,3168,2018-03-15,2018-03-15
103,Matt Weisfeld,The Object-Oriented Thought Process,336,2018-03-15,2018-03-18

Wykonamy dla niego testy, które sprawdzą:

  1. Czy nazwy kolumn w nagłówku są zapisane wielkimi literami.
  2. Czy pierwsza kolumna w nagłówku to ID.
  3. Czy nagłówek zawiera kolumnę CREATED
  4. Czy nagłówek zawiera kolumnę UPDATED
  5. Czy każdy rekord zawiera dane dla wszystkich kolumn z nagłówka.
  6. Czy pierwsza wartość każdego rekordu to liczba.

Pierwsze testy

Zaczniemy od utworzenia skryptu z sześcioma testami, które póki co nie będą niczego sprawdzać. Weryfikacja warunku testowego polega na użyciu słowa kluczowego assert oraz wartości do sprawdzenia. Jeśli stanowi ona logiczną prawdę, to wynik testu jest poprawny, jeśli logiczny fałsz, to wynik jest błędny. Nasze testy póki co zawsze przechodzą, ponieważ sprawdzają assert True. Posłużą nam jako formatka do uzupełnienia właściwym kodem.

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
import pytest


def test_header_is_uppercase():
"""Check if column names in header are uppercase"""
assert True


def test_header_starts_with_id():
"""Check if the first column in header is ID"""
assert True


def test_header_has_column_created():
"""Check if header has column CREATED"""
assert True


def test_header_has_column_updated():
"""Check if header has column UPDATED"""
assert True


def test_record_matches_header():
"""Check if number of columns in each record matches header"""
assert True


def test_record_first_field_is_number():
"""Check if the first value in each record is a number"""
assert True

W celu uruchomienia testów należy w katalogu projektu w wierszu poleceń wykonać komendę python -m pytest, co powinno dać wynik podobny do tego poniżej:

1
2
3
4
5
6
7
8
============================= test session starts =============================
platform win32 -- Python 3.6.0, pytest-3.4.2, py-1.5.2, pluggy-0.6.0
rootdir: pytest-parametrize-example, inifile:
collected 6 items

test_csv.py ...... [100%]

========================== 6 passed in 0.20 seconds ===========================

Przygotowanie danych z użyciem fixtures

Każdy z testów wymaga podania danych wejściowych, które będzie mógł poddać analizie i weryfikacji. W powyższym przykładzie testy muszą mieć oczywiście dostęp do zawartości pliku CSV.

W Pytest za przygotowanie danych dla funkcji testowych odpowiadają tak zwane fixtures, czyli specjalnie oznaczone funkcje, które są wykonywane automatycznie przed wykonaniem testów, które od nich zależą. Jeśli test potrzebuje danych z określonej fixture, można to wskazać przez umieszczenie nazwy tej fixture wśród parametrów w sygnaturze funkcji testowej. Poniższy przykład zawiera fixture o nazwie csv_data, która wczytuje z dysku zawartość pliku book.csv:

1
2
3
4
5
6
7
8
import pytest


@pytest.fixture()
def csv_data():
with open('book.csv') as f:
data = f.read().split('\n')
return data

Fixture otwiera plik book.csv, odczytuje jego zawartość, dzieli ją względem znaków końca linii, zapisuje tak utworzoną listę do zmiennej data i na koniec ją zwraca.

Teraz musimy dodać csv_data do parametrów funkcji testowych, aby mogły skorzystać z tak przygotowanych danych. W poniższym kodzie znajduje się już również logika odpowiedzialna za poszczególne sprawdzenia:

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
def test_header_is_uppercase(csv_data):
"""Check if column names in header are uppercase"""
header = csv_data[0]
assert header == header.upper()


def test_header_starts_with_id(csv_data):
"""Check if the first column in header is ID"""
header = csv_data[0]
column_names = header.split(',')
first_column_name = column_names[0]
assert first_column_name == 'ID'


def test_header_has_column_created(csv_data):
"""Check if header has column CREATED"""
header = csv_data[0]
column_names = header.split(',')
assert 'CREATED' in column_names


def test_header_has_column_updated(csv_data):
"""Check if header has column UPDATED"""
header = csv_data[0]
column_names = header.split(',')
assert 'UPDATED' in column_names


def test_record_matches_header(csv_data):
"""Check if number of columns in each record matches header"""
header = csv_data[0]
column_names = header.split(',')
header_columns_count = len(column_names)
errors = []
for record in csv_data[1:]:
record_values = record.split(',')
record_values_count = len(record_values)
if record_values_count != header_columns_count:
errors.append(record)
assert not errors


def test_record_first_field_is_number(csv_data):
"""Check if the first value in each record is a number"""
errors = []
for record in csv_data[1:]:
record_values = record.split(',')
if not record_values[0].isdigit():
errors.append(record)
assert not errors

Niektóre przedstawione wyżej operacje mogą wymagać wyjaśnienia. Przypisanie zmiennej header zawartości wiersza nagłówkowego polega na odczytaniu elementu o indeksie 0 z danych przekazanych przez fixture csv_data. Zapisanie nazw kolumn na liście column_names odbywa się przez podzielenie wiersza nagłówkowego po przecinkach przy pomocy metody split. Przypisanie zmiennej first_column_name nazwy pierwszej kolumny polega na odczytaniu elementu o indeksie 0 z listy nazw kolumn.

Liczbę kolumn nagłówka w zmiennej header_columns_count można określić jako długość listy z nazwami kolumn. W dwóch ostatnich testach inicjalizowana jest pusta lista errors, która ma za zadanie przechować błędy zarejestrowane podczas analizy poszczególnych wierszy. Następnie pętle for odczytują rekordy jeden po drugim, zaczynając od linii o indeksie 1 z csv_data, a następnie dokonują właściwych sprawdzeń: porównania liczby wartości z liczbą kolumn nagłówka lub weryfikacji czy pierwsza wartość rekordu jest liczbą. Wynik testu jest pozytywny, jeśli lista błędów jest pusta.

Modułowa budowa fixtures

W powyższym skrypcie jest dużo zduplikowanego kodu: kilka razy jest odczytywany nagłówek, nazwy kolumn, rekordy itd. Tego typu powtórzeń należy unikać. To zła praktyka programistyczna, która może powodować problemy w dalszych pracach nad projektem. Na szczęście Pytest wspiera modułową budowę fixtures, co pozwala na wygodne podzielenie kodu pomiędzy kilka funkcji i automatyczne wykonanie ich kiedy zajdzie taka potrzeba.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import pytest


@pytest.fixture()
def csv_data():
with open('book.csv') as f:
data = f.read().split('\n')
return data


@pytest.fixture()
def csv_header(csv_data):
return csv_data[0]


@pytest.fixture()
def csv_records(csv_data):
return csv_data[1:]


@pytest.fixture()
def column_names(csv_header):
return csv_header.split(',')

Teraz każda funkcja testowa może użyć dokładnie tych danych, których potrzebuje, przygotowanych przez dedykowaną fixture.

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
33
34
35
36
37
38
39
40
41
def test_header_is_uppercase(csv_header):
"""Check if column names in header are uppercase"""
assert csv_header == csv_header.upper()


def test_header_starts_with_id(column_names):
"""Check if the first column in header is ID"""
first_column_name = column_names[0]
assert first_column_name == 'ID'


def test_header_has_column_created(column_names):
"""Check if header has column CREATED"""
assert 'CREATED' in column_names


def test_header_has_column_updated(column_names):
"""Check if header has column UPDATED"""
assert 'UPDATED' in column_names


def test_record_matches_header(column_names, csv_records):
"""Check if number of columns in each record matches header"""
header_columns_count = len(column_names)
errors = []
for record in csv_records:
record_values = record.split(',')
record_values_count = len(record_values)
if record_values_count != header_columns_count:
errors.append(record)
assert not errors


def test_record_first_field_is_number(csv_records):
"""Check if the first value in each record is a number"""
errors = []
for record in csv_records:
record_values = record.split(',')
if not record_values[0].isdigit():
errors.append(record)
assert not errors

W powyższym skrypcie zdefiniowane są cztery fixture oraz zależności pomiędzy nimi. W celu omówienia ich modułowej budowy i automatycznego wykonania posłużę się przykładem funkcji testowej test_header_starts_with_id. Zależy ona od fixture column_names i Pytest wykona ją automatycznie przed wykonaniem testu. Ale nietrudno zauważyć, że fixture column_names zależy od csv_header, która to zależy od csv_data, więc przed wykonaniem testu zostaną wykonane wszystkie te funkcje w odpowiedniej kolejności. Siła Pytest polega na tym, że zrobi to automatycznie, na podstawie tych prosto zdefiniowanych zależności.

Statyczna parametryzacja fixtures

Automatyzacja testów jest najbardziej efektywna, kiedy raz napisany kod możemy użyć do wykonania wielu sprawdzeń. Ułatwia to wspierana przez Pytest parametryzacja fixtures.

Załóżmy, że funkcjonalność eksportu do pliku CSV w testowanym przez nas systemie została wzbogacona o wsparcie dla nowych kategorii danych. Teraz musimy wykonać te same testy dodatkowo dla poniższych plików:

address.csv

1
2
3
4
ID,STREET,CITY,COUNTRY,CREATED,UPDATED
301,Park Avenue,New York,USA,2018-03-15,2018-03-16
302,Main Street,Boston,USA,2018-03-15,2018-03-17
303,Elm Parkway,Rogers,USA,2018-03-15,2018-03-17

customer.csv

1
2
3
4
5
ID,NAME,E-MAIL,PHONE,CREATED,UPDATED
201,John Doe,[email protected],123456789,2018-03-15,2018-03-15
202,Mary Smith,[email protected],987654321,2018-03-15,2018-03-15
203,Linda Williams,[email protected],NULL,2018-03-15,2018-03-15
204,Robert Brown,[email protected],333666999,2018-03-15,2018-03-15

Na szczęście wykorzystując funkcjonalności Pytest możemy to zrobić z niewielkimi zmianami w do tej pory napisanym skrypcie i bez niepotrzebnego duplikowania kodu. Wystarczy nieco zmienić fixture csv_data jak poniżej:

1
2
3
4
5
@pytest.fixture(params=['address.csv', 'book.csv', 'customer.csv'])
def csv_data(request):
with open(request.param) as f:
data = f.read().split('\n')
return data

Do dekoratora pytest.fixture został dodany parametr params z listą nazw plików, które mają zostać użyte w testach. Pytest automatycznie wykona fixture tyle razy, ile wartości parametru zostało podanych. W ciele funkcji csv_data dostęp do wartości parametru, czyli w tym przypadku nazwy pliku CSV, zapewnia obiekt request poprzez atrubut param. Został on podany jako argument bezpośrednio do funkcji open, odpowiedzialnej za otwarcie pliku z danymi.

Najważniejsze jednak jest to, że Pytest automatycznie identyfikuje wszystkie testy, które są bezpośrednio lub pośrednio zależne od takiej sparametryzowanej fixture i te testy również wykona dla każdej wartości parametru. Dlatego w raporcie z wykonania rzeczywiście widać po sześć testów wykonanych dla każdego z trzech plików (tym razem raport rozszerzony verbose, uzyskany przy użyciu komendy python -m pytest -v):

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
============================= test session starts =============================
platform win32 -- Python 3.6.0, pytest-3.4.2, py-1.5.2, pluggy-0.6.0
rootdir: pytest-parametrize-example, inifile:
collected 18 items

test_csv.py::test_header_is_uppercase[address.csv] PASSED [ 5%]
test_csv.py::test_header_is_uppercase[book.csv] PASSED [ 11%]
test_csv.py::test_header_is_uppercase[customer.csv] PASSED [ 16%]
test_csv.py::test_header_starts_with_id[address.csv] PASSED [ 22%]
test_csv.py::test_header_starts_with_id[book.csv] PASSED [ 27%]
test_csv.py::test_header_starts_with_id[customer.csv] PASSED [ 33%]
test_csv.py::test_header_has_column_created[address.csv] PASSED [ 38%]
test_csv.py::test_header_has_column_created[book.csv] PASSED [ 44%]
test_csv.py::test_header_has_column_created[customer.csv] PASSED [ 50%]
test_csv.py::test_header_has_column_updated[address.csv] PASSED [ 55%]
test_csv.py::test_header_has_column_updated[book.csv] PASSED [ 61%]
test_csv.py::test_header_has_column_updated[customer.csv] PASSED [ 66%]
test_csv.py::test_record_matches_header[address.csv] PASSED [ 72%]
test_csv.py::test_record_matches_header[book.csv] PASSED [ 77%]
test_csv.py::test_record_matches_header[customer.csv] PASSED [ 83%]
test_csv.py::test_record_first_field_is_number[address.csv] PASSED [ 88%]
test_csv.py::test_record_first_field_is_number[book.csv] PASSED [ 94%]
test_csv.py::test_record_first_field_is_number[customer.csv] PASSED [100%]

========================== 18 passed in 0.36 seconds ==========================

Statyczna parametryzacja funkcji testowych

Pytest umożliwia również parametryzację funkcji testowych, co możemy wykorzystać do dalszej optymalizacji kodu i pozbycia się niepotrzebnie zduplikowanych fragmentów. Można zauważyć, że dwie funkcje testowe - test_header_has_colum_created oraz test_header_has_column_updated - realizują właściwie taką samą funkcjonalność, różniącą się jedynie wartością sprawdzanej nazwy kolumny. Można zastąpić te dwie funkcje jedną i przy pomocy odpowiedniego dekoratora spowodować, żeby Pytest sam cudownie je rozmnożył:

1
2
3
4
@pytest.mark.parametrize('checked_name', ['CREATED', 'UPDATED'])
def test_header_has_column(column_names, checked_name):
"""Check if header has column CREATED"""
assert checked_name in column_names

Nie wpłynęło to na liczbę i charakter wykonanych testów, co można zauważyć w szczegółowym wyniku:

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
============================= test session starts =============================
platform win32 -- Python 3.6.0, pytest-3.4.2, py-1.5.2, pluggy-0.6.0
rootdir: pytest-parametrize-example, inifile:
collected 18 items

test_csv.py::test_header_is_uppercase[address.csv] PASSED [ 5%]
test_csv.py::test_header_is_uppercase[book.csv] PASSED [ 11%]
test_csv.py::test_header_is_uppercase[customer.csv] PASSED [ 16%]
test_csv.py::test_header_starts_with_id[address.csv] PASSED [ 22%]
test_csv.py::test_header_starts_with_id[book.csv] PASSED [ 27%]
test_csv.py::test_header_starts_with_id[customer.csv] PASSED [ 33%]
test_csv.py::test_header_has_column[address.csv-CREATED] PASSED [ 38%]
test_csv.py::test_header_has_column[address.csv-UPDATED] PASSED [ 44%]
test_csv.py::test_header_has_column[book.csv-CREATED] PASSED [ 50%]
test_csv.py::test_header_has_column[book.csv-UPDATED] PASSED [ 55%]
test_csv.py::test_header_has_column[customer.csv-CREATED] PASSED [ 61%]
test_csv.py::test_header_has_column[customer.csv-UPDATED] PASSED [ 66%]
test_csv.py::test_record_matches_header[address.csv] PASSED [ 72%]
test_csv.py::test_record_matches_header[book.csv] PASSED [ 77%]
test_csv.py::test_record_matches_header[customer.csv] PASSED [ 83%]
test_csv.py::test_record_first_field_is_number[address.csv] PASSED [ 88%]
test_csv.py::test_record_first_field_is_number[book.csv] PASSED [ 94%]
test_csv.py::test_record_first_field_is_number[customer.csv] PASSED [100%]

========================== 18 passed in 0.39 seconds ==========================

Dynamiczna parametryzacja funkcji

Wpisanie tego typu danych na sztywno w kod testowy nie zawsze jest dobrym pomysłem. Na przykład jeśli w przyszłości zostaną dodane kolejne pliki eksportu do CSV, będziemy musieli uzupełnić listę parametrów o kolejne nazwy. Można tego uniknąć i tutaj Pytest również przychodzi z pomocą, dając możliwość dynamicznego parametryzowania fixtures i funkcji testowych przy pomocy specjalnej funkcji pytest_generate_tests. Wykorzystamy ją do dynamicznego wygenerowania testów dla każdego pliku z rozszerzeniem CSV znajdującego się w katalogu projektu. Wystarczy zmienić początek skryptu jak poniżej:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import os
import pytest


def pytest_generate_tests(metafunc):
listing = os.listdir()
csv_files = [item for item in listing if item.endswith('.csv')]
if 'csv_file' in metafunc.fixturenames:
metafunc.parametrize('csv_file', csv_files)


@pytest.fixture()
def csv_data(csv_file):
with open(csv_file) as f:
data = f.read().split('\n')
return data

Na początku importujemy moduł os, który umożliwia wykonywanie działań w systemie opreracyjnym. Funkcja pytest_generate_tests wykorzystuje jego funkcję listdir do pobrania nazw plików znajdujących się w katalogu projektu, a następnie zapisuje pliki, których nazwa kończy się na .csv, na liście csv_files. W kolejnym kroku dynamicznie przypsiuje wszystkim fixtures i funkcjom testowym zależnym od csv_file listę parametrów w postaci listy nazw plików. Żeby parametryzacja mogła zadziałać, fixture csv_data ma teraz zdefiniowaną zależność od parametru csv_file. Wynik wykonania testów jest identyczny jak poprzednio, ale jeśli pojawią się kolejne pliki, nie trzeba będzie aktualizować listy z nazwami.

Podsumowanie

Przestawione powyżej przykłady pokazują oczywiście tylko ułamek możliwości frameworku Pytest. Kwestie takie jak wykorzystanie konfiguracji conftest.py, grupowanie testów w klasach, manipulowanie zakresem fixture, optymalizacja ścieżki wykonania, zaawansowane wykorzystanie asercji, pomijanie testów, dynamiczne modyfikowanie listy testów czy specyficzne podejście do teardown to tylko kilka spośród innych ważnych funkcjonalności.

Maciej Chmielarz

Nazywam się Maciej Chmielarz. Pracuję w branży informatycznej od 2008 roku. Doświadczenie zdobywałem u pracodawców należących do polskiej i światowej czołówki firm technologicznych, producentów unikatowych rozwiązań inżynierskich. Chętnie dzielę się wiedzą, prowadzę wykłady z testowania oprogramowania, występuję na konferencjach i spotkaniach branżowych oraz aktywnie uczestniczę w ich organizacji. Jestem pasjonatem cyberbezpieczeństwa i budowania świadomości informatycznej w społeczeństwie.