V tomto cvičení se budeme zabývat automatickým testováním kódu.
Pokud neznáte základy testování, projděte si začátečnickou lekci o testování. Obsah se zčásti překrývá, ale základní principy jsou tam vysvětleny trošku podrobněji.
Pokud si chcete přečíst krátký text o tom, jak testovat, zkuste blogový zápisek Michala Hořejška.
Začneme jednoduchou ukázkou z modulu isholiday
.
Modul isholiday
Ondřeje Caletky obsahuje informace o českých svátcích a je
ke stažení zde.
Nejprve ukázka použití modulu unittest
ze standardní knihovny Pythonu, abychom
následně měli s čím srovnávat.
import isholiday
import unittest
class TestIsHoliday(unittest.TestCase):
def test_xmas_2016(self):
holidays = isholiday.getholidays(2016)
self.assertIn((24, 12), holidays)
Test uložíme do souboru tests/test_holidays_unittest.py
a spustíme pomocí modulu unittest
takto:
$ python -m unittest tests/test_holidays_unittest.py
.
----------------------------------------------------------------------
Ran 1 test in 0.000s
OK
A teď už se podíváme na velmi oblíbený balíček pytest, který oproti standardnímu unittestu přináší mnoho výhod.
import isholiday
def test_xmas_2016():
"""Test whether there is Christmas in 2016"""
holidays = isholiday.getholidays(2016)
assert (24, 12) in holidays
Test uložíme někam do projektu, třeba do souboru tests/test_holidays.py
a
nainstalujeme a spustíme pytest
:
(__venv__) $ python -m pip install pytest
(__venv__) $ python -m pytest tests/test_holidays.py
============================= test session starts ==============================
platform linux -- Python 3.7.1, pytest-4.0.1, py-1.7.0, pluggy-0.8.0
rootdir: /tmp/tmp.etepchwQWh, inifile:
collected 1 item
tests/test_holidays.py . [100%]
=========================== 1 passed in 0.01 seconds ===========================
Všimněte si několika věcí:
test_*
a pytest
pozná,
že se jedná o test.assert
, žádná speciální metoda.Co se má testovat, se pytestu dá zadat pomocí argumentů příkazové řádky.
Můžou to být jednotlivé soubory nebo adresáře, ve kterých pytest
rekurzivně hledá všechny soubory začínající na test_
.
Vynecháme-li argumenty úplně, hledá rekurzivně v aktuálním adresáři.
(To se často hodí, ale obsahuje-li aktuální adresář i vaše virtuální prostředí,
pytest prohledá i to a často v něm najde neprocházející testy.)
Pytest upravuje chování assertu, což oceníte především, pokud test selže:
...
assert (23, 12) in holidays
(__venv__) $ python -m pytest tests/test_holidays.py
============================= test session starts ==============================
platform linux -- Python 3.7.1, pytest-4.0.1, py-1.7.0, pluggy-0.8.0
rootdir: /tmp/tmp.etepchwQWh, inifile:
collected 1 item
tests/test_holidays.py F [100%]
=================================== FAILURES ===================================
________________________________ test_xmas_2016 ________________________________
def test_xmas_2016():
"""Test whether there is Christmas in 2016"""
holidays = isholiday.getholidays(2016)
> assert (23, 12) in holidays
E assert (23, 12) in {(1, 1), (1, 5), (5, 7), (6, 7), (8, 5), (17, 11), ...}
tests/test_holidays.py:6: AssertionError
=========================== 1 failed in 0.02 seconds ===========================
S obyčejným assertem si vystačíte pro většinu testovaných případů kromě ověření vyhození výjimky. To se dělá takto:
import pytest
def f():
raise SystemExit(1)
def test_mytest():
with pytest.raises(SystemExit):
f()
Více o základním použití pytestu najdete v dokumentaci.
Jednou z vlastností pytestu, která často přichází vhod, jsou parametrické testy. Pokud bychom například chtěli otestovat, jestli je Štědrý den svátkem nejen v roce 2016, ale v jiných letech, nemusíme psát testů více, ani použít cyklus.
Nevýhoda více téměř stejných testů je patrná sama o sobě, nevýhoda cyklu je v tom, že celý test selže, i pokud selže jen jeden průběh cyklem. Zároveň se průběh testu při selhání ukončí.
Místo toho tedy použijeme parametrický test:
import pytest
import isholiday
@pytest.mark.parametrize('year', (2015, 2016, 2017, 2033, 2048))
def test_xmas(year):
"""Test whether there is Christmas"""
holidays = isholiday.getholidays(year)
assert (24, 12) in holidays
Zápis je určitým způsobem podobný knihovně click: funkce
s testem přijímá parametr vytvořený v dekorátoru.
Test se spustí pro každou uvedenou hodnotu, k jejich definici lze použít
jakýkoliv objekt, přes který jde iterovat, tedy kromě v ukázce použité
n-tice např. seznam, množinu, range
, vlastní generátor...
Pro podrobnější výpis výsledku testů můžete použít přepínač -v
:
(__venv__) $ python -m pytest -v
============================= test session starts ==============================
platform linux -- Python 3.7.1, pytest-4.0.1, py-1.7.0, pluggy-0.8.0 -- /tmp/tmp.etepchwQWh/__venv__/bin/python
cachedir: .pytest_cache
rootdir: /tmp/tmp.etepchwQWh, inifile:
collecting ... collected 5 items
tests/test_holidays.py::test_xmas[2015] PASSED [ 20%]
tests/test_holidays.py::test_xmas[2016] PASSED [ 40%]
tests/test_holidays.py::test_xmas[2017] PASSED [ 60%]
tests/test_holidays.py::test_xmas[2033] PASSED [ 80%]
tests/test_holidays.py::test_xmas[2048] PASSED [100%]
=========================== 5 passed in 0.02 seconds ===========================
Jednoduchým způsobem tak lze vyrobit z jednoho testu testů více. Výhodou je, že každý se testuje zvlášť, což má vliv na čitelnost výstupu, pokud nějaký test selže, a umožňuje to například testy pouštět paralelně nebo distribuovaně. (Což s jedním testem, který více podmínek ověřuje v cyklu, nejde, tedy alespoň ne jednoduše.)
Potřebujeme-li parametrizovat více argumentů, můžeme předat seznam jmen argumentů a seznam jejich hodnot:
import pytest
import isholiday
@pytest.mark.parametrize(
['year', 'month', 'day'],
[(2015, 12, 24),
(2016, 12, 24),
(2017, 1, 1),
(2033, 7, 5),
(2048, 7, 6)],
)
def test_some_holidays(year, month, day):
"""Test a few sample holidays"""
holidays = isholiday.getholidays(year)
assert (day, month) in holidays
Vždy je dobré pokusit se nějaký test rozbít v samotném kódu, který testujeme,
abychom se ujistili, že testujeme správně.
Přidáme tedy dočasně na konec funkce getholidays()
tento pesimistický kus kódu:
if year > 2020:
# After the Zygon war, the puppet government canceled all holidays
holidays = set()
============================= test session starts ==============================
platform linux -- Python 3.7.1, pytest-4.0.1, py-1.7.0, pluggy-0.8.0 -- /tmp/tmp.etepchwQWh/__venv__/bin/python
cachedir: .pytest_cache
rootdir: /tmp/tmp.etepchwQWh, inifile:
collecting ... collected 5 items
tests/test_holidays.py::test_xmas[2015] PASSED [ 20%]
tests/test_holidays.py::test_xmas[2016] PASSED [ 40%]
tests/test_holidays.py::test_xmas[2017] PASSED [ 60%]
tests/test_holidays.py::test_xmas[2033] FAILED [ 80%]
tests/test_holidays.py::test_xmas[2048] FAILED [100%]
=================================== FAILURES ===================================
_______________________________ test_xmas[2033] ________________________________
year = 2033
@pytest.mark.parametrize('year', (2015, 2016, 2017, 2033, 2048))
def test_xmas(year):
"""Test whether there is Christmas"""
holidays = isholiday.getholidays(year)
> assert (24, 12) in holidays
E assert (24, 12) in set()
tests/test_holidays.py:8: AssertionError
_______________________________ test_xmas[2048] ________________________________
year = 2048
@pytest.mark.parametrize('year', (2015, 2016, 2017, 2033, 2048))
def test_xmas(year):
"""Test whether there is Christmas"""
holidays = isholiday.getholidays(year)
> assert (24, 12) in holidays
E assert (24, 12) in set()
tests/test_holidays.py:8: AssertionError
====================== 2 failed, 3 passed in 0.03 seconds ======================
Často se stává, že před samotným testem potřebujte spustit nějaký kus kódu, abyste získali to, co teprve chcete testovat. Příkladem může být například inicializace objektu pro komunikaci s nějakým API.
V pytestu k tomuto účelu nejlépe slouží tz. fixtures, které se v samotných testech používají jako argumenty funkcí.
import pytest
@pytest.fixture
def client():
import twitter
return twitter.Client(...)
def test_search_python(client):
tweets = client.search('python', size=1)
assert len(tweets) == 1
assert 'python' in tweets[0].text.lower()
Fixtures se hledají pomocí jména: když má testovací funkce (nebo i jiná fixture) parametr, podle jména tohoto parametru se najde odpovídající fixture. Fixtures můžou být definovány v aktuálním souboru, v pluginu, konfiguračním souboru a některé jsou zabudované přímo v pytestu.
Pokud potřebujete po použití s fixturou ještě něco udělat, můžete místo return
použít yield
.
Často se to používá u zdrojů, které je po použití potřeba nějak finalizovat či
zavřít, například u databázových spojení.
Zde je ilustrační příklad, který si můžete rovnou vyzkoušet:
import pytest
class DBConnection:
def __init__(self, name):
print('Creating connection for ' + name)
...
def select(self, arg):
return arg
def cleanup(self):
print('Cleaning up connection')
...
@pytest.fixture
def connection():
d = DBConnection('sqlite')
yield d
d.cleanup()
@pytest.mark.parametrize('arg', (1, float, None))
def test_with_fixture(connection, arg):
assert arg == connection.select(arg)
Standardní výstup (stderr
a stdout
) z testů se normálně zobrazuje,
jen když test selže.
Chceme-li výstup vidět u všech testů, je třeba použít přepínač -s
.
I fixtury jdou parametrizovat, jen trochu jiným způsobem než testovací funkce:
parametry předané dekorátoru pytest.fixture
získáme ze speciálního parametru
request
, který obsahuje informace o probíhajícím testu:
@pytest.fixture(params=('sqlite', 'postgres'))
def connection(request):
d = DBConnection(request.param)
yield d
d.cleanup()
Hromadu dalších příkladů použití pytestu najdete dokumentaci v sekci s příklady. Hledáte-li příklady krok za krokem, zkuste příspěvek ze sborníku konference PyCon PL.
Při psaní testů se občas hodí trochu podvádět. Například když nechceme, aby testy měly nějaký vedlejší účinek, když chceme testovat něco, co závisí na náhodě a podobně. Obecně se tomuto říká mocking * či test doubles a existuje více různých knihoven, které to umožňují. Jednou z nich je flexmock.
* mocking je jen jeden druh podvádění, ale obecně se dá tento název použít pro funkcionalitu knihoven, které mají v názvu mock :)
Při testování často potřebujeme nějaký objekt, který má určité atributy a metody. Vytvářet si pro každý takový objekt třídu může být ubíjející:
class FakePlane:
operational = True
model = 'MIG-21'
def fly(self): pass
plane = FakePlane() # this is tedious!
Flexmock umožňuje vytvoření objektu rychle a jednoduše:
plane = flexmock(operational=True,
model='MIG-21',
fly=lambda: None)
Stejně tak můžete vzít i nějaký existující objekt nebo třídu a upravit jen část atributů nebo metod:
>>> import flexmock
>>> class Train:
... def get_speed(self):
... return 0
...
>>> flexmock(Train, get_speed=200)
<flexmock.Mock object at 0x7f88501d8908>
>>> train = Train()
>>> train.get_speed()
200
Můžete tak zfalšovat i volání builtin funkcí, jako je například open()
:
>>> import sys
>>> import flexmock
>>> import builtins
>>> from io import StringIO
>>> flexmock(builtins, open=StringIO('fake content'))
<module 'builtins' (built-in)>
>>> with open('/etc/passwd') as f:
... f.readlines()
...
['fake content']
Pomocí flexmocku můžete zároveň kontrolovat, že se vaší implementaci něco zavolalo, a to dvojím způsobem: buďto zároveň změníte výsledek funkce (mocks), nebo jen sledujete, jestli se zavolala (spies). (Příklady na odkazu.)
Dobrá mockovací knihovna se stará o to, aby platnost vašich změn byla omezená kontextem jedné funkce a tedy jednoho testu. Implementovat vlastní test double ale není nic těžkého a můžete to udělat sami (bez knihovny). Pro přepsání nějaké metody, funkce apod. na omezenou dobu můžete využít zabudovanou pytest fixturu monkeypatch.
Podvádění při testech občas vypadá nevyhnutelně. Pokud například vaše funkce
čte soubor /etc/passwd
a vy chcete testovat, že se zachová správně, pokud
bude obsahovat daný obsah, musíte si trochu zapodvádět, protože nemůžete vědět,
co v tom souboru je doopravdy na daném systému, v daný čas.
Je ale jednoduché sklouznout do fáze, kdy jsou vaše testy natolik přemockované, že už ani neplní svůj účel. Buďto proto, že příliš podvádíte a testy vždy projdou, i když je implementace rozbitá; nebo proto, že při sebemenší úpravě vnitřní implementace musíte vždy upravit i testy.
Mějte toto na paměti a k mockování se uchylujte až po vyčerpání „slušnějších”
možností.
Často jde trochu změnit kód, aby byl testovatelnější – například napsat funkci,
která čte soubor formátu /etc/passwd
, ale jméno souboru jí předat argumentem.
Mohl by vás zajímat záznam z přednášky Should I mock or should I not? z konference PyCon CZ 2017. V přednášce se Miro Hrončok věnuje různým způsobům podvádění při psaní testů.
Vaše programy často používají webová API. Při testování funkcionality API klientů se vynoří řada problémů:
V zásadě můžete omockovat knihovnu requests tak, aby
jednotlivá volání jako get()
apod. vracela předem definovanou odpověď.
Při ponoření do hloubky ale zjistíte, že komplexita takového mockování může
velmi přesáhnout komplexitu samotného kódu, který testujete.
Jednodušší je tak použít již hotové řešení. Jedno z nich je betamax.
Betamax umožňuje nahrát HTTP komunikaci do kazet (souborů), které se potom použijí při testech. V zásadě to funguje takto:
Betamax funguje pouze s knihovnou requests při použití session.
V kombinaci s pytestem můžete použít předpřipravenou fixture:
import betamax
with betamax.Betamax.configure() as config:
# tell Betamax where to find the cassettes
# make sure to create the directory
config.cassette_library_dir = 'tests/fixtures/cassettes'
def test_get(betamax_session):
betamax_session.get('https://httpbin.org/get')
Před spuštěním testu vytvořte složku tests/fixtures/cassettes
.
Po spuštění testu ji prozkoumejte.
Měla by obsahovat soubor test_filename.test_get.json
.
To je nahraná kazeta. Každý další průběh testu nevykoná GET požadavek,
ale pouze přehraje danou kazetu. Pokud chcete kazetu opět nahrát, prostě ji
smažte a pusťte test znovu.
Celé to ale funguje pouze, pokud kód vykonávaný uvnitř testu použije speciální session, kterou máme od betamaxu. Jak to udělat?
Je třeba, aby implementační část kódu uměla session přejmout, například takto:
class Client:
def __init__(self, session=None):
self.session = session or requests.Session()
...
def test_clent_foo(betamax_session):
client = Client(session=betamax_session)
assert client.foo() == 42
Pokud budete používat parametrizované testy, použijte
betamax_parametrized_session
, aby kazety měly odlišné jméno při odlišných
parametrech.
Pro tip: Abyste nevytvářeli novou instanci třídy ve všech testech, můžete si
vytvořit vlastní fixture, která použije fixture betamax_session
:
@pytest.fixture
def client(betamax_session):
return Client(session=betamax_session)
Při práci s webovými API často létají vzduchem citlivé údaje jako tokeny apod.
Vyvstávají dvě otázky:
Na obě otázky se pokusím odpovědět jedním okomentovaným kódem:
with betamax.Betamax.configure() as config:
if 'AUTH_FILE' in os.environ:
# If the tests are invoked with an AUTH_FILE environ variable
TOKEN = my_auth_parsing_func(os.environ['AUTH_FILE'])
# Always re-record the cassetes
# https://betamax.readthedocs.io/en/latest/record_modes.html
config.default_cassette_options['record_mode'] = 'all'
else:
TOKEN = 'false_token'
# Do not attempt to record sessions with bad fake token
config.default_cassette_options['record_mode'] = 'none'
# Hide the token in the cassettes
config.define_cassette_placeholder('<TOKEN>', TOKEN)
...
@pytest.fixture
def client(betamax_session):
return Client(token=TOKEN, session=betamax_session)
Co když ale nevíme, jak bude vypadat citlivá část požadavku, protože se teprve někde spočítá a získá, jako například v případě Twitter API? Na tuto otázku podrobněji odpovídá dokumentace.
V každém případě je moudré před uložením do gitu zkontrolovat, že se v kazetách nenachází žádný citlivý údaj, a pokud tam je, přepsat kód tak, aby se tam nenacházel.
Problém může nastat, pokud je token či jiná citlivá informace uložena jako část v těle
odpovědi (případně i požadavku) a zároveň je toto tělo zprávy zkomprimováno (defaultní
chování, viz dokumentace).
V takovém případě je potřeba k tomu, aby šlo v kazetě nahradit citlivé údaje, upravit
hlavičku Accept-Encoding
v betamax_session
tak, aby neobsahovala *
, gzip
,
compress
ani deflate
:
betamax_session.headers.update({'Accept-Encoding': 'identity'})
Kódování 'identity'
má shodné chování jako ''
a to, že data ve zprávě nejsou
nijak transformována, více viz Wikipedia
a specifikace HTTP)
Podle čeho se vyhodnotí, že HTTP požadavek odpovídá nahrané interakci a má se pouze přehrát? Ve výchozím stavu podle HTTP metody a URL. Pokud tedy na jedno URL provedete dva POST požadavky s jiným tělem, betamax je bude považovat za stejné. Toto chování lze měnit zapnutím (nebo vypnutím) různých matcherů. Těch je v betamaxu celá řada a je jednoduché napsat si vlastní. Více informací najdete v dokumentaci.
Mohl by vás zajímat záznam z přednášky If it Moves, Test it Anyway z konference PyCon CZ 2016. V přednášce se Miro Hrončok věnuje různým způsobům, jak testovat webové API klienty v Pythonu.
Pro testování aplikací ve Flasku se
používá app.test_client()
:
import pytest
@pytest.fixture
def testapp():
from hello import app
app.config['TESTING'] = True
return app.test_client()
def test_hello(testapp):
assert 'Hello' in testapp.get('/').get_data(as_text=True)
Pozor, metody na testovacím klientu vrací Response, ale trochu jinou, než tu
z requests.
Proto nelze použít přímo response.text
; text dostaneme pomocí
response.get_data(as_text=True)
.
Podobně funguje testování aplikací v clicku.
Click obsahuje třídu CliRunner
, která pomáhá s testováním:
from click.testing import CliRunner
def test_push_force():
runner = CliRunner()
result = runner.invoke(git_cli_made_in_click, ['push', '--force'])
assert result.exit_code == 0
assert 'forced update' in result.output
Dokumentace pytestu uvádí dvě možnosti, kam dát adresář s testy. Buď vedle adresáře s modulem:
setup.py
mypkg/
__init__.py
appmodule.py
tests/
test_app.py
...
nebo do něj:
setup.py
mypkg/
__init__.py
appmodule.py
...
test/
test_app.py
...
První způsob je preferovaný, protože pomáhá udržovat kód a testy oddělené.
Pokud ho použijete, nedávejte do něj __init__.py
– není to importovatelný
Pythonní modul, ale jen sada souborů s testy.
Ve druhém případě mějte na paměti, že pytest pouští testy jako samostatné
moduly, ne jako součást vašeho balíčku.
Relativní importy (from ..appmodule import xyz
) v testech nebudou fungovat.
Případné soubory potřebné k testování bývá zvykem dávat do složky fixtures
ve
složce s testy.
Co je špatně na této testovací sadě k funkci is_even()
?
def is_even(n):
return n % 2 == 0
@pytest.mark.parametrize('n', range(0, 1000, 2))
def test_is_even(n):
assert is_even(n)