Co se mi líbí na pytestu

Stručně jsem tu už o pytestu psal v poznámkách z konference PyCON UK. Od té doby jsme v našem týmu pytest nasadili na několik komponent a testuje se nám parádně. Proč?

Asserty

Není potřeba psát více, než je potřeba. Python už assert v sobě má.

def test_eq():
    assert 1 == 2

Spustíme…

$ py.test test.py
=========================== test session starts ===========================
platform linux2 -- Python 2.7.3 -- py-1.4.25 -- pytest-2.6.3
collected 1 items

test.py F

================================= FAILURES ================================
--------------------------------- test_eq ---------------------------------

    def test_eq():
>   	assert 1 == 2
E    assert 1 == 2

test.py:6: AssertionError
========================= 1 failed in 0.01 seconds =======================

Super! Tímto samozřejmě pytest nekončí. Například balík unittest dodává při assertu různé pomocné hlášky. Tady začíná trochu magie. Python sám o sobě nic víc při použití assertu neřekne, proto pytest udělá menší analýzu kódu a vyplivne podrobný rozbor, třeba i s diffem.

def test_eq():
    a = 'aa'
    assert a == 'aab'
    def test_eq():
    	a = 'aa'
>   	assert a == 'aab'
E    assert 'aa' == 'aab'
E      - aa
E      + aab
E      ?   +

Poradí si i s instancemi a vnořenými instrukcemi. Co ale další typy, třeba assertIn, assertIsInstance, …?

    def test_in():
>   	assert 42 in range(10)
E    assert 42 in [0, 1, 2, 3, 4, 5, ...]
E     +  where [0, 1, 2, 3, 4, 5, ...] = range(10)
    def test_isinstance():
>   	assert isinstance(datetime.date.today(), datetime.datetime)
E    assert isinstance(datetime.date(2015, 2, 14), )
E     +  where datetime.date(2015, 2, 14) = ()
E     +    where  = .today
E     +      where  = datetime.date
E     +  and    = datetime.datetime

Tady je vidět, že občas pytest trochu spamuje. V tomto případě je nám jedno, jakou konkrétní hodnotu datum obsahuje a pokud by se hodilo, stačí vědět datum, nikoliv kde všude v paměti se vše nachází. Ono je to ale takhle lepší, jak zjistíme později. Hlavně pokud se to děje samo s kódem, který musíme tak jako tak napsat, proč ne?

Jak jsou na tom očekávané výjimky?

def test_zerodivision():
    with pytest.raises(ZeroDivisionError) as excinfo:
        1/0
    assert excinfo.value.args[0] == 'integer division or modulo by zero'

Co třeba almostEqual?

Tato metoda a jí podobné chybí. Ale nikdo nezakazuje použití jiných balíků, třeba nose.

from nose.tools import assert_almost_equals

def test():
    assert_almost_equals(1.234, 1.2345, places=4)

Pytest s obyčejným assertem dokáže vše, co je potřeba, aniž byste přišli o jakoukoliv informaci. Dokonce vám dovolí rozšířit assert o vaše informace. Takže při volání assert s vašimi instancemi si můžete přidat potřebná data. Více v dokumentaci.

class Foo:
    def __init__(self, val):
        self.val = val

def pytest_assertrepr_compare(op, left, right):
    if isinstance(left, Foo) and isinstance(right, Foo) and op == "==":
        return [
            'Comparing Foo instances:',
            '   vals: %s != %s' % (left.val, right.val)
        ]

Výstup pak vypadá například takto:

    def test_compare():
        f1 = Foo(1)
        f2 = Foo(2)
>       assert f1 == f2
E       assert Comparing Foo instances:
E            vals: 1 != 2

Fixtury

Test se však neskládá jen z testu. Pro test je potřeba, především pro integrační test, předpřipravit data, konexe a další závislosti. Pytest tuto nutnost řeší od začátku bravurním způsobem. Pravděpodobně největší páka, proč jsem vynaložil energii unittest u nás vyměnit právě za pytest.

Klasické unittesty mají metody setUp a tearDown, kterými lze předpřipravit prostředí pro testy, či po sobě uklidit. Problém začne, jakmile taková příprava nění jednoduchá. Například pokud chcete otestovat založení objednávky v e-shopu. K tomu potřebujete minimálně testovací produkt a testovacího uživatele. Takový produkt může být nějakého typu, který potřebuje nakonfigurovat. U uživatele zase potřebujete vyplněné fakturační údaje; ověřit, že se vygeneruje dobře faktura. Poměrně hodně věcí pro jeden setUp.

Pytest na to jde jinak. „Magickým“ dependency injection.

def test_order(user, product):
    # test ordering of product by user

Kde pytest získá uživatele a produkt?

Vy mu je nadefinujete.

@pytest.fixture
def user():
    return create_some_user_here()

@pytest.fixture
def product():
    return create_some_product_here()

Dobře. Ale pro tyto fixtury je potřeba databáze…

@pytest.fixture(scope='function')
def db():
    return connect_to_database()

@pytest.fixture
def user(db):
    return create_some_user_here(db)

OK. Chtěl bych, aby test končil rollbackem a nic po sobě nenechal.

@pytest.yield_fixture(scope='function')
def db():
    db = connect_to_database()
    db.begin()
    yield db
    db.rollback()
    db.close()

Za pozornost stojí parametr scope. Tímto parametrem se určuje, kde všude se má fixture cachovat. Pokud řeknu „function“, říkám tím, aby ve všech fixturách, které potřebuje jeden test, byla stejná instance databáze. Tím mohu pracovat v jedné transakci a rollbacknout. Pokud bych neurčil scope, každá fixture by měla jiné spojení s databází a nešlo by využít transakcí.

Tohle je na fixturách moc hezké. Mohou záviset sami na sobě a příprava i čistka jsou na jednom místě vedle sebe. Plus fixtury mohou být sdílené přes všechny testy a vytvářejí se pouze, pokud jsou opravdu potřeba daným testem. Osobně mi to přijde skvělé a mnohem přehlednější.

Zní dobře. Je možné se připojit k databázi jednou a pro každý test jen začít transakci?

@pytest.yield_fixture(scope='session')
def db_connection():
    db = connect_to_database()
    yield db
    db.close()

@pytest.yield_fixture(scope='function')
def db(db_connection):
    db_connection.begin()
    yield db_connection
    db_connection.rollback()

Fixtury lze skládat jak je libo. Jediné pravidlo je, že nelze, logicky, z většího scope záviset na menším. Tedy například ve fixture db_connection nelze záviset například na fixture se scope „function“. Protože by se pak tato fixture musela v db_connection nacachovat pro všechny testy, což by mohlo mít nežádoucí následky.

Mimochodem v reportu testu uvidíte, jaká instance fixtury do testu vstupovala. To je proč se hodí vidět i ty adresy instancí v paměti. Lze pak jednoduše zkontroloval, zda chybu nezpůsobuje špatná konfigurace fixtur.

Parametry

Super. Chtěl bych vyzkoušet otestování s dvěma typy uživatelů – zaregistrovaný a jednorázový nákup.

@pytest.fixture(params=['registred', 'unregistred'])
def user(request, db):
    return create_some_user_here(db, request.param)

Takhle málo stačí a všechny testy, kde se využívá fixture user, se provedou dvakrát. V reportu testu je to opět hezky vidět.

================================= FAILURES ================================
-------------------------- test_order[registred] --------------------------

user = 'registred'

    def test_order(user):
>       assert 0
E       assert 0

t.py:11: AssertionError
------------------------- test_order[unregistred] -------------------------

user = 'unregistred'

    def test_order(user):
>       assert 0
E       assert 0

t.py:11: AssertionError
========================= 2 failed in 0.01 seconds =======================

Skvělé! Bude to fungovat i když bych chtěl testovat více produktů?

No jasně! Pak se test provede pro každou kombinaci. Samozřejmě lze parametrizovat i fixtury využité jinými fixtury. V našem případě například db_connection může vracet pokaždé jinou konexi – jednou na MySQL, podruhé třeba na PostgreSQL.

Dříve testovat všechny kombinace bylo peklo. S pytestem napíšete jeden test, jednu fixture a nastavíte parametry.

V některém testu bych chtěl mít ale nějaká specifická data. Třeba v jednom testu bych chtěl, aby se mi vytvořil produkt s určitou cenou, kdežto pro ostatní případy stačí cokoliv. V unittestu lze tohle snadno, ale tady se předává jen název fixtury.

I tohle lze zařídit a běžně využíváme. Dělá se to pomocí speciálního parametru request, už jsme ho viděli výše při předávání parametrů. Je to vlastně podobná technika.

@pytest.fixture
def user(request):
    return create_some_user_here(request.function.user_name)


def test(user):
    # ...
test.user_name = 'Some name'

V requestu naleznete také atribut module či cls. Ve skutečnosti je tam toho ještě víc, mrkněte do dokumentace.

Abychom si tohle usnadnili, udělali jsme si hezký dekorátor a pomocnou funkci na mergování těchto dat. Pak vytvoření záznamů do databáze máme jednou pro všechny testy, i když každý test potřebuje trochu jiná data.

def use_data(**data):
    def wrapper(func):
        for key, value in data.items():
            setattr(func, key, value)
        return func
    return wrapper

def get_data(request, attribute_name, default_data={}):
    data = default_data.copy()
    data.update(getattr(request.module, attribute_name, {}))
    data.update(getattr(request.cls, attribute_name, {}))
    data.update(getattr(request.function, attribute_name, {}))
    return data

Poté je použití velice jednoduché a můžete definovat na modulu i třídě najednou. Data se zmergují do sebe logicky, jak by se očekávalo (modul má nejnižší prioritu a funkce přebíjí vše).

@pytest.fixture
def user(request):
    data = get_data(request, 'user_data')
    return create_some_user_here(data)

user_data = {'username': 'test', 'name': 'name'}

class UserTest:
    user_data = {'email': 'test@example.com'}

    @use_data(user_data={'name': 'Some name'})
    def test(user):
	# user.username = 'test'
	# user.email = 'test@example.com'
	# user.name = 'Some name'

Naše pomocná metoda je trochu chytřejší a dokáže pracovat i se seznamem, takže snadno můžeme říct, že uživatelů chceme pro test víc. Ještě ale ladíme detaily. Jakmile vyladíme, velká šance, že na PyPI přibude balíček. Nebo pull request do samotného pytestu. :-)

EDIT: Balíček už existuje a je k nalezení pod názvem pytest-data.

Označování testů

Půjdeme dál. Je velice vhodné mít tzv. smoke testy. Pár testů, které musí vždy projít a je jistota, že základní věci budou určitě fungovat. S pytestem velice snadno.

@pytest.mark.smoketest
def test():
    pass

Hotovo. Nyní stačí spouštět buď všechny jako doposud, nebo s parametrem -m: py.test -m smoketest. Toto označení v pytestu není, takto jste si vlastně vytvořili vlastní označení testů a můžete si je označit jak chcete.

Co překlepy?

Aby nedocházelo k chybám, lze si marky zaregistrovat, případně nikdo nebrání si vytvořit mark a importovat si ho. Lze označit také pouze jeden parametr v parametrizovaném testu.

U nás jsme si to obohatili o vlastní command line option. Udělali jsme pro jistotu opak – místo označování důležitých testů označujeme nedůležité. Tak nezapomene na nějaký důležitý test. Díky tomuto spuštění testů je raz dva a integrační tool se pak postará o kompletní funkčnost. Mimochodem dříve jsme měli jednotkové a integrační testy oddělené – nyní je máme vedle sebe a integrační je označen jako slow.

slow = pytest.mark.slow

def pytest_addoption(parser):
    parser.addoption('--skip-slow-tests', action='store_true', help='skip slow tests')

def pytest_runtest_setup(item):
    if 'slow' in item.keywords and item.config.getoption('--skip-slow-tests'):
        pytest.skip('Skipping slow test (used param --skip-slow-tests)')

Jaké testy jsou ale pomalé? Easy peasy. Stačí přidat argument --duration. Pokud zavolám například py.test --duration 10, na konci testu pytest vyreportuje 10 nejpomalejších míst. Záměrně neříkám testů, ale míst, protože report je rozdělen na část setup, call a teardown. Což se hodí; například při testování se Seleniem je inicializace prohlížeče náročné a vidím aspoň tak, kolik reálně test dlouho trval. První test v řadě tak není hendikepován.

Zašli jsme ještě dál a začali jsme označovat integrační testy, které využívají cizí službu. Pracujeme na obchodní aplikaci, která komunikuje téměř se všemi službami Seznamu. To už je velká šance, že některé rozhraní v testu na chvíli spadne. Pokud taková situace nastane, popadá nám několik integračních testů, což akorát v reportu překáží.

Dnes si testy označíme a pokud potřebné rozhraní není dostupné, test se přeskočí. Stále vidíme, pokud se tak stalo, ale nyní se nemusíme prokousávat například sto spadlými testy. A stačí tak málo…

def pytest_runtest_setup(item):
    marker = item.get_market('uses')
    if marker:
        if not any(is_not_available(arg) for arg in marker.args):
            pytest.skip('Skipping test requiring {}'.format(marker.args))

@pytest.mark.uses('penezenka', 'sklik')
def test():
    # ...

Pluginy

Ještě nekončíme. Unittesty či nose mají argument fast fail indikující, aby běh testů spadl po první chybě. Je tu super věc na ladění. Pytest má ještě lepší: last fail.

Last fail je ve skutečnosti plugin. Instalace je však velice snadná a přidá do command line další argument --lf. A cože to dělá? Spustí jen ty testy, které selhaly v posledním běhu.

Modelová situace: spustíte všechny testy. Trvají 10 minut, spadne jich 20. Provedete opravu, ale nechcete čekat celých deset minut. Nevadí, přidáte argument --lf a spustí se pouze těch 20 rozbitých. Po dokončení většinu opravíte, ale ještě 6 rozbitých testů zbývá. Provedete opravu a zase spustíte s argumentem --lf. Spustí se pouze těch posledních šest. Jakmile se dostanete na nulu, spustíte opět všechny a ověříte, zda jste nerozbili něco dalšího. Mezitím ale můžete na kafe, protože velice pravděpodobně máte hotovo.

Šikovných pluginů je celá řada. Například si můžete udělat testy na PEP8. Existují pluginy pro Flask, Django, … Taky instafail na okamžitý report. Podívejte se do dokumentace a internetu.

Propojení s unittesty

Celé to je děsně super. Ale už mám v mém projektu unittesty…

Nevadí. Pouze zahoďte unittestový či nosový runner, nakonfigurujte si pytest dle potřeb a můžete začít psát testy v novém stylu. U nás máme nakombinované Seleniové testy a žádná velká dřina to nebyla. Pytest si s unittesty poradí, dokonce lze do unittestů dostat fixture. Takto jsem tam dostal Seleniový driver:

class BaseTestCase(unittest.TestCase):
    @pytest.fixutre(autouse=True)
    def _set_driver(self, driver):
        self.driver = driver

Parametr autouse u fixture je taky dobrá věc. Využili jsme například taky při vytváření virtuálního displeje pro Selenium. Není pak potřeba někde explicitně psát, aby se taková fixture všude použila.

OK. Ale pročti si dokumentaci. Tímhle to nekončí!