Inhalt

Einführung in das Testen von Python-Code mit PyTest

PyTest ist eine beliebte Testbibliothek für Python. Es handelt sich um ein einfaches, benutzerfreundliches Test-Framework, mit dem Sie Tests für Ihren Python-Code schreiben und ausführen können. Hier finden Sie eine kurze Einführung in die Verwendung von PyTest

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.

Es dreht sich alles um assert

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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
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:

Andererseits würden wir erwarten, dass unsere Funktion für E-Mail-Adressen wie diese den Wert False zurückgibt:

Wir können überprüfen, ob sich unsere Funktion tatsächlich so verhält, wie wir es erwarten:

1
2
3
4
5
6
print(is_valid_email_address('[email protected]'))               # True
print(is_valid_email_address('[email protected]'))  # True
print(is_valid_email_address('[email protected]'))     # True
print(is_valid_email_address('not [email protected]'))          # False
print(is_valid_email_address('john.doe'))                       # False
print(is_valid_email_address('john,[email protected]'))           # 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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
def test_regular_email_validates():
    assert is_valid_email_address('[email protected]')
    assert is_valid_email_address('[email protected]')
    assert is_valid_email_address('[email protected]')

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,[email protected]')
    assert not is_valid_email_address('not [email protected]')

Laufende Tests

Einfaches Beispiel

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:

1
2
3
4
5
6
7
8
======================= 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 [email protected] als gültig markieren, während eine Adresse wie [email protected] als ungültig markiert würde.

Fügen wir diese Testfälle nun zu unserer test_validator.py hinzu

1
2
3
4
5
6
...
def test_valid_email_can_have_plus_sign():
    assert is_valid_email_address('[email protected]')

def test_valid_email_must_have_a_tld():
    assert not is_valid_email_address('[email protected]')

Wenn wir pytest erneut ausführen, sehen wir fehlgeschlagene Tests:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
============================= 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('[email protected]')
E       AssertionError: assert False
E        +  where False = is_valid_email_address('[email protected]')

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('[email protected]')
E       AssertionError: assert not True
E        +  where True = is_valid_email_address('[email protected]')

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.

Entwerfen von Tests

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.

Konfiguration

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.

Fixtures

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.

Einrichten und Abreißen

Wir können solche Funktionen mit dem Dekorator pytest.fixture() erstellen. Wir tun dies zunächst innerhalb der Datei test_validator.py.

1
2
3
4
5
6
7
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):

1
2
def test_world(database_environment):
    assert 1 == 1

Daten von Fixtures erhalten

Anstatt yield zu verwenden, kann eine Fixture-Funktion auch beliebige Werte zurückgeben:

1
2
3
4
5
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:

1
2
def test_fruit(my_fruit):
    assert my_fruit == "apple"

Konfigurationsdateien

pytest kann seine projektspezifische Konfiguration aus einer dieser Dateien lesen:

  • pytest.ini
  • tox.ini
  • setup.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.ini und tox.ini verwenden

1
2
[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:

1
2
[tool:pytest]
addopts = ​-rsxX -l --tb=short --strict​

conftest.py

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.

CLI / PDB

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.

Nur einen Test ausführen

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:

1
pytest test_validator.py::test_regular_email_validates

Nur sammeln

Manchmal möchten Sie nur eine Liste der Testsammlung haben, anstatt alle Testfunktionen auszuführen.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
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 ==========================

Beenden beim ersten Fehler

Sie können pytest zwingen, nach einem fehlgeschlagenen Test keine weiteren Tests mehr auszuführen:

1
pytest -x

Nur den letzten fehlgeschlagenen Test ausfü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:

1
pytest --lf

Führen Sie alle Tests durch, aber die zuletzt fehlgeschlagenen Tests zuerst.

1
pytest --ff

Werte von lokalen Variablen in der Ausgabe anzeigen

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:

1
2
3
4
5
...
def test_valid_email_can_have_plus_sign():
    email = '[email protected]'
    assert is_valid_email_address('[email protected]')
...

Dann,

1
pytest -l

erhalten wir diese Ausgabe:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
============================= 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 = '[email protected]'
>       assert is_valid_email_address('[email protected]')
E       AssertionError: assert False
E        +  where False = is_valid_email_address('[email protected]')

email      = '[email protected]'

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('[email protected]')
E       AssertionError: assert not True
E        +  where True = is_valid_email_address('[email protected]')

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 ==========================

Verwendung von pytest mit einem Debugger

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
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 = '[email protected]'
(Pdb)

CI / CD

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.

Dokumentation

Die offizielle Dokumentation für pytest finden Sie hier: https://docs.pytest.org

Eine kurze Anmerkung:

Dies ist eine Wiederveröffentlichung des Originalartikels von Bas Steins mit seiner Genehmigung. Besuchen Sie seine Website für weitere Artikel und/oder folgen Sie ihm auf Twitter: @bascodes