Ein Test ist Code, der Code ausführt. Wenn Sie mit der Entwicklung einer neuen Funktion für Ihr Python-Projekt beginnen, können Sie die Anforderungen als Code formalisieren. Damit dokumentieren Sie nicht nur die Art und Weise, wie der Code Ihrer Implementierung verwendet werden soll, sondern Sie können auch alle Tests automatisch ausführen lassen, um sicherzustellen, dass Ihr Code immer den Anforderungen entspricht. Ein solches Tool, das Ihnen dabei hilft, ist pytest und es ist wahrscheinlich das beliebteste Testwerkzeug im Python-Universum.
Nehmen wir an, Sie haben eine Funktion geschrieben, die eine E-Mail-Adresse validiert. Beachten Sie, dass wir es hier einfach halten und keine regulären Ausdrücke oder DNS-Tests für die Überprüfung von E-Mail-Adressen verwenden. Stattdessen stellen wir nur sicher, dass die zu prüfende Zeichenkette genau ein @-Zeichen und nur lateinische Zeichen, Zahlen und die Zeichen ., - und _ enthält.
import string
def is_valid_email_address(s):
s = s.lower()
parts = s.split('@')
if len(parts) != 2:
# Not exactly one at-sign
return False
allowed = set(string.ascii_lowercase + string.digits + '.-_')
for part in parts:
if not set(part) <= allowed:
# Characters other than the allowed ones are found
return False
return True
Jetzt haben wir einige Assertionen für unseren Code. Wir behaupten zum Beispiel, dass diese E-Mail-Adressen gültig sind:
test@example.orguser123@subdomain.example.orgjohn.doe@email.example.org
Andererseits würden wir erwarten, dass unsere Funktion für E-Mail-Adressen wie diese den Wert False zurückgibt:
not valid@example.org - enthält platzjohn.doe fehlendes @ zeichenjohn,doe@example.org enthält komma
Wir können überprüfen, ob sich unsere Funktion tatsächlich so verhält, wie wir es erwarten:
print(is_valid_email_address('test@example.org')) # True
print(is_valid_email_address('user123@subdomain.example.org')) # True
print(is_valid_email_address('john.doe@email.example.org')) # True
print(is_valid_email_address('not valid@example.org')) # False
print(is_valid_email_address('john.doe')) # False
print(is_valid_email_address('john,doe@example.org')) # False
Diese E-Mail-Adressbeispiele, die wir uns ausdenken, werden Testfälle genannt. Für jeden Testfall erwarten wir ein bestimmtes Ergebnis. Ein Tool wie pytest kann dabei helfen, das Testen dieser Behauptungen zu automatisieren. Das Aufschreiben dieser Behauptungen kann Ihnen helfen,:
- zu dokumentieren, wie Ihr Code verwendet werden soll
- sicherzustellen, dass zukünftige Änderungen nicht andere Teile Ihrer Software beschädigen
- über mögliche Randfälle Ihrer Funktionalitäten nachzudenken
Um dies zu erreichen, erstellen wir einfach eine neue Datei für alle unsere Tests und fügen dort ein paar Funktionen ein.
def test_regular_email_validates():
assert is_valid_email_address('test@example.org')
assert is_valid_email_address('user123@subdomain.example.org')
assert is_valid_email_address('john.doe@email.example.org')
def test_valid_email_has_one_at_sign():
assert not is_valid_email_address('john.doe')
def test_valid_email_has_only_allowed_chars():
assert not is_valid_email_address('john,doe@example.org')
assert not is_valid_email_address('not valid@example.org')
Wir haben also zwei Dateien in unserem Projektverzeichnis: validator.py und test_validator.py.
Wir können nun einfach pytest von der Kommandozeile aus starten. Die Ausgabe sollte in etwa so aussehen:
======================= test session starts =========================
platform darwin -- Python 3.9.6, pytest-7.0.1, pluggy-1.0.0
rootdir: /Users/bascodes/Code/blogworkspace/pytest-example
collected 3 items
test_validator.py ... [100%]
======================== 3 passed in 0.01s ==========================
Hier informiert uns pytest, dass es drei Testfunktionen in test_validator.py gefunden hat und dass alle diese Funktionen bestanden wurden (wie durch die drei Punkte ... angezeigt).
Die Anzeige 100% gibt uns ein gutes Gefühl, da wir sicher sind, dass unser Validator wie erwartet funktioniert. Wie in der Einleitung beschrieben, ist die Validierungsfunktion jedoch alles andere als perfekt. Und das gilt auch für unsere Testfälle. Auch ohne DNS-Tests würden wir eine E-Mail-Adresse wie john.doe@example als gültig markieren, während eine Adresse wie john.doe+abc@gmail.com als ungültig markiert würde.
Fügen wir diese Testfälle nun zu unserer test_validator.py hinzu
...
def test_valid_email_can_have_plus_sign():
assert is_valid_email_address('john.doe+abc@gmail.com')
def test_valid_email_must_have_a_tld():
assert not is_valid_email_address('john.doe@example')
Wenn wir pytest erneut ausführen, sehen wir fehlgeschlagene Tests:
============================= test session starts ==============================
platform darwin -- Python 3.9.6, pytest-7.0.1, pluggy-1.0.0
rootdir: /Users/bascodes/Code/blogworkspace/pytest-example
collected 5 items
test_validator.py ...FF [100%]
=================================== FAILURES ===================================
_____________________ test_valid_email_can_have_plus_sign ______________________
def test_valid_email_can_have_plus_sign():
> assert is_valid_email_address('john.doe+abc@gmail.com')
E AssertionError: assert False
E + where False = is_valid_email_address('john.doe+abc@gmail.com')
test_validator.py:17: AssertionError
_______________________test_valid_email_must_have_a_tld_______________________
def test_valid_email_must_have_a_tld():
> assert not is_valid_email_address('john.doe@example')
E AssertionError: assert not True
E + where True = is_valid_email_address('john.doe@example')
test_validator.py:20: AssertionError
=========================== short test summary info ============================
FAILED test_validator.py::test_valid_email_can_have_plus_sign - AssertionErro...
FAILED test_validator.py::test_valid_email_must_have_a_tld - AssertionError: ...
========================= 2 failed, 3 passed in 0.05s ==========================
Beachten Sie, dass wir zusätzlich zu unseren drei ... Punkten zwei FF erhalten haben, um anzuzeigen, dass zwei Testfunktionen fehlgeschlagen sind.
Außerdem erhalten wir einen neuen Abschnitt FAILURES in unserer Ausgabe, der detailliert erklärt, an welchem Punkt unser Test fehlgeschlagen ist. Das ist bei der Fehlersuche sehr hilfreich.
Unser kleines Validierungsbeispiel zeigt, wie wichtig die Entwicklung von Tests ist.
Wir haben zuerst unsere Validierungsfunktion geschrieben und uns dann einige Testfälle dafür ausgedacht. Bald stellten wir fest, dass diese Testfälle keineswegs umfassend sind. Stattdessen haben wir einige wesentliche Aspekte der Validierung einer E-Mail-Adresse ausgelassen.
Vielleicht haben Sie schon von Test Driven Development (TDD) gehört, das für das genaue Gegenteil eintritt: Sie schreiben zuerst Ihre Testfälle, um die Anforderungen zu erfüllen, und beginnen erst dann mit der Implementierung einer Funktion, wenn Sie das Gefühl haben, alle Testfälle abgedeckt zu haben. Diese Denkweise war schon immer eine gute Idee, hat aber im Laufe der Zeit noch mehr an Bedeutung gewonnen, da die Komplexität von Softwareprojekten zugenommen hat.
Ich werde demnächst einen weiteren Blogbeitrag über TDD schreiben, in dem ich dieses Thema ausführlich behandeln werde.
Normalerweise ist die Einrichtung eines Projekts viel komplizierter als nur eine einzelne Datei mit einer Validierungsfunktion darin.
Vielleicht haben Sie eine Python-Paketstruktur für Ihr Projekt, oder Ihr Code ist von externen Abhängigkeiten wie einer Datenbank abhängig.
Vielleicht haben Sie den Begriff Fixture schon in anderen Zusammenhängen verwendet. Beim Django-Web-Framework zum Beispiel beziehen sich Fixtures auf eine Sammlung von Ausgangsdaten, die in die Datenbank geladen werden. Im Kontext von pytest beziehen sich Fixtures jedoch nur auf Funktionen, die von pytest vor und/oder nach den eigentlichen Testfunktionen ausgeführt werden.
Wir können solche Funktionen mit dem Dekorator pytest.fixture() erstellen. Wir tun dies zunächst innerhalb der Datei test_validator.py.
import pytest
@pytest.fixture()
def database_environment():
setup_database()
yield
teardown_database()
Beachten Sie, dass das Einrichten der Datenbank und das Abbauen der Datenbank in derselben Fixture erfolgen. Das Schlüsselwort yield bezeichnet den Teil, in dem pytest die eigentlichen Tests ausführt.
Damit die Fixture tatsächlich von einem Ihrer Tests verwendet wird, fügen Sie einfach den Namen der Fixture als Argument hinzu, etwa so (noch in test_validator.py):
def test_world(database_environment):
assert 1 == 1
Anstatt yield zu verwenden, kann eine Fixture-Funktion auch beliebige Werte zurückgeben:
import pytest
@pytest.fixture()
def my_fruit():
return "apple"
Auch hier anfordern Sie die Halterung von einer Testfunktion, indem Sie den Namen der Halterung als Parameter angeben:
def test_fruit(my_fruit):
assert my_fruit == "apple"
pytest kann seine projektspezifische Konfiguration aus einer dieser Dateien lesen:
pytest.initox.inisetup.cfg
Welche Datei Sie verwenden sollten, hängt davon ab, welche anderen Werkzeuge Sie in Ihrem Projekt verwenden. Wenn Sie Ihr Projekt gepackt haben, sollten Sie die Datei setup.cfg verwenden. Wenn Sie Tox verwenden, um Ihren Code in verschiedenen Umgebungen zu testen, können Sie die pytest-Konfiguration in die Datei tox.ini eintragen. Die Datei pytest.ini kann verwendet werden, wenn Sie außer pytest kein weiteres Tool verwenden möchten.
Die Konfigurationsdatei sieht für jeden dieser drei Dateitypen weitgehend gleich aus:
[pytest]
addopts = -rsxX -l --tb=short --strict
Wenn Sie die Datei setup.cfg verwenden, besteht der einzige Unterschied darin, dass Sie dem Abschnitt [pytest] das Wort tool: voranstellen müssen, etwa so:
[tool:pytest]
addopts = -rsxX -l --tb=short --strict
Jeder Ordner mit Testdateien kann eine Datei conftest.py enthalten, die von pytest gelesen wird. Dies ist ein guter Ort, um Ihre benutzerdefinierten Fixtures zu speichern, da diese von verschiedenen Testdateien gemeinsam genutzt werden können.
Die Datei(en) conftest.py können das Verhalten von pytest projektspezifisch verändern.
Neben gemeinsam genutzten Fixtures können Sie auch externe Hooks und Plugins oder Modifikatoren für den PATH platzieren, die von pytest verwendet werden, um Tests und Implementierungscode zu finden.
Während der Entwicklung, vor allem wenn Sie Ihre Tests vor der Implementierung schreiben, kann pytest ein nützliches Werkzeug zum Debuggen sein.
Wir werden uns die nützlichsten Befehlszeilenoptionen ansehen.
Wenn Sie nur einen bestimmten Test ausführen möchten, können Sie diesen Test über die Datei test_, in der er sich befindet, und den Namen der Funktion referenzieren:
pytest test_validator.py::test_regular_email_validates
Manchmal möchten Sie nur eine Liste der Testsammlung haben, anstatt alle Testfunktionen auszuführen.
pytest --collect-only
============================= test session starts ==============================
platform darwin -- Python 3.9.6, pytest-7.0.1, pluggy-1.0.0
rootdir: /Users/bascodes/Code/blogworkspace/pytest-example
collected 5 items
<Module test_validator.py>
<Function test_regular_email_validates>
<Function test_valid_email_has_one_at_sign>
<Function test_valid_email_has_only_allowed_chars>
<Function test_valid_email_can_have_plus_sign>
<Function test_valid_email_must_have_a_tld>
========================== 5 tests collected in 0.01s ==========================
Sie können pytest zwingen, nach einem fehlgeschlagenen Test keine weiteren Tests mehr auszuführen:
Wenn Sie nur die Tests ausführen möchten, die beim letzten Mal fehlgeschlagen sind, können Sie dies mit dem Flag --lf tun:
Wenn wir eine komplexere Testfunktion mit einigen lokalen Variablen einrichten, können wir pytest anweisen, diese lokalen Variablen mit dem Flag -l anzuzeigen.
Lassen Sie uns unsere Testfunktion so umschreiben:
...
def test_valid_email_can_have_plus_sign():
email = 'john.doe+abc@gmail.com'
assert is_valid_email_address('john.doe+abc@gmail.com')
...
Dann,
erhalten wir diese Ausgabe:
============================= test session starts ==============================
platform darwin -- Python 3.9.6, pytest-7.0.1, pluggy-1.0.0
rootdir: /Users/bascodes/Code/blogworkspace/pytest-example
collected 5 items
test_validator.py ...FF [100%]
=================================== FAILURES ===================================
_____________________ test_valid_email_can_have_plus_sign ______________________
def test_valid_email_can_have_plus_sign():
email = 'john.doe+abc@gmail.com'
> assert is_valid_email_address('john.doe+abc@gmail.com')
E AssertionError: assert False
E + where False = is_valid_email_address('john.doe+abc@gmail.com')
email = 'john.doe+abc@gmail.com'
test_validator.py:18: AssertionError
_______________________test_valid_email_must_have_a_tld_______________________
def test_valid_email_must_have_a_tld():
> assert not is_valid_email_address('john.doe@example')
E AssertionError: assert not True
E + where True = is_valid_email_address('john.doe@example')
test_validator.py:21: AssertionError
=========================== short test summary info ============================
FAILED test_validator.py::test_valid_email_can_have_plus_sign - AssertionErro...
FAILED test_validator.py::test_valid_email_must_have_a_tld - AssertionError: ...
========================= 2 failed, 3 passed in 0.09s ==========================
Es gibt einen Kommandozeilen-Debugger namens pdb, der in Python integriert ist. Sie können pytest verwenden, um den Code Ihrer Testfunktion zu debuggen.
Wenn Sie pytest mit --pdb starten, wird eine pdb Debugging-Sitzung gestartet, sobald in Ihrem Test eine Ausnahme ausgelöst wird. In den meisten Fällen ist dies nicht besonders nützlich, da Sie vielleicht jede Codezeile vor der ausgelösten Ausnahme untersuchen möchten.
Eine weitere Option ist das --trace Flag für pytest, das einen Haltepunkt bei der ersten Zeile jeder Testfunktion setzt. Dies kann etwas unpraktisch werden, wenn Sie viele Tests haben. Für Debugging-Zwecke ist daher eine gute Kombination --lf --trace, die eine Debug-Sitzung mit pdb am Anfang des letzten fehlgeschlagenen Tests starten würde:
pytest --lf --trace
============================= test session starts ==============================
platform darwin -- Python 3.9.6, pytest-7.0.1, pluggy-1.0.0
rootdir: /Users/bascodes/Code/blogworkspace/pytest-example, configfile: pytest.ini
collected 2 items
run-last-failure: rerun previous 2 failures
test_validator.py
>>>>>>>>>>>>>>>>>>>> PDB runcall (IO-capturing turned off) >>>>>>>>>>>>>>>>>>>>>
> /Users/bascodes/Code/blogworkspace/pytest-example/test_validator.py(17)test_valid_email_can_have_plus_sign()
-> email = 'john.doe+abc@gmail.com'
(Pdb)
In modernen Softwareprojekten wird die Software nach den Grundsätzen der Test Driven Development (testgetriebene Entwicklung) entwickelt und über eine Continuous Integration / Continuous Deployment-Pipeline mit automatisierten Tests ausgeliefert.
Typischerweise werden Übertragungen an den main/master-Zweig abgelehnt, wenn nicht alle Testfunktionen erfolgreich sind.
Wenn Sie mehr über die Verwendung von pytest in einer CI/CD-Umgebung erfahren möchten, bleiben Sie dran, denn ich plane einen neuen Artikel zu diesem Thema.
Die offizielle Dokumentation für pytest finden Sie hier: https://docs.pytest.org/en/stable/
Eine kurze Anmerkung:
Dies ist eine Wiederveröffentlichung des Artikels von Bas Steins, die mit seiner Genehmigung erfolgt. Ich empfehle einen Besuch seiner Website oder X @bascodes.