Pytest Fixtures: Einführung, Nutzung & Organisation in deiner Testarchitektur

In meinem letzten Post habe ich darüber gesprochen, warum ich TDD praktiziere und warum ich meinen Lernfortschritt in Testing teilen möchte. Heute möchte ich darüber sprechen, was pytest Fixtures sind, wann ich sie verwende und wie du Fixtures testübergreifend verfügbar machen kannst.
Was sind pytest Fixtures?
Stell dir vor, du schreibst einen Test, der eine Funktion namens validate_user(user: User)
auf den Prüfstand stellt. Dazu brauchst du natürlich einen Benutzer. Angenommen der User ist wie folgt definiert:
from dataclasses import dataclass
@dataclass
class User:
name: str
email: str
In deiner Testfunktion würdest du den Benutzer anlegen und ihn dann deiner Testfunktion übergeben:
def test_valid_user():
user = User(name="Patrick", email="myemail@gmail.com")
result = validate_user(user)
assert result.is_valid == True
So weit, so gut. Wenn du eine weitere Funktion testen möchtest, die die Korrektheit der E-Mail überprüft, musst du eine neue Testfunktion schreiben, die den Benutzer erneut erstellt und testet. Hier kommen die Fixtures ins Spiel. Anstatt das zu machen, schreibst du eine Fixture, die dir einen User
zurückliefert.
Beispiel:
@pytest.fixture()
def user_fixture():
return User(name="Patrick", email="myemail@gmail.com")
def test_valid_user(user_fixture):
result = validate_user(user)
assert result.is_valid == True
def test_valid_email(user_fixture):
result = validate_email(user)
assert result.is_valid == True
Das ist eigentlich die Idee dahinter. Eine kleine Randnotiz: Beim Testen geht es nicht nur darum, die Testabdeckung zu erhöhen, sondern auch darum, Fehler zu finden. Das heißt, in einer realen Anwendung würde ich nicht nur mit einem Benutzerobjekt testen, sondern mit einem Pool von Benutzern mit unterschiedlichen Namens- und E-Mail-Kodierungen, bei denen ich sehen möchte, ob sich die Funktionen so verhalten, wie sie sollten. Unter anderem möchte ich natürlich auch testen, ob ein ungültiger Name oder eine ungültige Email zu einem false Ergebnis führt.
Wann sollte ich Fixtures verwenden?
Grob, ist das ja bereits aus dem Beispiel erkenntlich. pytest selbst beschreibt das Fixture den Kontext von Testfunktionen bestimmen. Das kann zum Beispiel der Kontext für eine Datenbank sein oder die Anreicherung deiner Testdaten. Ich benutze es sehr häufig für beides. Sobald ich merke, dass Daten innerhalb eines Tests auch in einem anderen Test verwendet werden können, mache ich daraus ein Fixture. Wer von einer anderen Sprache kommt, z.B. Java, wird sicherlich die Ähnlichkeit zur Setup/Tear Down Funktionalität sehen. pytest beschreibt den Vergleich auf ihrer Webseite sehr gut und besser als ich es könnte. Es sei erwähnt, dass pytest auch die Möglichkeit bietet, einen typischen Setup Tear Down Stil zu wählen.
Unterscheidung zwischen Error vs Failure
Normalerweise, wenn ein Test fehlschlägt oder eine Exception wirft, bekommt er den Status “failed”. Wenn jedoch eine Fixture eine Exception wirft, deklariert pytest dies als Error und nicht als “Failed”. pytest beschreibt, dass der Status Error dafür gedacht ist, mitzuteilen, dass pytest den eigentlichen Test gar nicht erst ausführen konnte und schon an einem Fixture gescheitert ist, von dem der Test abhängt. Error ist dafür reserviert. Hier ist eine weitere, recht prägnante, Erklärung auf StackOverflow.
Folgend ein Beispiel, welches in der append_first
Fixture eine Exception wirft:
import pytest
@pytest.fixture
def order():
return []
@pytest.fixture
def append_first(order):
raise Exception
order.append(1)
@pytest.fixture
def append_second(order, append_first):
order.extend([2])
@pytest.fixture(autouse=True)
def append_third(order, append_second):
order += [3]
def test_order(order):
assert order == [1, 2, 3]
Der Code ist von der pytest Dokumentation. Du kannst ihn dir von meinem GitHub Gist kopieren.

Das Ergebnis ist das folgende:

Du siehst den ausgeführten Test in PyCharm und dass ein Error angezeigt wird. Gleichzeitig wird der Test auch als failed gezählt, was nicht ganz der Idee von pytest entspricht. Es ist also besser, die Ausgabe von pytest zu lesen und nicht nur die Meldung der IDE.
Wie erstelle ich Test-übergreifende fixtures?
Fixtures können also innerhalb eines Moduls verwendet werden. Manchmal benötigt es aber auch modulübergreifende Fixtures, wie z.B. ein Fixture für eine Datenbank. Du hast vielleicht einen Wrapper für die Datenbankverbindung und möchtest diese Funktionalität testen oder du hast Funktionen, die indirekt die Datenbank verwenden. Eine Möglichkeit ist die conftest.py
. Die conftest.py
liegt flach in deinem Tests Ordner:
tests/
├── conftest.py # Contains a db_connection_fixture
├── test_module1.py
├── test_module2.py
Der Vorteil der conftest.py
ist, dass pytest eine automatische Discovery von Fixtures innerhalb dieser Datei zur Verfügung stellt. Die registrierten Fixtures stehen dann in allen Modulen auf gleicher Ebene oder darunter ohne Import zur Verfügung. Dies kann am Anfang etwas irreführend wirken, da man nicht weiß, woher die Funktionsparameter (Fixtures) kommen bzw. wo sie definiert sind. Zumindest bietet PyCharm nicht die Möglichkeit die Funktionsdefinition per Klick aufzurufen. Nachdem man aber weiß, dass die Fixtures in conftest.py
liegen, bleibt der Vorteil, dass das Modul sauberer wird, da sich die Importe nicht häufen.
Ordnerstruktur versus conftest.py?
Es ist auch möglich, einen Fixture-Ordner anzulegen und die Fixtures dort abzulegen. Das hilft bei der Modularisierung und ist bei vielen Fixtures auch übersichtlicher. Du kannst ein Modul mit db_fixtures oder ein Modul mit user_fixtures erstellen. In der conftest.py
kannst du das Modul importieren und sie werden ebenfalls von pytest discovery aufgenommen.
tests/
├── fixtures/
│ └── db_fixtures.py # Contains the db_connection fixture
├── conftest.py # Can import db_connection if needed
├── test_module1.py
├── test_module2.py
#In conftest.py:
from fixtures.db_fixtures import db_connection # Import for reuse
Bei einem überschaubaren Projekt würde ich immer zuerst die Fixtures in der conftest.py
definieren und erst dann modularisieren, wenn es unübersichtlich wird. Die conftest.py
ist nämlich auch für andere Themen wichtig.
Ein Beispiel für ein db_fixture
wäre übrigens so etwas:
@pytest.fixture(scope="module")
def db_connection():
client = db.get_client(URI)
db = db.get_database(client, env_config.DB)
client.drop_database(env_config.DB)
yield db
client.drop_database(env_config.DB)
client.close()
Das Schöne an diesem Fixture ist, dass es für den Scope eines Moduls gültig ist und erst nach der Abarbeitung eines Moduls wird nach dem yield
weitergemacht und die DB geschlossen. Dies hat den Vorteil, dass die Datenbank mit Daten von verschiedenen Funktionen gefüllt werden kann (z.B. einige Inserts und dann ein Delete).
Pytest Fixtures in Open-Source Projekten
Wenn ich selbst unsicher bin, schaue ich mir an, wie bekannte Open Source Projekte das machen. Dazu werfe ich gerne einen Blick auf Pydantic und Streamlit 🙂. Beide nutzen pytest.
Wie Pydantic Fixtures nutzt
Die Python-library Pydantic besteht nicht nur aus dem gleichnamigen Repository, sondern verwendet unter anderem auch pydantic-core, welches in Rust entwickelt wird. Daher sollte man beide Repositories betrachten, wenn man verstehen will, wie Pydantic testet.
Pydantic und pydantic-core definieren ihre Fixtures überwiegend direkt in den jeweiligen Testmodulen, anstatt eine zentrale conftest.py
zu verwenden. In Pydantic gibt es derzeit 21 Testdateien, in pydantic-core neun, in denen Fixtures verwendet werden. Die conftest.py
hat in beiden Projekten relativ wenige Funktionen und insgesamt in beiden Projekten nur fünf Fixtures. Diese Organisation der Tests folgt dem Prinzip der hohen Kohäsion (im engl. High Cohesion), da zusammengehörige Testbestandteile in gleichen Modulen gehalten werden.
Kurzer Ausflug: Definitiv neu für mich war, dass Pydantic, wie auch pydantic-core, eine library namens Hypothesis verwendet. Hypothesis bietet Property-based Testing und wird mit Hilfe des Decorators (@given
) der zu testenden Funktion hinzugefügt. Dabei muss beschrieben werden, welche Art/Typen von Werten erlaubt sind und Hypothesis generiert zufällige Werte. Hier ist ein Beispiel von pydantic-core, in dem verschiedene Datenobjekte erzeugt werden:
#pydantic-core: tests/test_hypothesis.py
@given(strategies.datetimes())
@pytest.mark.thread_unsafe
def test_datetime_datetime(datetime_schema, data):
assert datetime_schema.validate_python(data) == data
Ich kannte Hypothesis noch nicht, finde jedoch, dass es TDD sehr gut ergänzt und werde es selbst ausprobieren.
Wie Streamlit Fixture nutzt
Streamlit macht es etwas anders als Pydantic und verwendet eine Mischung aus e2e (End to End) Testing mit Playwright und unittests.
Für Playwright e2e_playwright
sind mehr als zehn Fixtures in der conftest.py
definiert. Die restlichen Fixtures für pytest, welche mindestens genau so viele sind, werden in den Modulen selbst definiert.
Für die Unit Tests ist die conftest.p
y unter /lib/tests/conftest.py
definiert. Sie enthält eine Fixture, der Rest ist in den Modulen selbst definiert. Wenn man das Verhältnis betrachtet, scheint es, dass mehr Gewicht auf die e2e Tests gelegt wird.
Auch hier ein kleiner Ausflug: Beim Lesen des Streamlits-Codes bin ich auf das Projekt testfixtures
gestoßen, das für temporäre Verzeichnisse verwendet wird. Hier der Link zu dem bisher noch eher unbekannten Repo.
Wenn du dich für die Architektur, den Aufbau und die Softwareprinzipien von Open Source interessierst, dann lass es mich wissen, damit ich in Zukunft mehr davon einfließen lassen kann!
Welche Pytest Fixture Parameter sind sinnvoll?
Die häufigsten Parameter, die ich beobachte, sind scope
, autouse
, params
und name
. Lass uns kurz darüber gehen:
Scope
Mit dem scope
wird die Gültigkeitsdauer des Fixture festgelegt. Der default ist “function”
, das bedeutet, dass der Fixture immer wieder für jede Testfunktion erneut aufgerufen wird. Die Alternativen sind “class”
, “module”
, “package”
und “session”
. Ich selbst verwende oft “module”
wie im Beispiel oben (db_connection
), so dass der Fixture einmal zu Beginn des Moduls aufgerufen wird. Das Prinzip des Aufrufs kann sich auch auf class, package oder session beschränken. Session habe ich auch schon öfters beobachtet und bedeutet der Fixture wird einmal für alle tests in der “session”
aufgerufen.
Autouse
Wird auch sehr häufig verwendet und bedeutet, dass der Fixture für Tests verfügbar ist, ohne dass er als Parameter übergeben werden muss.
@pytest.fixture(autouse=True)
def set_up_env():
os.environ["APP_ENV"] = "test"
#Dieser Fixture stellt für jeden Test die Umgebungsvariable
#APP_ENV mit dem Wert "test" bereit
Ein weiteres Beispiel wäre ein Fixture mit autouse
für einen Patch für einen Request. Allerdings benutze ich das kaum, da ich versuche, so wenig wie möglich zu mocken.
Name
name
ist ein interessanter Parameter. In Streamlit habe ich nicht viele Fixtures gesehen, die ihn verwenden, aber in Pydantic schon. Hier ein Ausschnitt:
@pytest.fixture(scope='module', name='DateModel')
def date_model_fixture():
class DateModel(BaseModel):
d: date
return DateModel
def test_date_parsing(DateModel, value, result):
if isinstance(result, Err):
with pytest.raises(ValidationError,
match=result.message_escaped()):
DateModel(d=value)
else:
assert DateModel(d=value).d == result
Die Funktionalitaet von name
ist, dass der fixture umbenannt wird in die Zuweisung. Im Fall von Pydantic zu DateModel
. Dadurch wird der Name der Fixture Methode von der Referenzierung entkoppelt. Damit ist recht eindeutig, dass ein DateModel
Object übergeben wird und mit diesem kann dann direkt in test_date_parsing
gearbeitet werden.
Params
Mit params
wird ein Fixture parametrisiert, so dass ein Test mit verschiedenen Parametern durchgeführt wird. Hier ein Beispiel, diesmal nicht von Pydantic:
@pytest.fixture(params=[1, 2, 3])
def number(request):
return request.param
def test_even(number):
assert number % 2 == 0
Letzte Gedanken und mein Fazit
pytest Fixtures gehören zu einer guten Testarchitektur. Du kannst sie in der conftest.py
, in einem separaten Ordner oder in dem zu testenden Modul definieren. Sobald ein Fixture modulübergreifend ist, definiere ich sie in der conftest.py
, da dort auch die automatische Discovery stattfindet. Die Open-Source Projekte Streamlit und Pydantic definieren ihre Fixtures überwiegend in den Modulen wo diese benötigt werden. Ein Konzept was unter dem Begriff High Cohesion bekannt ist.
Deine eigenen Tests, kannst du mit Property-based Testing durch Hypothesis erweitern. Das ist nicht direkt mit den Fixtures verbunden, aber ich fand es sehr hilfreich, es kennenzulernen.
Als Parameter verwende ich sehr gerne den scope und habe durch pydantic den Vorteil des name Parameters kennengelernt. Diesen würde ich gerne öfter verwenden, da ich pydantic sehr oft verwende und ich den Ansatz, den Namen des zurückgebenden Modells als Namen der Fixture zu verwenden, sehr gut finde.
Abschliessen ist zu sagen, dass pytest manchmal etwas magical wirkt, zum Beispiel durch die automatische discovery und autouse. Die Lernkurve ist dadurch etwas höher, aber dafür abstrahiert pytest auch Arbeit für uns.