Unit Tests mit Python – Beispiele und Fallstricke

Autor / Redakteur: Tobias Pleyer * / Sebastian Gerstl

Unit Tests sind wohl die bekannteste Teststufe, die von Entwicklern vor der Integration in die Versionsverwaltung ausgeführt wird. Zunehmend wird dazu die Skriptsprache Python verwendet. Der folgende Beitrag zeigt, wie typische Fallstricke beim Testdesign mit Python umgangen werden können.

Anbieter zum Thema

Python hat sich als Mittel der Wahl bei Unit Tests weitgehend etabliert. Für eine gute und ordentliche Testabdeckung sollten allerdings einige potentielle Fallstricke beachtet werden. Hier einige Beispiele.
Python hat sich als Mittel der Wahl bei Unit Tests weitgehend etabliert. Für eine gute und ordentliche Testabdeckung sollten allerdings einige potentielle Fallstricke beachtet werden. Hier einige Beispiele.
(Bild: Clipdealer)

Tests sind ein wichtiger Bestandteil in der modernen Softwareentwicklung. Die Vorteile, die man aus einer guten Testabdeckung ziehen kann, sind:

  • Wachsendes Vertrauen in das Produkt
  • Reproduzierbarkeit
  • Aussagekräftige Statistiken
  • Schnellere Refactoring-Zyklen
  • Einfache Einbindung in ein Continuous Integration-System

Alle genannten Punkte gelten selbstverständlich für jede Programmiersprache. Ist die angewandte Programmiersprache statisch typisiert und kompiliert, so schließt der Compiler in der Regel bereits eine große Klasse von Fehlern aus.

Python ist eine sehr populäre Sprache und findet in allen Branchen Anwendung. Python wird attestiert, einfach zu erlernen und gut lesbar zu sein. Der Hauptgrund für Pythons Beliebtheit birgt aber auch Risiken: Python ist eine dynamisch typisierte Interpretersprache. Das heißt: Abgesehen von Syntaxfehlern wird alles zur Laufzeit abgehandelt. Üblicherweise kommt Python in kleinen bis mittelgroßen Projekten zum Einsatz, oder aber begleitend im Projekt-Tooling. Die Schnelligkeit und Leichtigkeit, mit Python zum Ziel zu kommen verleitet dazu, gewissen Mustern zu folgen, die das spätere Testen erheblich erschweren. Es ist sehr üblich, dass erst ab einem gewissen Reifegrad des Projekts die Notwendigkeit von Tests erkannt wird. Zu diesem Zeitpunkt stellt der bestehende Code dann oft eine unerwartet große Hürde dar.

Der Punkt, der fast immer zu wenig Beachtung findet, ist das "Design for Testability". Damit ist - zumindest im Kontext des hier behandelten Unit Tests - der Entwurf von Klassen und Funktionen mit dem Ziel reduzierten Aufwands bei der Testerstellung sowie geringere Fehleranfälligkeit und bessere Lesbarkeit der Tests gemeint. Im vorliegenden Artikel soll das Problem zustandsüberladener Klassenimplementierungen erörtert werden und Lösungen zu ihrer Vermeidung gezeigt werden.

Typische Szenarien für Unit Tests mit Python

Die einfache Zusammenfassung der folgenden Argumentation lautet: Je öfter self im Code zu finden ist, desto schwieriger wird das Testen.

In Pythons Klassenmodell stellt self die Referenz auf die aktuelle Instanz der Klasse dar. Dies ist vergleichbar mit dem Konzept von this in C++. Die Nutzung oder bloße Anwesenheit von self zieht aber immer eine Kopplung an den Zustand der Instanz mit sich. Als einfache Daumenregel gilt: Mehr Zustand bedeutet auch mehr Totlast beim Testen.

Der Grund dafür ist einfach: Zustand kann nicht ignoriert werden. Er findet sich implizit überall, sobald eine Klasse instanziiert wird. Das folgende Codebeispiel zeigt eine zustandsbehaftete Klassendefinition.

1  class Example:
2      def __init__(self, x):
3          self.x = x
4
5      # ...
6
7  ex = Example(5)
8  print(ex.x)
9  # 5
10 ex.x = 6
11 print(ex.x)
12 # 6

Jede Instanz der Klasse Example hat ihre eigene Kopie der Variable x. Im Programmverlauf kann sich der Wert der Variable mehrfach ändern, was eine Zustandsänderung der entsprechenden Instanz mit sich zieht. Der Zustand von ex in Zeile 8 ist nicht identisch mit dem Zustand in Zeile 11.

Im obigen Beispiel ist ex.x zunächst gleich 5. Dies gehört zum impliziten Zustand des Objekts. Überall dort, wo die Referenz auf ex existiert, kann dieser Zustand potentiell verändert werden. In der Regel kann man nicht wissen, ob ein vorhergehender Funktionsaufruf den Zustand des Objekts nicht verändert hat.

Das Dilemma äußert sich wie folgt: Was für das alltägliche Programmieren eine Erleichterung darstellt, weil Änderungen beiläufig erledigt werden können, rächt sich später beim Testen. Sehr oft werden Klassenattribute verwendet, um Methoden mit weniger expliziten Argumenten zu erzielen:

Probleme, die sich aus sich ändernden Objekten ergeben

Warum stellt das ein Problem dar? Es wird immer dann problematisch, wenn eine Klasse komplexe Objekte enthält, typischerweise andere Klassen. Unit Tests basieren auf dem Gedanken, einzelne Funktionen oder Funktionsblöcke aus der Gesamtanwendung freizulegen und separat zu testen. Um dieses Ziel zu erreichen, muss man eine gesicherte Umgebung für die Testeinheit anlegen. Gesichert bedeutet in diesem Kontext eine genaue Kenntnis beziehungsweise Vorbereitung des Ausgangszustandes vor dem Test in dem Umfang, wie der Zustand den Test beeinflusst. Dies beinhaltet eine hinreichend funktionsfähige Bereitstellung des Zustands und von Abhängigkeiten. Je komplexer die beteiligten Klassen und je größer die impliziten Abhängigkeiten, desto nichttrivialer wird die Umsetzung einer solchen Bereitstellung.

Ergänzendes zum Thema
Fakes, Stubs & Mocks

Im Zusammenhang mit der Bereitstellung von Sekundärobjekten, um einen Test am Laufen zu halten, tauchen drei Begriffe regelmäßig auf: Fake/Dummy, Stub und Mock. Obwohl im Kern das Gleiche gemeint ist, so ist die Intention dahinter leicht unterschiedlich.

Fake: Ein Fake besitzt das gleiche Verhalten wie sein Original, bedient sich aber leichtgewichtigerer Implementierungen oder Abkürzungen. Zum Beispiel kann ein Server-Fake auf Seiten aus einem Cache zugreifen, das Routing und andere Logik aber über die echten Implementierungen erledigen.

Stub: Ein Stub personifiziert ein anderes Objekt nur genau so weit, wie es für einen spezifischen Anwendungsfall notwendig ist. Geht man über diese Grenzen hinaus (unerwarteter Funktionsaufruf), wird man schnell in einen Fehlerfall laufen. Stubs sind eine gute Wahl für Unittests, weil in der Regel nur wenige Funktionen eines Originals bereitgestellt werden müssen und diese im Vorhinein bekannt sind.

Mock: Ein Mock ist ein Stub mit verknüpften Erwartungen. Zum Beispiel kann man die Erwartung formulieren, dass eine Mockfunktion genau zweimal aufgerufen wird oder etwa niemals.

Im Alltagsgebrauch spricht man meist in allen drei Fällen von einem Mock.

Hier einige Beispielhafte Implementierungen einer Klasse:

1  class Example_Version1:
2      def __init__(self):
3          self.connected = False
4      # ...
5
6      def react(self, event):
7          if self.connected:
8              #...
9
10     def onEvent(self, event):
11         #...
12         self.react(event)

Der Code in diesem Beispiel ist sehr gängige Praxis in der kommerziellen Pythonprogrammierung und die meisten Programmierer würden ihn als intuitiv und leserlich empfinden. Eine alternative Implementierung könnte dagegen das connected-Flag als Funktionsparamter explizit übergeben. Während der erste Ansatz stark objektorientiert (zustandsbehaftet) ist, versucht der zweite Ansatz funktional zu bleiben. Objektorientiertes Programmieren und funktionales Programmieren sind zwei unterschiedliche Strömungen in der Softwareentwicklung.

Richtiges Verhältnis zwischen funktionaler und objektorientierter Programmierung

Im Alltag gilt es, das richtige Verhältnis zwischen den Maximen der funktionalen und der objektorientierten Programmierung zu finden und in der Praxis wird es immer zu einem Kompromiss kommen. Fakt ist: Ohne Zustand geht es nicht. Jede Anwendung hat bestimmte Daten, die über mehrere Funktionsaufrufe hinweg Bestand haben müssen. Die Persistenz der Daten über die Funktionsaufrufe sollte aber auf ein Minimum reduziert werden. Im Kontext dieses Beitrags soll FP Funktionen bedeuten, die nur ihre expliziten Funktionsparameter zur Berechnung heranziehen und Seiteneffekte (Input/Output) auf ein Minimum reduzieren, im Bestfall vermeiden.

Ergänzendes zum Thema
FP vs. OOP

Funktionales (FP) und objektorientiertes (OOP) Programmieren sind zwei Paradigmen der Softwareentwicklung, die sich nicht zwangsläufig gegenseitig ausschließen. Zum Beispiel ist F# eine vornehmlich funktionale Programmiersprache mit Unterstützung für OOP. FP schließt folgende Konzepte mit ein:

  • Funktionen können wie andere Datentypen auch von Funktionen verwendet und erzeugt werden.
  • Die Argumente der Funktion allein sind ausreichend, um den Rückgabewert zu berechnen.
  • Ein Programm besteht nur aus Ausdrücken, aber keinen Anweisungen.
  • Einmal zugewiesene Werte sind unveränderlich (immutable).
  • Referenzielle Transparenz: Ein Funktionsaufruf mit einem fixierten Satz an Parametern hat immer dasselbe Ergebnis zur Folge und keine Seiteneffekte.
  • Es gibt keinen externen oder impliziten Zustand.
  • Lazy Evaluation.
  • Purity (side effect free evaluation).

Eine Programmiersprache muss nicht alle oben genannte Punkte erfüllen, um als funktional betrachtet zu werden. Objektorientierung hingegen bedeutet, definiertes Verhalten in Kombination mit einem Zustand unter einem gemeinsamen Namen (Objekt) zu vereinen und geteiltes Verhalten zwischen Objekten mittels einer hierarchischen Struktur zu vererben.

1  class Device:
2      def __init__(self, network, separator, id):
3          self.network = network
4          self.message_separator = separator
5          self.id = id
6          
7          self.message_buffer = bytes()
8          self.requests = []
9          self.connected = False
10
11         self._request_map = {
12             'request_X': 42
13             # ...
14         }
15         self.setup()
16
17 def setup(self):
18     self.my_addr = self.iface.get_next_free_address()
19     if self.is_valid_addr():
20         self.connected = True
21
22 def data_received(self, data):
23     self.message_buffer += data
24     self.parse_data()
25     self.respond()
26
27 def respond(self):
28     for resp in self.requests:
29     resp = self.lookup_response(req)
30     self.network.send(resp)
31     self.requests = []
32
33 def parse_data(self):
34     for msg in self.message_buffer.split(self.message_separator):
35         # ...
36         self.requests.append(request)
37     # Es ist möglich das der letzte Teil des Nachrichtenpuffers eine
38     # unvollständige Nachricht enthält, diese sollte im Puffer
39     # behalten werden...

40      self.message_buffer = uncomplete_message
41 def lookup_response(self, request):
42      if request in self._request_map:
43          return self._request_map[request]
44      return None
45
46 def is_valid_addr(self):
47      #...
48      return is_valid

Das Klassenbeispiel in Beispiel 3 stellt das Skelett einer einfachen Klasse mit Netzwerkanbindung dar. Die Klasse empfängt Daten aus dem Netzwerk über die Callback-Funktion data_received als Bytes. Anschließend müssen die Daten in einzelne Komponenten zerlegt und interpretiert werden. Zu jeder Nachricht wird eine Antwort generiert und anschließend über das Netzwerk verschickt.

Der Aufbau der Klasse im dritten Codebeispiel folgt gängiger Praxis. Lookup-Hashtabellen, Statusvariablen und beteiligte Klassen werden über self referenziert. Diese Daten sind damit Teil der Klasse selbst und je nach deren aktuellen Werten wird sich eine Instanz dieser Klasse anders verhalten.

Aufsetzen eines geeigneten Unit Tests

Unser Ziel sei es, einen Unit Test für die Methode parse_data zu schreiben. Weil diese Methode den internen Nachrichtenpuffer der Klasse verwendet, bedeutet das, wir müssen eine Instanz dieser Klasse erzeugen. Das bedeutet aber auch, dass der Konstruktor der Klasse aufgerufen wird. Er erwartet unter anderem ein Objekt einer Netzwerkklasse (Abb. 3, Zeile 2 und 3). Dieses Objekt wird an mehreren Stellen verwendet. Folglich reicht ein naiver Mock nicht aus, um genügend Funktionalität zur Verfügung zu stellen, so dass kein Unterschied zum Original erkennbar wird. Obwohl wir nur eine Methode der Klasse testen möchten, zwingt uns die implizite Zustandsverknüpfung dazu, andere Klassen in nicht-trivialer Weise zu mocken.

Abhilfe

Sehen wir uns jetzt die gleiche Klasse nochmals an, aber diesmal wurden die impliziten Abhängigkeiten entschärft.

class Device:
    _request_map = {
        'request_X': 42
        # ...
    } # Teil der Klasse, kein self


    def __init__(self, network, separator, id):
        self.network = network
        self.message_separator = separator
        self.id = id

        self.message_buffer = bytes()
        # self.requests wird nicht mehr benötigt
        self.connected = False

        self.setup()

    def setup(self):
        self.my_addr = self.network.get_next_free_address()
        if self.is_valid_addr(self.my_addr):
           self.connected = True

    def data_received(self, data):
        self.message_buffer += data
        requests, uncomplete = self.parse_data(self.message_separator, self.message_buffer)
        self.respond(self.network, requests)
        self.message_buffer = uncomplete

    @classmethod
    def respond(cls, network, requests):
        for req in requests:
            resp = cls.lookup_response(req)
            network.send(resp)

    @classmethod
    def parse_data(cls, message_separator, message_buffer):
        requests = []
        for msg in message_buffer.split(message_separator):
            # ...
             requests.append(request)
        return requests, uncomplete_message

    @classmethod
    def lookup_response(cls, request):
         if request in cls._request_map:
            return cls._request_map[request]
        return None

    @staticmethod
    def is_valid_addr(addr):
        #...
        return is_valid

In der im obigen Beispiel gezeigten Implementierung wurde versucht, Logik und Funktionalität auf die Klasse selbst zu verlagern, nicht auf konkrete Instanzen. Die Unterschiede im Vergleich zu der Implementierung in Beispiel 3 sind in fett hervorgehoben. Der große Vorteil besteht darin, dass parse_data(),lookup_response(), is_valid_addr() und respond() vollständig getestet werden können, ohne jemals eine Instanz der Klasse Device erzeugen zu müssen. Bis auf respond() ist es auch nicht notwendig, einen Stub der Netzwerkklasse zur Verfügung zu stellen. Ein Stub der Netzwerkklasse für einen Test von respond()benötigt lediglich ein send() und ist damit erheblich einfacher bereitzustellen als ein Stub, der für die gesamte Klasse genügt.

Ein Unit Test könnte wie folgt aussehen:

from Somewhere import Device

def test_parse_data():
    sep = ";"
    inputs = [...]
    expected_outputs = [...]
    for inp, expected in zip(inputs, expected_outputs):
        output = Device.parse_data(sep, inp)
        assert output == expected

Beispiel 5 hier zeigt eine mögliche Implementierung eines Testfalls für parse_data() aus der Implementierung in Beispiel 4. Der Testfall erstellt eine Liste von Eingabewerten und eine Liste der erwarteten Ausgaben. Anschließend wird über diese Spezifikationen iteriert und sichergestellt, dass für jede Eingabe die erwartete Ausgabe erfolgt. Die Funktion zip() wird genutzt, um jeden Input mit dem jeweils erwarteten Output zu verknüpfen.

Einfacher kann ein Unit Test nicht aussehen. In diesem Test wird kein Objekt der Klasse Device erzeugt. Die Methode der Klasse kann fast wie eine normale Funktion benutzt werden, sie muss nur über das Objekt Device angesprochen werden. In dieser Situation verhält sich die Klasse Device wie ein namespace in C++. Vergleichen wir dies mit einem Unit Test für die erste Implementierung:

from Somewhere import Device
from Unit Test.mock import Mock

class NetworkMock(Mock):
    def get_next_free_address(self):
        # ...
    def register(self, id):
        # ...
    def send(self, data):
         # ...

def test_parse_data():
    sep = ";"
    inputs = [...]
    expected_outputs = [...]
    network = NetworkMock()
    device = Device(network, sep, 42)
    for inp, expected in zip(inputs, expected_outputs):
        device.message_buffer = inp
        device.parse_data()
        output = device.requests
        assert output == expected
        device.message_buffer = []
        device.requests = []

Es ist auffällig, wie viel Aufwand notwendig ist, bis wir die zu testende Methode soweit freigestellt haben, dass wir sie testen können. Ich möchte besonders die Notwendigkeit hervorheben, am Ende jeder Iteration der for-Schleife die Daten wieder zurückzusetzen. Nachdem die Funktion diese internen Variablen benutzt, diese aber außerhalb des echten Anwendungsfalls nicht geleert werden, würden sich die Daten anhäufen. Das ist nicht nur lästig, sondern auch ein potentielles Risiko für die Korrektheit der Testimplementierung.

Man kann einwenden, dass setup() und data_received() in der zweiten Implementierung immer noch die gleichen Probleme aufweisen. Das ist korrekt, aber ein Kompromiss der in der Regel eingegangen werden muss. Im Falle einer unvollständigen Nachricht muss diese bis zum Eintreffen neuer Bytes erhalten bleiben. Dies kann nur geschehen indem diese Daten bis zum nächsten Aufruf von data_received im Speicher bleiben. Die Daten im Zustand der Klasse abzulegen ist der einzige Weg. Die data_received Methode muss daher zustandsbehaftet bleiben. Bei genauerem Hinsehen fällt jedoch auf, das weder setup noch data_received substanzielle eigene Logik implementieren. In diesen Funktionen wird über bereits vorhandene Ergebnisse iteriert beziehungsweise Funktionen der Netzwerkklasse aufgerufen. Mit den Unit Tests haben wir abgesichert, dass die Ergebnisse, über die iteriert wird, korrekt sind. Die Tests der Klasse Device sollten nicht dazu missbraucht werden, auch die Netzwerkklasse abzutesten. An dieser Stelle sollte dafür gesorgt werden, dass für diese Klasse ein eigener Unit Test existiert.

Unter diesen Umständen wäre es vertretbar keinen Test für setup und data_received zu implementieren. Eine solche Entscheidung ist aber stark fallabhängig.

Unit Tests mit Python können mitunter ein Umdenken erfordern

Die gesammelten Erkenntnisse lassen sich wie folgt zusammenfassen:

  • self so wenig wie möglich verwenden,
  • @classmethod und @staticmethod dagegen so häufig wie möglich.
  • Möglichst viele Attribute als Klassenattribute anlegen.
  • Explizite Argumentübergaben und Funktionsrückgaben sind den impliziten Varianten vorzuziehen.
  • Jede eigenständige logische Einheit der Klasse als eigene Funktion anlegen.
  • Input/Output sollte in so wenigen Funktionen wie möglich verwendet werden

Manche dieser genannten Punkte erfordern ein Umdenken beziehungsweise einen Stilbruch zu bestehenden Gepflogenheiten. Zum Beispiel sollten Klassenattribute über einen expliziten Funktionsaufruf einer classmethod oder staticmethod neue Werte zugewiesen werden anstatt diesen inmitten von anderer Logik Zwischenergebnisse zuzuweisen. Die Investition in diese Maßnahmen zahlt sich aber unmittelbar dadurch aus, dass der Aufwand und die Wartbarkeit für Tests erheblich reduziert werden.

Beispiel

self.x = get_new_value(a, b, c)

ist besser als


a = y[-1]
b = self.z[0]
self.x = y[a:b]
for i in y:
….

Der Autor

Der Autor: Tobias Pleyer, MixedMode.
Der Autor: Tobias Pleyer, MixedMode.
(Bild: MixedMode GmbH)

* Tobias Pleyer ist Master of Science Physik. Er hat Erfahrung in den Branchen Automotive und Medizin als State Event Machine Entwickler, Projectengineer, Continuous Integration Engineer und Testingenieur gesammelt.

(ID:45478099)