10. Testování (pytest) a type hinting
Instalování balíčků
Instalátor pip
Umožňuje instalování balíčku (lokálních nebo z repozitářů jako je PyPi). Základní popis funkcionality naleznete zde.
Instalace balíčku pytest
na UNIX systému (Linux, Mac OS a další):
1
$ python3 -m pip install pytest
Instalace balíčku pytest
na Windows systému:
1
$ py -m pip install pytest
Pro vývoj je podstatné instalovat balíčky v interaktivním režimu. Kód potom můžeme jednoduše testovat. Tečka reprezentuje aktuální adresář (je tedy nutné být v hlavním adresáři balíčku - tam kde je umístěn soubor setup.py
).
1
$ python3 -m pip install -e .
Windows:
1
$ py -m pip install -e .
Soubor setup.py
Centrální soubor pro instalaci balíčku, obsahuje rovněž všechna důležitá metadata. V praxi může být poměrně komplexní, detailní informace je možné nalézt zde.
Základní obsah souboru setup.py
může vypadat následovně:
1
2
3
4
5
6
7
8
9
10
11
12
#!/usr/bin/env python
from setuptools import setup, find_packages
setup(name='data',
version='1.0',
description='Perfect library for students.',
author='Lukas Novak',
author_email='lukas@novak.cz',
url='https://lukasnovak.cz',
packages=find_packages(),
)
Podstatná je funkce find_packages()
, která se postará o lokalizování balíčku ve složce ve které je soubor setup.py
umístěn. Ve většině případů stačí použít funkci find_packages()
bez argumentů.
Testování
Testování je nedílnou součástí vývoje jakéhokoli softwaru, určité typy testování jdou automatizovat. My si ukážeme takzvané unit testy (jednotkové testy), název je odvozen od faktu, že postupně testujeme všechny části kódu odděleně (většinou funkci po funkci). Jeden unit test testuje právě jednu věc.
Při vhodném návrhu testů získáme větší důvěru (nikoli jistotu), že náš kód neobsahuje zásadní funkční chyby. Z pravidla je nutné vymyslet a otestovat veškeré krajní případy a případy pro které chceme aby funkce fungovala/nefungovala.
Testování probíhá způsobem, kdy funkci předáme vstup a očekávaný výstup. Pokud tyto dvě věci nejsou rovny, test selže.
Hlavní výhodou unit testů je jejich jednoduchá znovu proveditelnost. Testy nám tímto způsobem “chrání” již implementovaný (a otestovaný) kód. V praxi je běžné nejdříve napsat testy a poté implementovat samotný kód (test driven development - to jste dělali po dobu celého kurzu).
Balíček pytest
Python obsahuje nativní možnost unit testů, budeme však používat rozšířenější možnost - balíček pytest
. Ten je tedy nutné nejprve nainstalovat.
První test
Vytvořme soubor test_inc.py
s následujícím obsahem:
1
2
3
4
5
6
7
def inc(x):
return x + 1
# funkce test_inc provádějící test funkce inc
# assert již známe, pokud selže, selže i celkový test
def test_inc():
assert inc(3) == 5
Poté můžeme pustit příkaz pytest
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
$ pytest
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-6.x.y, py-1.x.y, pluggy-1.x.y
cachedir: $PYTHON_PREFIX/.pytest_cache
rootdir: $REGENDOC_TMPDIR
collected 1 item
test_sample.py F [100%]
================================= FAILURES =================================
_______________________________ test_answer ________________________________
def test_answer():
> assert inc(3) == 5
E assert 4 == 5
E + where 4 = inc(3)
test_sample.py:6: AssertionError
========================= short test summary info ==========================
FAILED test_sample.py::test_answer - assert 4 == 5
============================ 1 failed in 0.12s =============================
Organizace testů
Existují základní doporučení jak testy v rámci balíčku organizovat (nalezneme je zde).
Jde především o následující adresářovou strukturu:
1
2
3
4
5
6
7
8
9
setup.py
mypkg/
__init__.py
app.py
view.py
tests/
test_app.py
test_view.py
...
Všimněme si prefixu test_
u souborů obsahující testy. Druhou částí názvu je pak název modulu, který v souboru testujeme (soubor test_app.py
může obsahovat libovolné množství testů).
Hlavní výhodou je, že po instalaci balíčku interaktivní metodou (-e .
) můžeme spouštět ve složce příkaz pytest
, který automaticky spustí všechny dostupné testy.
Další příklady
Podívejme se na test test_dot_product.py
z balíčku algebra
(3. seminář).
1
2
3
4
5
6
7
8
import pytest
from algebra.vector import dot_product
def test_dot_product():
assert dot_product([1, 2, 3], [3, 2, 1]) == 10
assert dot_product([-1, 2, 3], [3, 2, 1]) == 4
Funkce test_dot_product()
tedy testuje dva možné vstupy (a výstupy) pro funkci dot_product
.
V případě dot_product
může být žádoucí otestovat větší množství kombinací vstupu a výstupu. Pro tyto potřeby můžeme použít dekorátor @pytest.mark.parametrize
- test poté parametrizuje.
1
2
3
4
5
6
7
8
9
10
11
import pytest
from algebra.vector import dot_product
@pytest.mark.parametrize(
"v1, v2, result",
[([1, 2, 3], [3, 2, 1], 10),
([-1, 2, 3], [3, 2, 1], 4)],
)
def test_dot_product(v1, v2, result):
assert dot_product(v1, v2) == result
Test se poté spustí pro všechny vstupy/výstupy které uvádíme.
Dalším příkladem je testování třídy Index
z balíčku data
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
import pytest
from data.index import Index
def test_index():
test_labels = ["key 1", "key 2", "key 3", "key 4", "key 5"]
idx = Index(labels=test_labels)
values = [0, 1, 2, 3, 4]
assert idx.labels == test_labels
assert isinstance(idx.labels, list)
assert idx.name == ""
Funkce test_index
testuje vytvoření instance třídy Index
. Vzpomeňme si však, že třída Index
obsahuje i další metody, je tedy nutné testy rozšířit.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import pytest
from data.index import Index
def test_index():
test_labels = ["key 1", "key 2", "key 3", "key 4", "key 5"]
idx = Index(labels=test_labels)
assert idx.labels == test_labels
assert isinstance(idx.labels, list)
assert idx.name == ""
def test_get_loc():
test_labels = ["key 1", "key 2", "key 3", "key 4", "key 5"]
idx = Index(labels=test_labels)
values = [0, 1, 2, 3, 4]
assert values[idx.get_loc("key 2")] == 1
Tyto testy již pokrývají i metodu Index.get_loc()
- ne však úplně, netestujeme vyvolání výjimky, o tom ale později.
Fixtures
Při prvním pohledu vidíme, že v každém testu musíme znovu vytvořit instanci třídy Index
. Balíček pytest
však umožňuje vytvářet takzvané fixtures. Slouží ke zjednodušení opakujících se částí testů. Použití fixtures nám zaručí, že se data pro každý test vytvoří znovu.
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
from data.index import Index
@pytest.fixtures
def labels():
return ["key 1", "key 2", "key 3", "key 4", "key 5"]
@pytest.fixtures
def values():
return [0, 1, 2, 3, 4]
# fixture může používat ostatní fixture
@pytest.fixtures
def index(labels):
return Index(labels=labels)
# test používající dvě fixtures - index a labels
def test_index(index, labels):
assert index.labels == test_labels
assert isinstance(index.labels, list)
assert index.name == ""
# test používající dvě fixtures - index a labels
def test_get_loc(index, values):
assert values[index.get_loc("key 2")] == 1
Testování vyjímek
Jak jsme zmínili, u metody get_loc
nemáme otestovaný případ pro vyvolání výjimky. Situaci vyřešíme přidáním testu.
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
import pytest
from data.index import Index
@pytest.fixtures
def labels():
return ["key 1", "key 2", "key 3", "key 4", "key 5"]
@pytest.fixtures
def values():
return [0, 1, 2, 3, 4]
@pytest.fixtures
def index(labels):
return Index(labels=labels)
def test_index(index, labels):
assert index.labels == test_labels
assert isinstance(index.labels, list)
assert index.name == ""
def test_get_loc(index, values):
assert values[index.get_loc("key 2")] == 1
# testování situace kdy metoda musí vyvolat vyjimku
def test_invalid_key(index):
with pytest.raises(KeyError):
index.get_loc("key 10")
Testování v rámci docstringů
Elegantním způsobem pro testování jednoduchých funkcí je docstring. Knihovna pytest
umožňuje spouštět testy ve formě příkladů v docstring. Příklady jsou potom rovněž zobrazeny v nápovědě k funkci, což zlepšuje její používání.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
def sum_two_numbers(a, b):
"""Sums two numbers.
Args:
a: first number
b: second number
Example:
>>> sum_two_numbers(10, 20)
30
>>> sum_two_numbers(-10, 20)
10
"""
return a + b
Poté stačí přidat argument --doctest-modules
při spuštění pytest
:
1
2
3
4
5
6
7
8
9
10
$ pytest --doctest-modules
======================= test session starts ========================
platform darwin -- Python 3.10.0, pytest-6.2.5, py-1.10.0, pluggy-1.0.0
rootdir: /Users/tomasmikula/Downloads/vyuka
plugins: cov-3.0.0
collected 1 item
sum_two_numbers.py . [100%]
======================== 1 passed in 0.03s =========================
Pozor, ne vždy stačí spoléhat na testy v podobě příkladů v docstring, komplexnější testování by mělo být provedeno v samostatných test_*.py
souborech.
Code coverage
V kontextu testování zdrojového kódu je nutné zmínit pojem code coverage. Jedná se o procentuální pokrytí testů, neboli jaké procento zdrojového kódu je testováno. V ideálním případě, je dobré docílit 100% pokrytí, v praxi jsou však hodnoty nad 90% dostatečné.
Jak code coverage počítat?
V kombinaci s knihovnou pytest
lze nainstalovat knihovnu coverage
, která code coverage vypočítá.
1
$ py -m pip install coverage
Dále můžeme code coverage spočítat příkazem coverage run -m pytest
(případně musíme dodat další argumenty jako --doctest-modules
).
1
$ coverage run -m pytest
A zobrazit výsledek:
1
2
3
4
5
6
7
$ coverage report -m
Name Stmts Miss Cover Missing
-------------------------------------------------------
my_program.py 20 4 80% 33-35, 39
my_other_module.py 56 6 89% 17-23
-------------------------------------------------------
TOTAL 76 10 87%
Type hinting
Jazyk Python je dynamicky typovaný, to nám ale nebrání implementovat takzvaný type hinting (nápověda datových typů). Uživatelům našeho zdrojového kódu pak umožníme jednoduše vidět s jakým datovým typem náš zdrojový kód pracuje. Tyto informace jsou využity rovněž různými editory (například Microsoft Visual Code).
1
2
3
4
5
6
7
8
9
10
11
def sum_two_numbers(a: int, b: int) -> int:
"""Sums two numbers.
Args:
a (int): first number
b (int): second number
Returns:
int: Summation of a and b
"""
return a + b
Všimněme si několika věcí. Dvojtečka za názvem parametru umožňuje zápis datového typu. Pro určení typu návratové hodnoty můžeme použít ->
za závorkou na řádku definice funkce, nezapomeňte ale na :
na konci řádku. Je nutné zdůraznit, že typing nikterak neovlivňuje funkčnost funkce, takto definované funkce můžeme předat float
a výsledek bude vypočítán.
Rozšiřme tedy typování této funkce na libovolné číslo. Tahák k modulu typing
nalezneme zde. Abstraktní typ čísla můžeme získat z modulu numbers
.
1
2
3
4
5
6
7
8
9
10
11
12
13
from numbers import Number
def sum_two_numbers(a: Number, b: Number) -> Number:
"""Sums two numbers.
Args:
a (Number): first number
b (Number): second number
Returns:
Number: Summation of a and b
"""
return a + b
Druhou možností by bylo použít typing.Union
(realizuje sjednocení, funkce přijímá více datových typů).
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import typing
def sum_two_numbers(
a: typing.Union[int, float], b: typing.Union[int, float]
) -> typing.Union[int, float]:
"""Sums two numbers.
Args:
a (typing.Union[int, float]): first number
b (typing.Union[int, float]): second number
Returns:
typing.Union[int, float]: Summation of a and b
"""
return a + b
Pozor v tomto případě komunikujeme, že funkce přijímá pouze int
a float
, existují však další číselné typy (např complex
).
Typování sekvencí/kolekcí
Pokud funkce pracuje se sekvencemi můžeme typovat sekvenci obecnou typing.Sequence
, obdobně potom obecnou kolekci typing.Collection
. V případě konkrétních sekvencí/kolekce (například list
) můžeme použít typing.List
(typing.Tuple
, typing.Dict
, typing.Set
a další).
Vraťme se k příkladu dot_product()
pro dva vektory:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import typing
def dot_product(
vector_1: typing.Sequence[typing.Union[int, float]],
vector_2: typing.Sequence[typing.Union[int, float]],
) -> typing.Union[int, float]:
"""Calculates dot product of two vectors.
Args:
vector_1 (typing.Sequence): vector 1
vector_2 (typing.Sequence): vector 2
Returns:
typing.Union[int, float]: Dot product of vector 1 and vector 2
"""
return sum((a * b for a, b in zip(vector_1, vector_2)))
Všimněme si především toho, že v případě sekvencí/kolekcí/iterátorů můžeme udat datový typ obsahu.
Typování tříd
V případě tříd metoda __init__
nevrací žádnou hodnotu, proto u typování uvádíme -> None
. Rovněž v případě parametru self
typování vynecháme.
1
2
3
4
5
6
class MyClass:
def __init__(self) -> None:
...
def my_method(self, num: int, str1: str) -> str:
return num * str1
Volitelné argumenty a typing.Optional
V případě, že funkce přijímá některé volitelné argumenty, syntaxe zůstane obdobná jako u funkcí, které typování nemají. Pro případ kdy chceme definovat argument určitého typu a zároveň umožňuje hodnotu None
použijeme typing.Optional[typ]
, který je ekvivalentní typing.Union[typ, None]
.
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
import typing
def submatrix(
matrix: typing.Sequence[typing.Sequence[typing.Union[int, float]]],
drop_rows: typing.Optional[typing.Sequence] = None,
drop_columns: typing.Optional[typing.Sequence] = None,
) -> typing.Sequence[typing.Sequence[typing.Union[int, float]]]:
"""Creates submatrix of given matrix based on row and column indexes.
Args:
matrix (typing.Sequence[typing.Sequence[typing.Union[int, float]]]):
input matrix
drop_rows (typing.Optional[typing.Sequence], optional):
rows which should be dropped. Defaults to None.
drop_columns (typing.Optional[typing.Sequence], optional):
columns which should be dropped. Defaults to None.
Returns:
typing.Sequence[typing.Sequence[typing.Union[int, float]]]:
resulting matrix
"""
new_matrix = []
...
return new_matrix
Ostatní typy
Modul typing
obsahuje většinu typů pro pohodlné typování, jedná se například o typing.Iterable
případně typing.Callable
(v případě typování parametru na funkci). Doporučuji prostudovat celou nabídku.
Úkoly
Nevíte si rady? Přečtěte si “Jak pracovat s Github Classroom?”.
- L10E01: TicTacToe [Náhled], [Příjmout úkol]