V tomto cvičení se budeme zabývat automatickým testováním kódu. Modul unittest ze standardní knihovny už byste měli znát, co to jsou jednotkové testy a k čemu slouží tedy rovnou přeskočím.
Pokud základy testování neznáte, 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.
Rovnou se podíváme na velmi oblíbený balíček pytest, který oproti standardnímu
unittestu přináší mnoho výhod. Začneme jednoduchou ukázkou z modulu isholiday
z cvičení o modulech.
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.6.0, pytest-3.0.7, py-1.4.33, pluggy-0.4.0
rootdir: /tmp/tmp.wGP3zD7HeD, inifile:
collected 1 items
tests/test_holidays.py .
=========================== 1 passed in 0.01 seconds ===========================
Všimněte si několika věcí:
test_*
a pytest
pozná,
že se jedná o test.assert
, nikoliv metoda z unittest
.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.)
Pokud pytest nemůže naimportovat váš modul, můžete udělat několik věcí:
develop
).PYTHONPATH
na .
.Testovat nainstalovaný balíček je výhodnější – ověříte zároveň, že nainstalovaný modul se chová dle očekávání. Je dobré testy psát tak, aby šly spouštět z jakéhokoliv adresáře, a pro jistotu je spouštět odjinud, než z adresáře s kódem. Odhalíte tím často balíčkovací chyby.
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.6.0, pytest-3.0.6, py-1.4.32, pluggy-0.4.0
rootdir: /tmp/tmp.NsIa3sXVQA, inifile:
collected 1 items
tests/test_holidays.py F
=================================== 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.04 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
...
tests/test_holidays.py::test_xmas[2015] PASSED
tests/test_holidays.py::test_xmas[2016] PASSED
tests/test_holidays.py::test_xmas[2017] PASSED
tests/test_holidays.py::test_xmas[2033] PASSED
tests/test_holidays.py::test_xmas[2048] PASSED
=========================== 5 passed in 0.26 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()
(__venv__) $ python -m pytest -v
...
tests/test_holidays.py::test_xmas[2015] PASSED
tests/test_holidays.py::test_xmas[2016] PASSED
tests/test_holidays.py::test_xmas[2017] PASSED
tests/test_holidays.py::test_xmas[2033] FAILED
tests/test_holidays.py::test_xmas[2048] FAILED
...
====================== 2 failed, 3 passed in 0.23 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 věnuji 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 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 věnuji 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('/').data.decode('utf-8')
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.data.decode('utf-8')
.
Podobně funguje testování aplikací v clikcu.
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.
Pozor na to, aby testy byly součástí archivu s balíčkem (setup.py sdist
), ale
pokud zvolíte první variantu umístění, aby se neinstalovaly (setup.py install
),
protože by tam kolidovaly s ostatními testy z jiných balíčků.
Případné soubory potřebné k testování bývá zvykem dávat do složky fixtures
ve
složce s testy.
setup.py test
Standardně se testy v Pythonu nespouští pomocí python -m pytest
, ale
python setup.py test
, což funguje i s jinými nástroji než je pytest.
Pokud pytest používáme, je proto dobré setup.py
naučit spouštět pytest.
K tomu potřebujeme nakonfigurovat závislosti: v setup_requires
musí být
pytest-runner
a v tests_require
pak pytest
a další testovací závislosti
(flexmock
, betamax
...).
from setuptools import setup
setup(
...,
setup_requires=['pytest-runner', ...],
tests_require=['pytest', ...],
...,
)
a přidat následující sekci do setup.cfg
:
[aliases]
test=pytest
Příkaz python setup.py test
by měl fungovat, ale neočekává se, že bude
podporovat další argumenty pytestu (jako -v
).
Na to uživatel spustí pytest samotný.
Další informace jsou v dokumentaci pytestu.
Vaše testy nemusí běžet jen u vás na počítači, ale můžete je pouštět automaticky na službě Travis CI při každém pushnutí na GitHub.
Travis CI je zadarmo pro veřejné repozitáře na travis-ci.org, pro soukromé repozitáře je placená verze na travis-ci.com. V rámci studentského balíčku můžete i tuto verzi využít zdarma.
Přihlaste se na travis-ci.com pomocí GitHubu (vpravo nahoře). Pak opět vpravo nahoře zvolte Accounts a povolte Travis pro váš repozitář.
Do repozitáře přidejte soubor .travis.yml
:
language: python
python:
- '3.6'
install:
- python setup.py install
script:
- python setup.py test
Po pushnutí by se na Travisu měl automaticky spustit test. Více informací o použití pro Python najdete v dokumentaci.
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)