Testing mit Pytest

Wer in Python seinen Code testen möchte, sollte sich einmal Pytest als Alternative zu Unittest anschauen.

Pytest ist ein Testing-Framework für Python, welches das Testen sehr einfach und ohne viel Boilerplate ermöglicht. Seine Vorteile liegen in der Einfachheit, automatischem Test-Discovery, modularer Fixtures und intelligenter Fehlerausgabe.

Dieser Blogbeitrag soll einen Überblick und einfachen Einstieg in das Testen mit Pytest geben. Er gibt zuerst eine Übersicht über die Verwendung von Pytest, geht danach darauf ein, wie man Ressourcen für die Tests zur Verfügung stellt und schliesst mit ein paar Tipps und Tricks ab.

Einführung

Tests schreiben

Tests können mit Pytest ganz einfach geschrieben werden. Man benötigt lediglich das Python eigene Assert-Statement. Damit Pytest den Test auch findet, stellt man der Test-Funktion das Präfix test_ voran:

def test_blog():  
    assert 1 > 0

Mehr ist zum Testen nicht nötig!

Tests starten

Nach der Installation via pip (pip install pytest) oder durch den Paketmanager seiner Wahl kann Pytest grundsätzlich auf zwei Arten gestartet werden:

  • mit dem Dateinamen als Argument: pytest foo.py
  • ohne Argument. In diesem Fall sucht Pytest alle Dateien mit dem Format *_test.py oder test_*.py und testet diese

Output

Bei erfolgreichem Test erhält man eine Übersicht über das Test-Resultat:

======= test session starts =======
platform linux -- Python 3.5.2, pytest-3.0.5, py-1.4.31, pluggy-0.4.0  
rootdir: /home/sh/pytest_blog, inifile:  
plugins: factoryboy-1.1.6  
collected 1 items 

first.py .

======= 1 passed in 0.00 seconds =======

Pytest entfaltet seine Stärke jedoch erst so richtig mit Tests, die nicht funktionieren, indem nach Möglichkeit eine detailierte Ausgabe gemacht wird, was genau nicht bestanden hat. Sehr schön am Beispiel von zwei Listen, die nicht die selben Elemente enthalten:

def test_list():  
    assert [1, 2] == [1, 2, 3]

Resultiert in

    def test_list():
>       assert [1, 2] == [1, 2, 3]
E       assert [1, 2] == [1, 2, 3]  
E         Right contains more items, first extra item: 3  
E         Use -v to get the full diff

list.py:4: AssertionError  
=============== 1 failed in 0.01 seconds ================

Wie man sieht, liefert Pytest gleich das Element, welches den Fehler verursacht!

Wichtige Optionen

Einige wichtige Optionen von Pytest, die wesentlich beim Gebrauch von Pytest helfen, sind die folgenden:

  • -s Verhindert das Capturing von Input/Output. Sehr wichtig, da man den Output seiner Print-Statements im normalen Modus nicht sieht oder der Debugger ansonsten nicht gestartet werden kann
  • -k <string> Dient zum Filtern von Tests. Startet dabei nur diejenigen Tests, die string als Substring enthalten
  • -x Bricht beim ersten fehlgeschlagenen Test ab

Über die Datei pytest.ini können Standardwerte für Pytest konfiguriert werden.

Fixtures

Oftmals benötigen Tests irgendwelche Ressourcen, die zum Testen zur Verfügung stehen müssen. Beispiele hierfür sind Datenbankverbindungen, Config-Files, der Browser für UI-Tests etc. Pytest bietet dafür das Konzept von Fixtures. Man kann sich Fixtures ein wenig wie die setUp()- und tearDown()-Funktionen von bekannten Unit-Test-Frameworks vorstellen. Jedoch können Pytest-Fixtures viel dynamischer pro Test geladen werden: um eine Fixture in einem Test zu verweden, kann sie lediglich als Argument in der Funktion angegeben werden:

def test_something_fancy(browser):  
    browser.visit(foo)  # browser ist nun eine geladene fixture für diesen test

Um eine Fixture zu erstellen, wird der Decorator @pytest.fixture verwendet. Der benötigte Wert wird mit yield zurückgegeben. So ist es möglich, dass nach dem yield-Statement die Fixture wieder abgebaut werden kann. Ein Beispiel zur Klärung:

 1    import os
 2    import sqlite3
 3    
 4    import pytest
 5    
 6    @pytest.fixture(scope='function')
 7    def db(tmpdir):
 8        file = os.path.join(tmpdir.strpath, "test.db")
 9    
10        conn = sqlite3.connect(file)  
11        conn.execute("CREATE TABLE blog (id, title, text)")  
12  
13        yield conn  
14  
15        conn.close()  
16  
17    def test_entry_creation(db):  
18        query = ("INSERT INTO blog "  
19                 "(id, title, text)"  
20                 "VALUES (?, ?, ?)")  
21        values = (1,  
22                  "PyTest",  
23                  "Dies ist ein Blogeintrag")  
24  
25        db.execute(query, values)  

Die Fixture stellt hier eine DB-Verbindung für den Test zur Verfügung und erstellt auch gleich eine Tabelle, die zum Testen nötig ist.

Interessant sind Zeilen 6 und 7: Auf Zeile 6 wird mittels Parameter scope festgelegt, wie häufig eine Fixture ausgeführt wird. Zur Verfügung stehen function, class, module und session. Wählt man z.B. den Scope module, wird die Fixture für das gesamte Modul nur einmal erstellt und anschliessend für jeden Test wiederverwendet. Dies ist sehr praktisch bei Ressourcen, die nicht unbedingt bei jedem Test neu erstellt werden müssen und somit wiederverwendet werden können.

Auf Zeile 7 wird eine weitere Fixture in unserer Fixture geladen, was ohne weiteres möglich ist. In Falle dieses Beispiels ist dies eine Fixture, die von Pytest mitgeliefert wird. Sie gibt ein temporäres Verzeichnis zurück das für jeden Test-Aufruf einzigartig ist.

Weitere wichtige Features

Tests parametrisieren

Mit dem Decorator pytest.mark.parametrize kann man Tests mit verschiedenen Parametern generieren. Für ein Beispiel erweitern wir den test_entry_creation Test von oben:

@pytest.mark.parametrize("id,title,text", [
    (1, "House Stark", "Winter is coming"),
    (2, "House Lannister", "Hear me Roar"),
    (3, "House Martell", "Unbowed, Unbent, Unbroken")
])
def test_parametrized_entry_creation(id, title, text, db):  
    query = ("INSERT INTO blog "
             "(id, title, text)"
             "VALUES (?, ?, ?)")

    values = (id, title, text)

    db.execute(query, values)

So werden nun drei Tests generieren, jeder mit einem anderen Set von Parametern.

Skippen, failen und markieren

Tests können auf verschiedene Arten markiert werden:

  • @pytest.mark.skip(reason="Not implemented yet") bringt Pytest dazu, den Test zu überspringen
  • @pytest.mark.skipif(not os.environ.get('CI', False)) bringt Pytest dazu, den Test zu überpspringen, wenn die Bedingung erfüllt ist
  • @pytest.mark.xfail(reason="Can't work yet") erwartet, dass ein Test scheitert
  • @pytest.mark.abc sonstiger Marker. Tests können so beim Aufruf gefiltert werden. Beispiel: starte alle Tests, die mit abc markiert sind: pytest -m abc. Um alle Tests zu starten, die nicht mit abc markiert sind, kann der not Operator benutzt werden: pytest -m 'not abc'

Exceptions erwarten

Wenn Exceptions aus dem getesteten Code erwartet werden, wird dies in Pytest mit einem ContextManager gemacht:

def test_exception():  
    with pytest.raises(Exception):
        raise(Exception)

Autouse von Fixtures

Zu guter Letzt, um eine Fixture automatisch in jedem Test zur Verfügung zu stellen, kann dies als Parameter im Decorator übergeben werden. Folgender Code könnte ein Beispiel dafür sein, dass bei jedem Test im Browser ein Login geschieht:

@pytest.fixture(autouse=True)
def session(login_page, browser):  
    login_page.login()
    yield
    browser.delete_all_cookies()

Zu guter Letzt...

Für Pytest existieren zum Zeitpunkt von diesem Beitrag etwas über 260 Plugins. Sie erweitern Pytest um jenste Sachen, von witzigen Erweiterungen wie das emoji Plugin, welches die Test-Ausgabe etwas spassiger macht, über erweiterte Checks wie Import-Reihenfolge (Isort) und Syntax Linting mit Flake8, zu Erweiterungen wie die Integration von Selenium oder Splinter. Wem also ein Feature in Pytest fehlt, sollte definitiv ein Blick in die Plugin-Liste werfen - es lohnt sich!

Alles in allem, Pytest macht das Testen so einfach, dass es keine Ausreden für das Vernachlässigen von Tests gibt!

Pallando

Pallando is the tech blogger/wizard at Adfinis SyGroup. He is one of the Istari, who were sent by the angelic Valar to aid the Elves and Men in their struggle against the Dark Lord Sauron.

comments powered by Disqus