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 detaillierte 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](http://docs.pytest.org/en/latest/builtin.html#builtin-fixtures-function-arguments). 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!