Test-Funktionen schreiben

assert-Anweisungen

Wenn ihr Testfunktionen schreibt, ist die normale pytest-assert-Anweisung euer wichtigstes Werkzeug. Die Einfachheit dieser Anweisung bringt viele Entwickler dazu, pytest gegenüber anderen Frameworks zu bevorzugen. Im Folgenden findet ihr eine Liste einiger assert-Formen und assert-Hilfsfunktionen von Unittest:

pytest

unittest

assert something

assertTrue(something)

assert not something

assertFalse(something)

assert x == y

assertEqual(x, y)

assert x != y

assertNotEqual(x, y)

assert x <= y

assertLessEqual(x, y)

assert x is None

assertIsNone(x)

assert x is not None

assertIsNotNone(x)

Mit pytest könnt ihr assert AUSDRUCK mit einem beliebigen Ausdruck verwenden. Wenn der Ausdruck bei einer Konvertierung in einen booleschen Wert zu False ausgewertet würde, würde der Test fehlschlagen.

pytest enthält eine Funktion namens assert rewriting, die assert-Aufrufe abfängt und sie durch etwas ersetzt, das euch mehr darüber sagen kann, warum eure Annahmen fehlgeschlagen sind. Sehen wir uns an, wie hilfreich dieses Rewriting ist, indem wir uns einen fehlgeschlagenen assert-Test ansehen:

def test_equality_fails():
    i1 = Item("do something", "veit")
    i2 = Item("do something else", "veit")
    assert i1 == i2

Dieser Test schlägt fehl, aber interessant sind die Traceback-Informationen:

$ pytest tests/test_item_fails.py
============================= test session starts ==============================
…
collected 1 item

tests/test_item_fails.py F                                               [100%]

=================================== FAILURES ===================================
_____________________________ test_equality_fails ______________________________

    def test_equality_fails():
        i1 = Item("do something", "veit")
        i2 = Item("do something else", "veit.schiele")
>       assert i1 == i2
E       AssertionError: assert Item(summary=...odo', id=None) == Item(summary=...odo', id=None)
E
E         Omitting 1 identical items, use -vv to show
E         Differing attributes:
E         ['summary', 'owner']
E
E         Drill down into differing attribute summary:
E           summary: 'do something' != 'do something else'...
E
E         ...Full output truncated (8 lines hidden), use '-vv' to show

tests/test_item_fails.py:7: AssertionError
=========================== short test summary info ============================
FAILED tests/test_item_fails.py::test_equality_fails - AssertionError: assert Item(summary=...odo', id=None) == Item(summary=...od...
============================== 1 failed in 0.03s ===============================

Das sind eine Menge Informationen:

Für jeden fehlgeschlagenen Test wird die genaue Zeile des Fehlers mit einem > angezeigt, das auf den Fehler verweist.

Die E-Zeilen zeigen Ihnen zusätzliche Informationen über den assert-Fehler, damit ihr herausfinden könnt, was falsch gelaufen ist. Ich habe absichtlich zwei Fehlanpassungen in test_equality_fails() eingegeben, aber nur die erste wurde angezeigt. Versuchen wir es noch einmal mit der -vv-Option, wie in der Fehlermeldung vorgeschlagen:

$ pytest -vv tests/test_item_fails.py
============================= test session starts ==============================
…
collected 1 item

tests/test_item_fails.py::test_equality_fails FAILED                     [100%]

=================================== FAILURES ===================================
_____________________________ test_equality_fails ______________________________

    def test_equality_fails():
        i1 = Item("do something", "veit")
        i2 = Item("do something else", "veit.schiele")
>       assert i1 == i2
E       AssertionError: assert Item(summary='do something', owner='veit', state='todo', id=None) == Item(summary='do something else', owner='veit.schiele', state='todo', id=None)
E
E         Matching attributes:
E         ['state']
E         Differing attributes:
E         ['summary', 'owner']
E
E         Drill down into differing attribute summary:
E           summary: 'do something' != 'do something else'
E           - do something else
E           ?             -----
E           + do something
E
E         Drill down into differing attribute owner:
E           owner: 'veit' != 'veit.schiele'
E           - veit.schiele
E           + veit

tests/test_item_fails.py:7: AssertionError
=========================== short test summary info ============================
FAILED tests/test_item_fails.py::test_equality_fails - AssertionError: assert Item(summary='do something', owner='veit', state='to...
============================== 1 failed in 0.03s ===============================

pytest hat genau aufgelistet, welche Attribute übereinstimmen und welche nicht. Zudem wurden die genauen Abweichungen hervorgehoben.

Zum Vergleich können wir uns anzeigen lassen, was Python bei assert-Fehlern anzeigt. Um den Test direkt von Python aus aufrufen zu können, müssen wir einen Block am Ende von tests/test_item_fails.py einfügen:

if __name__ == "__main__":
    test_equality_fails()

Wenn wir den Test nun mit Python durchführen, erhalten wir folgendes Ergebnis:

python tests/test_item_fails.py
Traceback (most recent call last):
  File "tests/test_item_fails.py", line 11, in <module>
    test_equality_fails()
  File "tests/test_item_fails.py", line 7, in test_equality_fails
    assert i1 == i2
           ^^^^^^^^
AssertionError

Das sagt uns nicht viel. Die pytest-Ausgabe gibt uns viel mehr Informationen darüber, warum unsere Annahmen fehlgeschlagen sind.

Fehlschlagen mit pytest.fail() und Exceptions

Das Fehlschlagen von Behauptungen ist die Hauptursache dafür, dass Tests fehlgeschlagen. Aber das ist nicht der einzige Weg. Ein Test schlägt auch fehl, wenn es eine nicht abgefangene Exceptions gibt. Das kann passieren, wenn

  • eine assert-Anweisung fehlschlägt, was zu einer AssertionError-Exception führt,

  • der Testcode pytest.fail() aufruft, was zu einer Exception führt, oder

  • eine andere Exception ausgelöst wird.

Obwohl jede Exception einen Test fehlschlagen lassen kann, ziehe ich es vor, assert zu verwenden. In seltenen Fällen, in denen assert nicht geeignet ist, verwende ich meist pytest.fail().

Hier ist ein Beispiel für die Verwendung der Funktion fail() von pytest, um einen Test explizit fehlschlagen zu lassen:

def test_with_fail():
    i1 = Item("do something", "veit")
    i2 = Item("do something else", "veit.schiele")
    if i1 != i2:
        pytest.fail("The items are not identical!")

Die Ausgabe sieht wie folgt aus:

pytest tests/test_item_fails.py
============================= test session starts ==============================
…
collected 1 item

tests/test_item_fails.py F                                               [100%]

=================================== FAILURES ===================================
________________________________ test_with_fail ________________________________

    def test_with_fail():
        i1 = Item("do something", "veit")
        i2 = Item("do something else", "veit.schiele")
        if i1 != i2:
>           pytest.fail("The items are not identical!")
E           Failed: The items are not identical!

tests/test_item_fails.py:10: Failed
=========================== short test summary info ============================
FAILED tests/test_item_fails.py::test_with_fail - Failed: The items are not identical!
============================== 1 failed in 0.03s ===============================

Beim Aufruf von pytest.fail() oder dem Auslösen einer Exception, erhalten wir nicht das von pytest angebotene assert-Rewriting. Es gibt jedoch sinnvolle Gelegenheiten, pytest.fail() zu verwenden, wie z.B. in einem assertion-Hilfsprogramm.

Schreiben von assertion-Hilfsfunktionen

Eine assertion-Hilfsfunktion dient dazu, eine komplizierte assertion-Prüfung zu verpacken. Ein Beispiel: Die Datenklasse Item ist so eingerichtet, dass zwei Items mit unterschiedlichen IDs trotzdem Gleichheit berichten. Wenn wir eine strengere Prüfung wünschen, könnten wir eine Hilfsfunktion namens assert_ident wie folgt schreiben:

import pytest

from items import Item


def assert_ident(i1: Item, i2: Item):
    __tracebackhide__ = True
    assert i1 == i2
    if i1.id != i2.id:
        pytest.fail(f"The IDs do not match: {i1.id} != {i2.id}")


def test_ident():
    i1 = Item("something to do", id=42)
    i2 = Item("something to do", id=42)
    assert_ident(i1, i2)


def test_ident_fail():
    i1 = Item("something to do", id=42)
    i2 = Item("something to do", id=43)
    assert_ident(i1, i2)

Die assert_ident-Funktion setzt __tracebackhide__ = True. Die Folge ist, dass fehlgeschlagene Tests nicht in den Traceback aufgenommen werden. Das normale assert i1 == i2 wird dann verwendet, um alles außer id auf Gleichheit zu prüfen.

Schließlich werden die IDs überprüft pytest.fail() verwendet, um den Test mit einer hilfreichen Meldung fehlschlagen zu lassen. Schauen wir uns an, wie das nach der Ausführung aussieht:

$ pytest tests/test_helper.py
============================= test session starts ==============================
…
collected 2 items

tests/test_helper.py .F                                                  [100%]

=================================== FAILURES ===================================
_______________________________ test_ident_fail ________________________________

    def test_ident_fail():
        i1 = Item("something to do", id=42)
        i2 = Item("something to do", id=43)
>       assert_ident(i1, i2)
E       Failed: The IDs do not match: 42 != 43

tests/test_helper.py:22: Failed
=========================== short test summary info ============================
FAILED tests/test_helper.py::test_ident_fail - Failed: The IDs do not match: 42 != 43
========================= 1 failed, 1 passed in 0.03s ==========================

Testen auf erwartete Exceptions

Wir haben uns angesehen, wie jede Exception einen Test zum Scheitern bringen kann. Was aber, wenn ein Teil des Codes, den wir testen, eine Exception auslösen soll? Hierfür verwenden wir pytest.raises(), um auf erwartete Exceptions zu testen. Ein Beispiel hierfür wäre die Items-API, die eine ItemsDB-Klasse hat, die ein Pfadargument benötigt.

from items.api import ItemsDB


def test_db_exists():
    ItemsDB()
$ pytest --tb=short tests/test_db.py
============================= test session starts ==============================
…
collected 1 item

tests/test_db.py F                                                       [100%]

=================================== FAILURES ===================================
________________________________ test_db_exists ________________________________
tests/test_db.py:5: in test_db_exists
    ItemsDB()
E   TypeError: ItemsDB.__init__() missing 1 required positional argument: 'db_path'
=========================== short test summary info ============================
FAILED tests/test_db.py::test_db_exists - TypeError: ItemsDB.__init__() missing 1 required positional argument: 'db_p...
============================== 1 failed in 0.03s ===============================

Hier habe ich das kürzere Traceback-Format --tb=short verwendet, weil wir nicht den vollständigen Traceback sehen müssen, um herauszufinden, welche Exception ausgelöst wurde.

Die Exception TypeError erscheint sinnvoll, da der Fehler beim Versuch auftritt, den benutzerdefinierten ItemsDB-Typ zu initialisieren. Wir können einen Test schreiben, um sicherzustellen, dass diese Exception ausgelöst wird, etwa so:

import pytest

from items.api import ItemsDB


def test_db_exists():
    with pytest.raises(TypeError):
        ItemsDB()

Die Anweisung with pytest.raises(TypeError): besagt, dass der nächste Codeblock eine TypeError-Exception auslösen soll. Wenn keine Ausnahme ausgelöst wird oder eine andere Ausnahme ausgelöst wird, schlägt der Test fehl.

Wir haben gerade in test_db_exists() den Typ der Exception überprüft. Wir können auch überprüfen, ob die Meldung korrekt ist, oder jeden anderen Aspekt der Exception, wie z.B. zusätzliche Parameter:

def test_db_exists():
    match_regex = "missing 1 .* positional argument"
    with pytest.raises(TypeError, match=match_regex):
        ItemsDB()

oder

def test_db_exists():
    with pytest.raises(TypeError) as exc_info:
        ItemsDB()
    expected = "missing 1 required positional argument"
    assert expected in str(exc_info.value)