Unit Tests mit Python

Finde den Fehler!
Kommentare

Zur Qualitätssicherung von Software ist Testen unverzichtbar. Manuelles Testen, wenngleich teilweise notwendig, ist zeitaufwändig, fehleranfällig und teuer und sollte daher möglichst reduziert werden. Wünschenswert ist dagegen ein automatisiertes Testen von Software, beispielsweise bei jedem Check-in in ein Versionskontrollsystem. Unit Tests erlauben es, Codeeinheiten automatisch testen zu lassen und helfen dabei, die Softwarequalität nachhaltig zu sichern. Dieser Artikel zeigt an einem Beispiel die Verwendung von Unit Tests mit Python.

Tests sollen allgemein sicherstellen, dass Software das tut, was von ihr erwartet wird. Klassischerweise (aufgrund ihrer Herkunft aus der Mathematik) befasste sich die Informatik mit der Korrektheit von Berechnungen und Algorithmen. In der Frühzeit der Computerverwendung herrschte das EVA-Prinzip vor (Eingabe – Verarbeitung – Ausgabe). Mathematisch lässt sich ein Programm in diesem Paradigma als die Implementierung einer Funktion verstehen, die eine Menge von Eingangsdaten nach einer definierten Regel auf eine Menge von Ausgangsdaten abbildet. Ein Programm ist demnach korrekt, wenn es für alle möglichen erlaubten Mengen von Eingangsdaten die der Regel entsprechenden Mengen von Ausgangsdaten generiert. Die entsprechenden Algorithmen wurden damals auf dem Papier sehr sorgfältig entworfen und nur abschließend einmal (nun ja, falls sie funktionierten) getestet.Heutzutage gibt es deutlich mehr Akzeptanzkriterien für Software. Korrektheit ist nach wie vor wichtig, aber es geht auch um Themen wie Usability (Benutzbarkeit einer Software), Robustheit (Toleranz gegenüber fehlerhaften/unerwarteten Eingangsdaten), Performance usw. Und so gibt es auch ein breites Spektrum an Testverfahren, wie Usability-Tests, Stresstests, Integrationstests und vieles mehr. Der beste Test für eine komplexere Systemsoftware, zumindest aus wirtschaftlicher Sicht, ist oft letztlich der Test durch den Endanwender/Kunden. Denn dieser entscheidet schließlich, ob das Verhalten der Software aus seiner Sicht akzeptabel ist oder nicht. Obwohl manuelle Tests des Endanwenders mit dem System und Integrationstests, die das Zusammenspiel der einzelnen Komponenten/Subsysteme prüfen, sehr aussagekräftig sind, sind sie auf der anderen Seite auch sehr teuer. Statt das gesamte System mit demselben Verfahren zu testen, hat sich inzwischen weitgehend die Ansicht durchgesetzt, dass ein Mix verschiedener Tests geeigneter ist, um ein System zu testen. Üblicherweise werden Tests auf drei Ebenen vorgenommen:

• UI-/Endanwendertests
• Tests der Serviceschicht/Integrationstest
• Tests einzelner Codeeinheiten

Diese Gliederung entspricht der von Mike Cohn vorgeschlagenen Testpyramide, bei der die Unit Tests die Basis, die Integrationstests die mittlere Schicht und die UI-Tests die Spitze bilden. Unit Tests bilden deshalb die Basis, weil sie sehr einfach automatisch durchgeführt werden können und entsprechend billig sind. Außerdem bewirken Fehler in den einzelnen Codeeinheiten fast immer Fehler in den darüber liegenden Schichten. Das Gegenteil ist dagegen keineswegs der Fall: Wenn alle Codeeinheiten einzeln richtig funktionieren, bedeutet das keineswegs, dass das Gesamtsystem richtig arbeitet. Deshalb können Unit Tests die darüber liegenden Tests auch nicht ersetzen.

Grundlagen

Unit Tests gehen im Wesentlichen auf den Artikel „Simple Smalltalk testing with patterns“ von Kent Beck zurück, in dem er ein Framework für die Ausführung vorstellt. Unter anderem führte er die folgenden Konzepte ein:

• Fixture: Darunter ist eine bestimmte Konfiguration zu verstehen, die als Ausgangspunkt eines Tests zu verstehen ist. Es handelt sich damit um die Eingangsdaten für den Test. Die Fixture wird durch eine spezielle setUp()-Methode der Testklasse erzeugt.
• Test Case: Die Durchführung eines bestimmten Tests für eine gegebene Fixture. Dieser Test wird mit einer entsprechenden Methode angestoßen, die normalerweise mit test beginnt, also etwa test_addition().
• Check: Die Prüfung des Ergebnisses der Ausführung eines Tests. Dieses geschieht innerhalb der beschriebenen Testmethode.
• Test Suite: Die Zusammenfassung einer Menge von Tests, die im Rahmen eines Testlaufs durchgeführt werden sollen.

Unit Tests mit Python

Python ist eine dynamisch typisierte Sprache. Das bedeutet, dass aufgrund der fehlenden Möglichkeit einer statischen Typprüfung viele Fehler während der Laufzeit (runtime) auftreten, statt zur Zeit der Kompilierung (compile time). Umgekehrt erleichtert die Interpreternatur der Sprache das Testen, da das zeitraubende Kompilieren und Linken hier entfällt. Es liegt also nahe, durch Einsatz automatischer Unit Tests die beschriebenen Fehler zu minimieren. Als Bonus erhält man eine Absicherung, dass der betreffende Code (zumindest für die getesteten Fälle) auch korrekt arbeitet. Das ist mehr, als eine statische Typprüfung leistet, die letztlich nur Typsicherheit, aber nicht korrekte Ausführung gewährleistet.In der Python-Standardbibliothek befindet sich das Modul unittest. Es handelt sich hier um eine Implementierung gemäß den von Beck vorgeschlagenen Prinzipien in Python. Es definiert eine Klasse TestCase, mit der Tests implementiert werden, und Funktionalität zum Auffinden und Ausführen von Tests (zur Installation von Python siehe Kasten: „Installation von Python“).

Installation von Python
Zum Nachvollziehen der Beispiele wird Python in der Version 2.7 benötigt.
Installation unter Debian/Ubuntu: Unter Debian/Ubuntu kann die Installation relativ einfach über apt aus einer Shell vorgenommen werden: sudo apt-get install python
Ggf. muss noch das Paket python-setuptools installiert werden: sudo apt-get install python-setuptools
Installation unter Windows: Python für Windows ist hier verfügbar. Das setuptools-Paket kann installiert werden, indem das Skript heruntergeladen und ausgeführt wird.

Fallbeispiel: die „Tribool“-Klasse

Als Beispiel für diesen Artikel soll uns eine Tribool-Klasse dienen, wie sie hier als Übungsaufgabe vorgeschlagen wird. Die Tribool-Klasse repräsentiert im Unterschied zu einer normalen booleschen Variable drei mögliche Ausprägungen:

• wahr
• falsch
• unbekannt

Instanzen von Tribool können dabei mit den üblichen logischen Operatoren (&=UND, |=OR, ~=NOT) kombiniert werden. Tabelle 1 und Tabelle 2 zeigen die Wahrheitstabellen für Tribool-Instanzen. Abbildung 1 zeigt das Klassendiagramm für die Klasse Tribool.

A B A & B (und) A – B (oder)
Wahr Wahr Wahr Wahr
Wahr Falsch Falsch Wahr
Wahr Unbekannt Falsch Wahr
Falsch Wahr Falsch Wahr
Falsch Falsch Falsch Falsch
Falsch Unbekannt Falsch Unbekannt
Unbekannt Wahr Falsch Unbekannt
Unbekannt Falsch Falsch Unbekannt
Unbekannt Unbekannt Unbekannt Unbekannt

Tabelle 1: Binäre Operationen auf „Tribool“-Instanzen

A ~A (Negation)
Wahr Falsch
Falsch Wahr
Unbekannt Unbekannt

Tabelle 2: Unäre Operation (not) auf „Tribool“-Instanzen

Die drei möglichen Ausprägungen modellieren wir zweckmäßigerweise wie von Summerfield vorgeschlagen wie folgt:

• Wahr: True
• Falsch: False
• Unbekannt: None

Abb. 1: Klassendiagramm für die Klasse „Tribool“

Test-driven Development (TDD)

In klassischen Vorgehensmodellen für die Softwareentwicklung (z. B. Wasserfallmodell) wurde der Test der Funktionalität als einer der letzten Schritte betrachtet. Das führte dann oft dazu, dass die Struktur der Software für das Schreiben von Tests sehr ungeeignet geriet, da einerseits die entsprechenden Schnittstellen fehlten, andererseits aufgrund fehlender Modularisierung Code gar nicht isoliert getestet werden konnte. Moderne Verfahren wie Extreme Programming ermutigen dagegen dazu, Tests von Anfang an zu schreiben, sogar schon vor der Implementierung der zu testenden Funktionalität. Das hat mehrere Vorteile:

• Die Software wird automatisch so entworfen, dass die betreffenden Codeeinheiten isoliert testbar sind.
• Tests werden nicht am Ende weggelassen, weil etwa das Budget verbraucht ist.
• Eine Qualitätssicherung findet von Anfang an statt.
• Refactoring wird erleichtert, wenn automatische Tests die Funktionalität von Anfang an absichern.

Das empfohlene „Rezept“ für TDD ist dabei:

1. Schreibe einen Test für die zu implementierende Funktionalität.
2. Führe den Test aus und prüfe, ob der Test fehlschlägt (wenn das nicht der Fall ist, ist der Test definitiv falsch und prüft nicht das gewünschte Verhalten).
3. Schreibe die Implementierung der zu testenden Funktionalität.
4. Führe den Test erneut aus. Im Falle eines Misserfolgs gehe zurück zu Schritt 3 und ändere die Implementierung entsprechend.
5. Ist der Test erfolgreich verlaufen, kann ein Refactoring des Codes durchgeführt werden, wobei die Funktionalität jeweils durch Ausführung des Tests geprüft werden soll.

Aufmacherbild: Businessman looking through a magnifying glass to document von Shutterstock / Urheberrecht: Bacho

[ header = Seite 2: Implementierung der Tests mit unittest ]

Implementierung der Tests mit unittest

Abbildung 2 zeigt schematisch die beteiligten Komponenten beim Arbeiten mit unittest. Testcode wird geschrieben, indem eine Unterklasse von unittest.TestCase implementiert wird. Spezielle Methoden werden zum Erzeugen und Abräumen der Fixtures verwendet. Die jeweiligen Tests werden vom Framework ausgeführt, das die Ergebnisse einsammelt und auswertet.

Abb. 2: Beteiligte Komponenten bei unittest

Gemäß dem im vorigen Abschnitt Beschriebenen, implementieren wir die Tests, bevor wir die eigentliche Funktionalität der Klasse implementieren. Lediglich die Schnittstelle der zu testenden Klasse muss bekannt sein, um die Tests schreiben zu können. Wir implementieren dazu zunächst nur den Rumpf der Klasse ohne weitere Funktionalität in einer Datei tribool.py. Das Ergebnis ist in Listing 1 zu sehen.

#!/usr/bin/python
# -*- coding: latin-1 -*-

class Tribool(object):
    """
    Represents logical values as true, false or unknown.
    """
    def __init__(self, value):
        """
        :Parameters:
            value : True, False or None
        """
        pass

    def value(self):
        """
        Return the value of self.
        """
        pass

    def __str__(self):
        """
        Return a string representation of self.
        """
        pass

    def __and__(self, tribool):
        """
        Return logical conjunction with other Tribool instance.
        :Parameters:
            tribool : Tribool instance
        """
        pass

    def __or__(self, tribool):
        """
        Return logical disjunction with other Tribool instance.
        :Parameters:
            tribool : Tribool instance
        """
        pass

    def __invert__(self):
        """
        Return logical negation of self. 
        """
        pass

Listing 2 zeigt den Testcode für die Initialisierung der Klasse, den wir in der Datei test_tribool.py ablegen. Wir definieren dazu zunächst eine Unterklasse von unittest.TestCase. Diese enthält zwei Methoden.

#!/usr/bin/python
# -*- coding: latin-1 -*-

import unittest

from tribool import Tribool

class Test_Tribool(unittest.TestCase):
    def test_init(self):
        """
        Ensure the values are set correctly.
        """
        t1 = Tribool(True)
        assert t1._value == True
        t2 = Tribool(False)
        assert t2._value == False
        t3 = Tribool(None)
        assert t3._value == None

    def test_init_invalid(self):
        """
        Ensure the initializer is called with valid parameters, otherwise
        an exception should be raised.
        """
        with self.assertRaises(TypeError):
            t = Tribool(42)

if "__main__" == __name__:
    unittest.main()

test_init() soll sicherstellen, dass, wenn die Klasse mit den Werten True, False oder None initialisiert wird, das private Attribut _value auf den entsprechenden Wert gesetzt wird. Das geschieht, indem jeweils eine entsprechende Instanz der Klasse mit dem jeweiligen Wert initialisiert wird und dann mit assert geprüft wird, ob der Wert entsprechend gesetzt wurde. Im Docstring der Methode steht, was genau dieser Test sicherstellen soll. Es empfiehlt sich, solche Docstrings immer zu verwenden, da man sonst schnell den Überblick verliert, was genau in einer Testmethode getestet wird.test_init_invalid() soll prüfen, ob eine Exception vom Typ TypeError geworfen wird, wenn dem Initializer ein falscher Wert übergeben wird. Dazu wird die Methode assertRaises() von TestCase als Context Manager verwendet, indem ein with vorangestellt wird und der Testcode darunter eingerückt eingefügt wird. Die Semantik ist hier, dass beim Aufrufen des Testcodes eine Exception vom Typ TypeError geworfen werden soll. Es wird dazu dem Initializer der ungültige Wert 42 übergeben. Die Zeilen

if "__main__" == __name__:
    unittest.main()

erlauben, die Tests durch Aufruf des Moduls mit dem Python-Interpreter ausführen zu lassen:

python test_tribool.py

Es kommt zu folgender Ausgabe (Listing 3).

EF
==============================================================
ERROR: test_init (__main__.Test_Tribool)
--------------------------------------------------------------
Traceback (most recent call last):
  File "test_tribool.py", line 14, in test_init
    assert t1._value == True
AttributeError: 'Tribool' object has no attribute '_value'

==============================================================
FAIL: test_init_invalid (__main__.Test_Tribool)
--------------------------------------------------------------
Traceback (most recent call last):
  File "test_tribool.py", line 26, in test_init_invalid
    t = Tribool(42)
AssertionError: TypeError not raised

--------------------------------------------------------------
Ran 2 tests in 0.001s

FAILED (failures=1, errors=1)

Die Tests sind fehlgeschlagen, was nicht verwunderlich ist, da wir die entsprechende Funktionalität noch nicht implementiert haben. Die Fehlschläge werden hier unterschieden in Failures und Errors. Ein Failure liegt vor, wenn ein Test dahingehend fehlschlägt, dass der Check (hier implementiert als Assertions) nicht erfolgreich ist. Ein Error liegt vor, wenn aus anderen Gründen ein Test nicht erfolgreich ausgeführt werden kann. In diesem Fall schlägt der erste Test (test_init()) deshalb fehl, weil die Klasse Tribool noch gar kein Attribut _value hat, entsprechend handelt es sich um einen Error. Beim zweiten Test schlägt nur der Check fehl, entsprechend wird er als Failure eingestuft.
Zusätzlich zu den Testergebnissen wird die Anzahl der durchlaufenen Tests und die verbrauchte Zeit ausgegeben. Wir implementieren jetzt den Code für den Initializer (Listing 4).

class Tribool(object):
    """
    Represents logical values as true, false or unknown.
    """
    def __init__(self, value):
        """
        :Parameters:
            value : True, False or None
        """
        if value in (True, False, None):
            self._value = value
        else:
            raise TypeError
...

Anschließend rufen wir den Test erneut auf. Das Ergebnis zeigt Listing 5.

python test_tribool.py 
..
-------------------------
Ran 2 tests in 0.000s

OK

Beide Tests wurden erfolgreich durchlaufen, was mit „OK“ signalisiert wird. Jetzt fügen wir weiteren Testcode hinzu, um die Methoden value() und __str__() zu testen. Listing 6 zeigt die hinzugekommenen Methoden.

...
class Test_Tribool(unittest.TestCase):
    ...
    def test_value(self):
        """
        The value returned by value() should equal self._value.
        """
        for in_value in (True, False, None):
            t = Tribool(in_value)
            assert t.value() == t._value

    def test_str(self):
        """
        True should be printed as 'T', False as 'F', None as 'U'.
        """
        t1 = Tribool(True)
        assert t1.__str__() == 'T'
        t2 = Tribool(False)
        assert t2.__str__() == 'F'
        t3 = Tribool(None)
        assert t3.__str__() == 'U'
...

test_value() prüft für alle validen Eingangswerte, ob der von value() zurückgegebene Wert identisch ist mit dem Wert des Attributs _value. test_str() prüft, ob die String-Repräsentation für die verschiedenen Werte korrekt ist („T“ für True, „F“ für False, „U“ für None). Gemäß dem Prinzip von TDD führen wir zunächst wieder die Tests aus, um sicherzustellen, dass sie fehlschlagen (Listing 7).

python test_tribool.py 
..FF
=====================================================
FAIL: test_str (__main__.Test_Tribool)
-----------------------------------------------------
Traceback (most recent call last):
  File "test_tribool.py", line 41, in test_str
    assert t1.__str__() == 'T'
AssertionError

======================================================
FAIL: test_value (__main__.Test_Tribool)
------------------------------------------------------
Traceback (most recent call last):
  File "test_tribool.py", line 34, in test_value
    assert t.value() == t._value
AssertionError

-------------------------------------------------------
Ran 4 tests in 0.006s

FAILED (failures=2)

Erwartungsgemäß schlagen beide Tests wie gewünscht fehl (Failures). Wir erweitern jetzt den zu testenden Code um die entsprechende Funktionalität (Listing 8).

class Tribool(object):
    """
    Represents logical values as true, false or unknown.
    """
    ...
    def value(self):
        """
        Return the value of self.
        """
        return self._value

    def __str__(self):
        """
        Return a string representation of self.
        """
        if self._value == True:
            ret = 'T'
        elif self._value == False:
            ret = 'F'
        elif self._value == None:
            ret = 'U'
        return ret
    ...

value() gibt jeweils den Wert des Attributs _value zurück. __str__() gibt abhängig von _value die definierte Repräsentation als String-Repräsentation zurück. Ein erneuter Aufruf des Tests zeigt, dass die Implementierung aus Sicht der Tests jetzt korrekt ist:

python test_tribool.py 
....
-------------------------
Ran 4 tests in 0.006s

OK

[ header = Seite 3: Verwendung von Fixtures ]

Verwendung von Fixtures

Bisher wurden alle Ausgangsdaten für die Tests jeweils in den test_*()-Methoden erzeugt, indem in jedem Test Tribool-Instanzen angelegt wurden. Wenn bei einer Menge von Tests immer dieselbe Fixture (=Ausgangskonfiguration) benötigt wird, kann diese aber in einem separaten Schritt vor der Ausführung des jeweiligen Tests erzeugt werden. Oft müssen z. B. für verschiedene Tests Konfigurationsdateien angelegt oder Einträge in Datenbanken vorgenommen werden. unittest.TestCase bietet dafür eine eigene Methode setUp().
Für die Tests der logischen Operatoren benötigen wir jeweils Instanzen der drei möglichen Ausprägungen von Tribool. Es bietet sich daher an, die Erzeugung dieser Instanzen in die setUp()-Methode zu verlagern. Da diese Konfiguration für die bisherigen Tests nicht benötigt wird, erstellen wir eine neue Instanz von TestCase für die Tests der Logik und speichern sie in einer Datei test_tribool_logic.py (Listing 9).

#!/usr/bin/python
# -*- coding: latin-1 -*-

import unittest

from tribool import Tribool

class Test_Tribool_logical_operations(unittest.TestCase):
    def setUp(self):
        self.trueTribool = Tribool(True)
        self.falseTribool = Tribool(False)
        self.unknownTribool = Tribool(None)

    def test_not(self):
        """
        Test logical not operation.
        """
        assert (~ self.trueTribool).value() == False
        assert (~ self.falseTribool).value() == True
        assert (~ self.unknownTribool).value() == None

...
if "__main__" == __name__:
    unittest.main()

Die Methode setUp() legt hier Instanzen der drei möglichen Ausprägungen von Tribool als Attribute der Testklasse an. Das unittest-Framework arbeitet dabei wie folgt:

• Vor Aufruf jeder test_*()-Methode wird setUp() aufgerufen.
• Die jeweilige test_*()-Methode selbst wird aufgerufen.
• Nach jeder Testmethode wird tearDown() aufgerufen.

Die Methode tearDown() wird in unserem Beispiel nicht verwendet. Sie dient dazu, nach Durchführung eines Tests wieder aufzuräumen, Ressourcen freizugeben, Datenbankeinträge wieder zu entfernen, Umgebungsvariablen zurückzusetzen oder entstandene Dateien zu löschen. Insbesondere wird sichergestellt, dass diese Methode auch aufgerufen wird, wenn es im Rahmen der Tests zu Exceptions kommt.
Listing 10 und 11 enthalten den Code zum Testen der logischen UND- und ODER-Verknüpfung. Es wurde für jede Kombination von Ausprägungen ein eigener Test geschrieben. Das wäre nicht unbedingt notwendig gewesen, es hätte jeweils ein Test für die UND- und die ODER-Verknüpfung geschrieben werden können. Das hätte den Testcode aber schwerer zu lesen und zu schreiben gemacht. Mehr noch als für normalen Code ist es für Tests jedoch wichtig, dass sie einfach und verständlich gehalten werden. Andernfalls besteht die Gefahr, dass man mehr Zeit mit der Wartung des Testcodes als mit dem Testen des Codes verbringt.

...
class Test_Tribool_logical_operations(unittest.TestCase):
    ...
    def test_and_true_true(self):
        """
        Test logical and operation if both values are true.
        """
        assert (self.trueTribool & self.trueTribool).value() == True

    def test_and_true_false(self):
        """
        Test logical and operation if one value is true and one false.
        """

        assert (self.trueTribool & self.falseTribool).value() == 
               (self.falseTribool & self.trueTribool).value() == False

    def test_and_true_unknown(self):
        """
        Test logical and operation if one value is true and one unknown.
        """
        assert (self.trueTribool & self.unknownTribool).value() == 
               (self.unknownTribool & self.trueTribool).value() == False

    def test_and_false_false(self):
        """
        Test logical and operation if both values are false.
        """
        assert (self.falseTribool & self.falseTribool).value() == False


    def test_and_false_unknown(self):
        """
        Test logical and operation if one value is fals and one unknown.
        """
        assert (self.falseTribool & self.unknownTribool).value() == 
               (self.unknownTribool & self.falseTribool).value() == False
        
    def test_and_unknown_unknown(self):
        """
        Test logical and operation if both values are unknown.
        """
        assert (self.unknownTribool & self.unknownTribool).value() == None
    ...
...

class Test_Tribool_logical_operations(unittest.TestCase):
    ...
    def test_or_true_true(self):
        """
        Test logical or operation if both values are true.
        """
        assert (self.trueTribool | self.trueTribool).value() == True

    def test_or_true_false(self):
        """
        Test logical or operation if one value is true or one false.
        """

        assert (self.trueTribool | self.falseTribool).value() == 
               (self.falseTribool | self.trueTribool).value() == True

    def test_or_true_unknown(self):
        """
        Test logical or operation if one value is true or one unknown.
        """
        assert (self.trueTribool | self.unknownTribool).value() == 
               (self.unknownTribool | self.trueTribool).value() == True

    def test_or_false_false(self):
        """
        Test logical or operation if both values are false.
        """
        assert (self.falseTribool | self.falseTribool).value() == False


    def test_or_false_unknown(self):
        """
        Test logical or operation if one value is fals or one unknown.
        """
        assert (self.falseTribool | self.unknownTribool).value() == 
               (self.unknownTribool | self.falseTribool).value() == None
        
    def test_or_unknown_unknown(self):
        """
        Test logical or operation if both values are unknown.
        """
        assert (self.unknownTribool | self.unknownTribool).value() == None
    ...

Wie üblich sollte jetzt zunächst der Testcode ausgeführt werden, um sicherzustellen, dass die Tests wirklich fehlschlagen. Wir lassen an dieser Stelle die entsprechende Ausgabe weg – das Ergebnis sind dreizehn Fehlschläge für die Tests der logischen Operatoren. Im letzten Schritt fügen wir nun die Implementierungen für die logischen Operatoren hinzu (Listing 12). Ein erneuter Aufruf des Testcodes zeigt, dass jetzt alle Tests erfolgreich durchlaufen werden.

...
class Tribool(object):
    """
    Represents logical values as true, false or unknown.
    """
    ... 
    def __invert__(self):
        """
        Return logical negation of self. 
        """
        inverted = None
        if self._value == True:
            inverted = False
        elif self._value == False:
            inverted = True
        return Tribool(inverted)

    def __and__(self, tribool):
        """
        Return logical conjunction with other Tribool instance.
        :Parameters:
            tribool : Tribool instance
        """
        ret = Tribool(False)
        if self._value == True and tribool._value == True:
            ret = Tribool(True)
        elif self._value == None and tribool._value == None:
            ret = Tribool(None)
        return ret

    def __or__(self, tribool):
        """
        Return logical disjunction with other Tribool instance.
        :Parameters:
            tribool : Tribool instance
        """
        ret = Tribool(None)
        if self._value == True or tribool._value == True:
            ret = Tribool(True)
        elif self._value == False and tribool._value == False:
            ret = Tribool(False)
        return ret

Test-Discovery – Finden von Tests

Bisher haben wir die Tests ausgeführt, indem wir in die entsprechenden Testmodule die Zeilen

if "__main__" == __name__:
    unittest.main()

aufgenommen haben und die entsprechenden Skripte explizit aufgerufen haben. Das ist bei vielen Testmodulen umständlich. Eigentlich ist die Möglichkeit erwünscht, alle Testmodule mit einem einfachen Aufruf zu testen. Dazu kann das Kommando discover von unittest verwendet werden:

python -m unittest discover
.................
---------------------------
Ran 17 tests in 0.002s

OK

Als Folge des Aufrufs findet unittest alle Tests im aktuellen Verzeichnis und führt sie aus. Ein alternatives Startverzeichnis kann ggf. mit der Option –start-directory festgelegt werden.

Erweiterte Möglichkeiten: py.test

Bisher haben wir mit dem Modul unittest der Standardbibliothek gearbeitet. Wie wir gesehen haben, lassen sich damit grundlegende Tests implementieren, auffinden und ausführen. Mittlerweile gibt es aber für die Durchführung von Tests deutlich leistungsfähigere Bibliotheken. Sehr bekannt ist nose. Wir werden an dieser Stelle pytest verwenden, um insbesondere die Möglichkeiten der verbesserten Test-Discovery und der Ausgabe von Coverage-Informationen zu zeigen. Zur Installation kann das folgende Kommando verwendet werden: easy_install -U pytest. Unter Linux-Distributionen, die auf Debian basieren, wie Ubuntu, muss die Installation ggf. mit sudo durchgeführt werden: sudo easy_install pytest.

[ header = Seite 4: Organisation von Testcode/erweiterte Test-Discovery ]

Organisation von Testcode/erweiterte Test-Discovery

Für größere Projekte empfiehlt sich, Test-Code in Unterverzeichnissen unterhalb des zu testenden Codes zu organisieren, in unserem Beispiel etwa so:

.../subsys_tribool/
.../subsys_tribool/tribool.py
.../subsys_tribool/tests/test_tribool.py
.../subsys_tribool/tests/test_tribool_logic.py

Damit wird der Programmcode vom Testcode getrennt, was die Übersichtlichkeit erhöht. Andererseits befinden sich die Testmodule immer noch in der Nähe des zu testenden Codes, was günstig ist, da Änderungen im Programmcode meistens auch Änderungen an Tests mit sich ziehen. Mithilfe der erweiterten Test-Discovery können dann alle relevanten Tests für den Programmcode ausgeführt werden (Listing 13).

py.test 
============================= test session starts ============================== 
platform linux2 -- Python 2.7.3 -- py-1.4.20 -- pytest-2.5.2 
collected 0 items / 2 errors 

==================================== ERRORS ==================================== 
____________________ ERROR collecting tests/test_tribool.py ____________________ 
tests/test_tribool.py:3: in <module> 
>   from tribool import Tribool 
E   ImportError: No module named tribool 
_________________ ERROR collecting tests/test_tribool_logic.py _________________ 
tests/test_tribool_logic.py:3: in <module> 
>   from tribool import Tribool 
E   ImportError: No module named tribool 
=========================== 2 error in 0.30 seconds ============================

Die Ausgabe zeigt, dass pytest die Tests auch im Unterverzeichnis test/ findet. Allerdings kommt es zu import-Fehlern, weil der Testcode jetzt nicht mehr im selben Verzeichnis liegt wie der Programmcode. Um das zu beheben, kann etwas Code in den Testmodulen ergänzt werden:

import sys
import os
dir_up = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(dir_up,'..'))
...

Dieser Code bewirkt, dass das darüber liegende Verzeichnis in den Pfad aufgenommen wird. Ein erneuter Aufruf zeigt (Listing 14), dass die Tests jetzt erfolgreich ausgeführt werden.

py.test
============================= test session starts ==============================
platform linux2 -- Python 2.7.3 -- py-1.4.20 -- pytest-2.5.2
plugins: cov
collected 17 items 

tests/test_tribool.py ....
tests/test_tribool_logic.py .............

========================== 17 passed in 0.35 seconds ===========================

Coverage/Testabdeckung

Eine wichtige Metrik beim Testen ist neben der reinen Anzahl der Tests die Abdeckung (Coverage), die Aussagen darüber macht, wie viel des Programmcodes tatsächlich getestet wird. Um Coverage mit pytest verwenden zu können, muss zunächst das entsprechende Modul installiert werden (ggf. muss hier wieder ein sudo vorangestellt werden): pip install pytest-cov. Jetzt können wir die Coverage messen. Die Aufrufsyntax ist dabei:

py.test --cov <Name des zu testenden Moduls ohne Endung> <Testverzeichnis>

Es ergibt sich dann die folgende Ausgabe (Listing 15).

py.test --cov tribool tests/
============================= test session starts ==============================
platform linux2 -- Python 2.7.3 -- py-1.4.20 -- pytest-2.5.2
plugins: cov
collected 17 items 

tests/test_tribool.py ....
tests/test_tribool_logic.py .............
--------------- coverage: platform linux2, python 2.7.3-final-0 ----------------
Name      Stmts   Miss  Cover
-----------------------------
tribool      36      0   100%

========================== 17 passed in 0.32 seconds ===========================

Neben der Anzahl der durchgeführten Tests erhalten wir damit Informationen über die Anzahl der Statements des Programmcodes und die Coverage. In unserem Fall beträgt die Coverage 100 Prozent. Das ist ein Idealwert, der normalerweise in realen Projekten oft nicht erreicht werden kann. Es ergibt meistens auch keinen Sinn, unbedingt eine hundertprozentige Abdeckung erreichen zu wollen. Eine Abdeckung von 80 Prozent kann im Durchschnitt als hinreichend betrachtet werden. Wir erweitern jetzt die Klasse Tribool um eine Methode __cmp__() (Listing 16), um Gleichheitsoperationen zu implementieren.

class Tribool(object):
    ...

    def __cmp__(self, other):
        """
        Compare two Tribool instances.
        """
        lhsValue = self.value()
        rhsValue = other.value()
        if lhsValue == None:
            lhsValue = -1
        if rhsValue == None:
            rhsValue = -1
        return cmp(lhsValue, rhsValue)
    ...

Ein erneuter Aufruf (Listing 17) zeigt, dass die Coverage jetzt nur noch 84 Prozent beträgt, und dass sieben Statements des Programmcodes nicht abgedeckt sind.

py.test --cov tribool tests/
============================= test session starts ==============================
platform linux2 -- Python 2.7.3 -- py-1.4.20 -- pytest-2.5.2
plugins: cov
collected 17 items 

tests/test_tribool.py ....
tests/test_tribool_logic.py .............
--------------- coverage: platform linux2, python 2.7.3-final-0 ----------------
Name      Stmts   Miss  Cover
-----------------------------
tribool      44      7    84%

========================== 17 passed in 0.31 seconds ===========================

Es gibt also Programmcode, der nicht getestet wird. In diesem Fall wissen wir natürlich, dass es sich um den neu hinzugekommenen Code für die __cmp__()-Methode handeln muss. In wirklichen Projekten ist es aber oft so, dass der Code vielleicht von jemand anderem hinzugefügt wurde. Es gibt dafür die Möglichkeit, sich anzeigen zu lassen, welche Zeilen genau nicht abgedeckt sind. Dazu muss die Option  –cov-report mit dem Argument term-missing aufgerufen werden (Listing 18).

py.test --cov-report term-missing --cov tribool tests
============================= test session starts ==============================
platform linux2 -- Python 2.7.3 -- py-1.4.20 -- pytest-2.5.2
plugins: cov
collected 17 items 

tests/test_tribool.py ....
tests/test_tribool_logic.py .............
--------------- coverage: platform linux2, python 2.7.3-final-0 ----------------
Name      Stmts   Miss  Cover   Missing
---------------------------------------
tribool      44      7    84%   77-83

========================== 17 passed in 0.34 seconds ===========================

Der Ausgabe können wir entnehmen, dass der ungetestete Code sich in den Zeilen 77 bis 83 des Moduls befindet, und können nun gezielt einen Test für diesen Code schreiben. Wir fügen also für die Methode __cmp__() Testcode in test_tribool.py hinzu (Listing 19).

...
class Test_Tribool(unittest.TestCase):
    ...
   def test_cmp(self):
        """
        Tribools with identical values should be considered equal.
        """
        trueTribool = Tribool(True)
        falseTribool = Tribool(False)
        unknownTribool = Tribool(None)
        assert trueTribool == Tribool(True)
        assert falseTribool == Tribool(False)
        assert unknownTribool == Tribool(None)
        

    ...

Eine erneute Ausführung der Tests zeigt (Listing 20), dass die Abdeckung jetzt wieder bei 100 Prozent liegt.

py.test --cov-report term-missing --cov tribool tests
============================= test session starts ==============================
platform linux2 -- Python 2.7.3 -- py-1.4.20 -- pytest-2.5.2
plugins: cov
collected 18 items 

tests/test_tribool.py .....
tests/test_tribool_logic.py .............
--------------- coverage: platform linux2, python 2.7.3-final-0 ----------------
Name      Stmts   Miss  Cover   Missing
---------------------------------------
tribool      44      0   100%   

========================== 18 passed in 0.38 seconds ===========================

Fazit

Wir haben an einem sehr einfachen Beispiel einen Einblick in die Verwendung von Unit Tests mit unittest und pytest erhalten. Natürlich haben wir dabei nur einen Bruchteil der Möglichkeiten gestreift. Nichtsdestotrotz sollte klar geworden sein, dass es mit den beschriebenen Frameworks relativ einfach ist, Tests zu automatisieren und damit das Verhalten des Programmcodes abzusichern. Die vollständigen Listings finden Sie unter [6].
Für einen tieferen Einstieg in die Materie empfiehlt sich, neben der Lektüre der betreffenden Dokumentationen, einfach anzufangen, die beschriebenen Methoden in eigenen Projekten einzusetzen. Man wird schnell feststellen, dass das TDD zahlreiche Vorteile bieten kann, und dass diese Arbeitsweise einfach auch sehr befriedigend ist, da man durch die erfolgreich absolvierten Tests schon sehr früh zu Feedback und Erfolgserlebnissen kommt – und zwar bevor die Funktionalität des Gesamtsystems überhaupt verfügbar ist, weil etwa das User Interface noch nicht implementiert ist.

Unsere Redaktion empfiehlt:

Relevante Beiträge

Meinungen zu diesem Beitrag

X
- Gib Deinen Standort ein -
- or -