Testen und Dokumentieren gleichzeitig: Ein Blick auf Pythons »doctest«

Tests direkt in der Quellcodedokumentation verfassen? Pythons »doctest« macht's möglich. Besonders praktisch ist das, wenn man im Rahmen der Quellcodedokumentation sowieso Beispiele mit angeben möchte. Damit spart man viel Zeit und die Beispiele funktionieren garantiert.

Mit diesem Blogpost betrachten wir »doctest« und damit die Kombination von Doku und Testfällen. Das eignet sich in vielen Fällen sogar für die testgetriebene Entwicklung (TDD), was wir an einem einfachen Beispiel ausprobieren wollen.

Installation von »doctest« und Python Virtual Environment

»doctest« ist Bestandteil der Python-Standard-Bibliothek und wird bei jeder Installation in den Bordmitteln mitgeliefert. Es ist nicht notwendig, irgend etwas nachzuinstallieren.

Da wir aber im weiteren Verlauf des Artikels noch Code-Coverage-Analysen bei der doctest-Verwendung anschauen wollen, bietet es sich an, ein Python Virtual Environment für die Versuche zu erstellen, so dass man die nötigen Pakete nicht global für das ganze System installieren muss. Je nach System/Distribution erhält man ein Virtual Environment mit dem Namen test-env mit einem der folgenden Befehle, vorausgesetzt, das Paket virtualenv ist installiert:

virtualenv test-env
virtualenv-3.5 test-env
pyvenv-3.5 test-env
venv-3.5 test-env

Mehr Details zu virtualenv findet man hier. Vergesst nicht, die Umgebung nun auch zu aktivieren:

# Unter Linux:
. test-env/bin/activate
# Unter Windows:
test-env\Scripts\activate

Dokumentation und Methodenrumpf aus den Anforderungen an die Methode

Stellen wir uns vor, wir sollen eine Methode addieren implementieren. Dazu haben wir diese Spezifikation erhalten:

Die Methode »addieren« soll 2-5 Summanden annehmen und deren Summe zurückliefern. Werden weniger als zwei Summanden angegeben, so ist mit einem ValueError zu antworten. Werden mehr als 5 Summanden angegeben, so ist ebenfalls mit einem ValueError zu antworten. Wenn einer der Summanden nicht vom Typ int oder float ist, soll er ignoriert werden. Bleiben dabei weniger als zwei Summanden übrig, ist wieder mit einem ValueError zu antworten.

Mit diesen Angaben können wir nun zunächst diesen Quellcode in einer Datei mathe.py erzeugen:

def addiere(*args):
    """
    Die Methode »addieren« soll 2-5 Summanden annehmen und deren Summe zurückliefern.

    Werden weniger als zwei Summanden angegeben, so ist mit einem `ValueError` zu antworten.

    Werden mehr als 5 Summanden angegeben, so ist ebenfalls mit einem `ValueError` zu antworten.

    Wenn einer der Summanden nicht vom Typ `int` oder `float` ist, soll er ignoriert werden. 
    Bleiben dabei weniger als zwei Summanden übrig, ist wieder mit einem `ValueError` zu antworten.

    :param args: Summanden
    """
    pass

Erste Ausführung von »doctest«

Nun ist es an der Zeit, zum ersten Mal »doctest« auszuführen. Dazu wird dieser Befehl eingegeben:

python -m doctest -v mathe.py

-m steht hier für »ein Modul ausführen« und -v für »ausführliche Ausgaben«. mathe.py ist unsere soeben angelegte Datei. Auf das -v kann man später verzichten, aber hier wird es zu Demonstrationszwecken verwendet. Wir halten diese Antwort:

2 items had no tests:
    mathe
    mathe.addiere
0 tests in 2 items.
0 passed and 0 failed.
Test passed.

Wir sehen: »doctest« hat weder auf Modulebene (mathe), noch auf Methodenebene (mathe.addiere) Tests zur Ausführung gefunden. Allerdings gibt es hier auch keinen Fehlschlag. Höchste Zeit, an die Testdefinition zu gehen.

Definition der Doctests

Wir haben schon die Anforderung als Quellcodedokumentation an der Methode hinterlassen. In diese Dokumentation können wir nun die Tests einfügen. Dies geschieht vom Handwerklichen genau so, wie wir es in einer Python Shell machen würden. Ein Aufruf beginnt immer mit >>>. Die Folgezeile enthält das erwartete Ergebnis. Eine Verzweigung im Code wird mit ... am Zeilenanfang eingeleitet – ein Beispiel dazu folgt später, da wir zu so einem Fall in diesem Minimalbeispiel nicht kommen.

Fangen wir mit der ersten Teilanforderung an: »Die Methode »addieren« soll 2-5 Summanden annehmen und deren Summe zurückliefern.« Hieraus ergeben sich gleich mehrere Tests:

  • 2 Summanden: addiere(1, 2) == 3
  • 3 Summanden: addiere(1, 2, 3) == 6
  • 4 Summanden: addiere(1, 2, 3, 4) == 10
  • 5 Summanden: addiere(1, 2, 3, 4, 5) == 15

Natürlich reichen die Beispiele im Folgenden nicht für einen »echten« Test einer solchen Methode aus – denn andere wichtige Merkmale wie Grenzwerte etc. bleiben in den Beispielen völlig unbeachtet.

Aus diesen Erkenntnissen können wir also diese Tests aufbauen:

def addiere(*args):
    """
    Die Methode »addieren« soll 2-5 Summanden annehmen und deren Summe zurückliefern.

    >>> addiere(1, 2)
    3

    >>> addiere(1, 2, 3)
    6

    >>> addiere(1, 2, 3, 4)
    10

    >>> addiere(1, 2, 3, 4, 5)
    15

    Werden weniger als zwei Summanden angegeben, so ist mit einem `ValueError` zu antworten.

    Werden mehr als 5 Summanden angegeben, so ist ebenfalls mit einem `ValueError` zu antworten.

    Wenn einer der Summanden nicht vom Typ `int` oder `float` ist, soll er ignoriert werden.
    Bleiben dabei weniger als zwei Summanden übrig, ist wieder mit einem `ValueError` zu antworten.

    :param args: Summanden
    """
    pass

Nun führen wir die Tests mit python -m doctest mathe.py (wir können hier schon – wie angedroht – auf das -v verzichten) erneut aus. Wir erhalten diese Ausgabe:

**********************************************************************
File "/home/juergen/work/blog/mathe.py", line 5, in mathe.addiere
Failed example:
    addiere(1, 2)
Expected:
    3
Got nothing
**********************************************************************
File "/home/juergen/work/blog/mathe.py", line 8, in mathe.addiere
Failed example:
    addiere(1, 2, 3)
Expected:
    6
Got nothing
**********************************************************************
File "/home/juergen/work/blog/mathe.py", line 11, in mathe.addiere
Failed example:
    addiere(1, 2, 3, 4)
Expected:
    10
Got nothing
**********************************************************************
File "/home/juergen/work/blog/mathe.py", line 14, in mathe.addiere
Failed example:
    addiere(1, 2, 3, 4, 5)
Expected:
    15
Got nothing
**********************************************************************
1 items had failures:
   4 of   4 in mathe.addiere
***Test Failed*** 4 failures.

Alle unsere Tests sind fehlgeschlagen, aber nichts anderes haben wir erwartet. Die Methode ist schließlich nicht implementiert. Wir können aber nun auch für die anderen Anforderungen Tests bauen. Gleich bei der nächsten Anforderung (»Werden weniger als zwei Summanden angegeben, so ist mit einem ValueError zu antworten.«) wird es interessant, da wir das Erscheinen eines Errors herausfordern wollen. Doch auch dies geht prima mit Doctests. Hier kann man bei der Ergebniserwartung sich auf die wesentlichen Angaben beschränken und unwichtige Teile einfach per ... auslassen. Bei dem Error sieht das dann so aus (der besseren Lesbarkeit halber habe ich das mal gekürzt):

def addiere(*args):
    """
    (schnipp)

    Werden weniger als zwei Summanden angegeben, so ist mit einem `ValueError` zu antworten.

    >>> addiere()
    Traceback (most recent call last):
    ...
    ValueError

    >>> addiere(1)
    Traceback (most recent call last):
    ...
    ValueError

    (schnapp)

    :param args: Summanden
    """
    pass

Auch für die nächste Anforderung (»Werden mehr als 5 Summanden angegeben, so ist ebenfalls mit einem ValueError zu antworten.«) muss wieder auf einen Error getestet werden (wieder gekürzt):

def addiere(*args):
    """
    (schnipp)

    Werden mehr als 5 Summanden angegeben, so ist ebenfalls mit einem `ValueError` zu antworten.

    >>> addiere(1, 2, 3, 4, 5, 6)
    Traceback (most recent call last):
    ...
    ValueError

    >>> addiere(1, 2, 3, 4, 5, 6, 7, 8, 9)
    Traceback (most recent call last):
    ...
    ValueError

    (schnapp)    

    :param args: Summanden
    """
    pass

Für den letzten Block an Anforderungen benötigen wir sowohl Tests, die einen Wert liefern, als auch Tests, die auf das Auftreten eines Errors ausgelegt sind (gekürzt):

def addiere(*args):
    """
    (schnipp)

    Wenn einer der Summanden nicht vom Typ `int` oder `float` ist, soll er ignoriert werden.
    Bleiben dabei weniger als zwei Summanden übrig, ist wieder mit einem `ValueError` zu antworten.

    >>> addiere(1, 2, '3', 4)
    7

    >>> addiere('a', 2, 'b', 3)
    5

    >>> addiere(1, 2.5, 3, 'a')
    6.5

    >>> addiere('a', 'b')
    Traceback (most recent call last):
    ...
    ValueError

    >>> addiere('', 4)
    Traceback (most recent call last):
    ...
    ValueError

    :param args: Summanden
    """
    pass

Komplett und ungekürzt sieht unsere mathe.py nun so aus:

def addiere(*args):
    """
    Die Methode »addieren« soll 2-5 Summanden annehmen und deren Summe zurückliefern.

    >>> addiere(1, 2)
    3

    >>> addiere(1, 2, 3)
    6

    >>> addiere(1, 2, 3, 4)
    10

    >>> addiere(1, 2, 3, 4, 5)
    15

    Werden weniger als zwei Summanden angegeben, so ist mit einem `ValueError` zu antworten.

    >>> addiere()
    Traceback (most recent call last):
    ...
    ValueError

    >>> addiere(1)
    Traceback (most recent call last):
    ...
    ValueError

    Werden mehr als 5 Summanden angegeben, so ist ebenfalls mit einem `ValueError` zu antworten.

    >>> addiere(1, 2, 3, 4, 5, 6)
    Traceback (most recent call last):
    ...
    ValueError

    >>> addiere(1, 2, 3, 4, 5, 6, 7, 8, 9)
    Traceback (most recent call last):
    ...
    ValueError

    Wenn einer der Summanden nicht vom Typ `int` oder `float` ist, soll er ignoriert werden.
    Bleiben dabei weniger als zwei Summanden übrig, ist wieder mit einem `ValueError` zu antworten.

    >>> addiere(1, 2, '3', 4)
    7

    >>> addiere('a', 2, 'b', 3)
    5

    >>> addiere(1, 2.5, 3, 'a')
    6.5

    >>> addiere('a', 'b')
    Traceback (most recent call last):
    ...
    ValueError

    >>> addiere('', 4)
    Traceback (most recent call last):
    ...
    ValueError

    :param args: Summanden
    """
    pass

Die erneute Ausführung von python -m doctest mathe.py fördert nun erwartungsgemäß eine Menge an Fehlern zu Tage:

**********************************************************************
File "/home/juergen/work/blog/mathe.py", line 5, in mathe.addiere
Failed example:
    addiere(1, 2)
Expected:
    3
Got nothing
**********************************************************************
File "/home/juergen/work/blog/mathe.py", line 8, in mathe.addiere
Failed example:
    addiere(1, 2, 3)
Expected:
    6
Got nothing
**********************************************************************
File "/home/juergen/work/blog/mathe.py", line 11, in mathe.addiere
Failed example:
    addiere(1, 2, 3, 4)
Expected:
    10
Got nothing
**********************************************************************
File "/home/juergen/work/blog/mathe.py", line 14, in mathe.addiere
Failed example:
    addiere(1, 2, 3, 4, 5)
Expected:
    15
Got nothing
**********************************************************************
File "/home/juergen/work/blog/mathe.py", line 19, in mathe.addiere
Failed example:
    addiere()
Expected:
    Traceback (most recent call last):
    ...
    ValueError
Got nothing
**********************************************************************
File "/home/juergen/work/blog/mathe.py", line 24, in mathe.addiere
Failed example:
    addiere(1)
Expected:
    Traceback (most recent call last):
    ...
    ValueError
Got nothing
**********************************************************************
File "/home/juergen/work/blog/mathe.py", line 31, in mathe.addiere
Failed example:
    addiere(1, 2, 3, 4, 5, 6)
Expected:
    Traceback (most recent call last):
    ...
    ValueError
Got nothing
**********************************************************************
File "/home/juergen/work/blog/mathe.py", line 36, in mathe.addiere
Failed example:
    addiere(1, 2, 3, 4, 5, 6, 7, 8, 9)
Expected:
    Traceback (most recent call last):
    ...
    ValueError
Got nothing
**********************************************************************
File "/home/juergen/work/blog/mathe.py", line 44, in mathe.addiere
Failed example:
    addiere(1, 2, '3', 4)
Expected:
    7
Got nothing
**********************************************************************
File "/home/juergen/work/blog/mathe.py", line 47, in mathe.addiere
Failed example:
    addiere('a', 2, 'b', 3)
Expected:
    5
Got nothing
**********************************************************************
File "/home/juergen/work/blog/mathe.py", line 50, in mathe.addiere
Failed example:
    addiere(1, 2.5, 3, 'a')
Expected:
    6.5
Got nothing
**********************************************************************
File "/home/juergen/work/blog/mathe.py", line 53, in mathe.addiere
Failed example:
    addiere('a', 'b')
Expected:
    Traceback (most recent call last):
    ...
    ValueError
Got nothing
**********************************************************************
File "/home/juergen/work/blog/mathe.py", line 58, in mathe.addiere
Failed example:
    addiere('', 4)
Expected:
    Traceback (most recent call last):
    ...
    ValueError
Got nothing
**********************************************************************
1 items had failures:
  13 of  13 in mathe.addiere
***Test Failed*** 13 failures.

Implementierung der Methode

Die Implementierung der Methode ist nun kein Geheimnis mehr, so dass ich nicht weiter darauf eingehe. Insgesamt sieht also unsere math.py nun so aus:

def addiere(*args):
    """
    Die Methode »addieren« soll 2-5 Summanden annehmen und deren Summe zurückliefern.

    >>> addiere(1, 2)
    3

    >>> addiere(1, 2, 3)
    6

    >>> addiere(1, 2, 3, 4)
    10

    >>> addiere(1, 2, 3, 4, 5)
    15

    Werden weniger als zwei Summanden angegeben, so ist mit einem `ValueError` zu antworten.

    >>> addiere()
    Traceback (most recent call last):
    ...
    ValueError

    >>> addiere(1)
    Traceback (most recent call last):
    ...
    ValueError

    Werden mehr als 5 Summanden angegeben, so ist ebenfalls mit einem `ValueError` zu antworten.

    >>> addiere(1, 2, 3, 4, 5, 6)
    Traceback (most recent call last):
    ...
    ValueError

    >>> addiere(1, 2, 3, 4, 5, 6, 7, 8, 9)
    Traceback (most recent call last):
    ...
    ValueError

    Wenn einer der Summanden nicht vom Typ `int` oder `float` ist, soll er ignoriert werden.
    Bleiben dabei weniger als zwei Summanden übrig, ist wieder mit einem `ValueError` zu antworten.

    >>> addiere(1, 2, '3', 4)
    7

    >>> addiere('a', 2, 'b', 3)
    5

    >>> addiere(1, 2.5, 3, 'a')
    6.5

    >>> addiere('a', 'b')
    Traceback (most recent call last):
    ...
    ValueError

    >>> addiere('', 4)
    Traceback (most recent call last):
    ...
    ValueError

    :param args: Summanden
    """
    gueltige_summanden = list(filter(lambda x: any([isinstance(x, int), isinstance(x, float)]), args))
    if 2 <= len(gueltige_summanden) <= 5:
        return sum(gueltige_summanden)
    raise ValueError

Wenn wir nun python -m doctest mathe.py ausführen, erhalten wir keine Ausgabe mehr, denn ohne -v meldet sich »doctest« nur dann, wenn es ein Problem gibt. Also probieren wir es nochmal mit -v: python -m doctest -v mathe.py. Wir erhalten nun dieses positive Feedback:

Trying:
    addiere(1, 2)
Expecting:
    3
ok
Trying:
    addiere(1, 2, 3)
Expecting:
    6
ok
Trying:
    addiere(1, 2, 3, 4)
Expecting:
    10
ok
Trying:
    addiere(1, 2, 3, 4, 5)
Expecting:
    15
ok
Trying:
    addiere()
Expecting:
    Traceback (most recent call last):
    ...
    ValueError
ok
Trying:
    addiere(1)
Expecting:
    Traceback (most recent call last):
    ...
    ValueError
ok
Trying:
    addiere(1, 2, 3, 4, 5, 6)
Expecting:
    Traceback (most recent call last):
    ...
    ValueError
ok
Trying:
    addiere(1, 2, 3, 4, 5, 6, 7, 8, 9)
Expecting:
    Traceback (most recent call last):
    ...
    ValueError
ok
Trying:
    addiere(1, 2, '3', 4)
Expecting:
    7
ok
Trying:
    addiere('a', 2, 'b', 3)
Expecting:
    5
ok
Trying:
    addiere(1, 2.5, 3, 'a')
Expecting:
    6.5
ok
Trying:
    addiere('a', 'b')
Expecting:
    Traceback (most recent call last):
    ...
    ValueError
ok
Trying:
    addiere('', 4)
Expecting:
    Traceback (most recent call last):
    ...
    ValueError
ok
1 items had no tests:
    mathe
1 items passed all tests:
  13 tests in mathe.addiere
13 tests in 2 items.
13 passed and 0 failed.
Test passed.

Code Coverage Bestimmung

Die Bestimmung der Code Coverage kann – wie schon von anderen Testsuites gewohnt – mit coverage.py erfolgen. Dazu muss in unserem Virtual Environment zunächst coverage.py installiert werden:

pip install coverage

Anschließend werden mittels coverage run die Doctests ausgeführt:

coverage run --branch -m doctest mathe.py

Der Parameter --branch weißt coverage.py an, neben der reinen Anweisungsabdeckung auch die Pfadabdeckung zu messen. -m ruft wieder ein Modul auf. Die Resultate können wir anschließend mit coverage report anschauen:

Name       Stmts   Miss Branch BrPart  Cover
--------------------------------------------
mathe.py       5      0      4      0   100%

Traumhaftes Ergebnis, oder? Wir haben auch nichts anderes erwartet.

Integration von Doctests in andere Testsuites

Natürlich ist »doctest« als alleiniges Testwerkzeug ungeeignet. Spätestens dann, wenn komplexere Sachverhalte zu testen sind, kommt man um (ergänzende) Unittests nicht herum. Mein Lieblings-Testrunner py.test kann zum Glück bei seinen Testläufen Doctests einfach mit berücksichtigen. So kann man die Vorteile von Doctests nutzen und muss nicht daran denken, sie extra auszuführen.

Komplexeres Beispiel mit ... am Zeilenanfang und mehr als einer Anweisung

Die bisher verwendeten Beispiele sind trivial. Doch was ist, wenn man mehr als eine Anweisung braucht, die Anweisung sich über mehrere Zeilen erstreckt oder eine Bedingung (oder andere Verzweigung) genutzt wird? Zunächst sollte man sich dann überlegen, ob der Doctest hier das richtige Werkzeug ist oder ob man nicht doch besser einen »echten« Unittest implementieren sollte. Hier ein Beispiel für einen solchen Test:

"""
Examples from the Blog Post:

The not-working reason for this module:

>>> x = None
>>> if all([x is not None, len(x) > 10]):
...     print('ok')
... else:
...     print('nah')
Traceback (most recent call last):
...
TypeError: object of type 'NoneType' has no len()

The working example with `lazy_all`:

>>> x = None
>>> if lazy_all([lambda: x is not None, lambda: len(x) > 10]):
...     print('ok')
... else:
...     print('nah')
nah
"""

Das Beispiel stammt aus der Datei laa.py, welche für das LAA-Paket aus diesem Blogpost implementiert wurde. Der vollständige Quellcode des Moduls ist über Github verfügbar – mit noch einigen weiteren Doctests.

Die Beispieldatei mathe.py steht als Gist zum Download bereit.


Kommentare

Noch keine Kommentare.