Testen in Python: Komponententests mithilfe des unittest-Moduls schreiben

Wir sind uns einig, dass das Testen wichtig ist, aber wie läuft das ganz konkret, wenn wir Software in Python entwickeln und diese testen möchten? Sehen wir uns an, was wir dafür brauchen.

Das unittest-Modul

Seit langer Zeit bringt Python das unittest-Modul mit, das sich hervorragend für Komponententests einsetzen lässt. Um mit unittest zu arbeiten, benötigt man eine eigene Testdatei, die mit der Anweisung

import unittest

beginnt. Anschließend erstellen wir eine Klasse, die von TestCase erbt. Darin schreiben wir unsere Tests als Methoden und können diese ausführen. In diesen Methoden verwenden wir das Schlüsselwort assert.

Das doctest-Modul verwenden wir nicht

Das Testen mit dem unittest-Modul ist nicht die einzige Art und Weise Code in Python zu prüfen. Es gibt daneben auch doctest, womit man ebenfalls Tests schreiben kann. Hierbei wird der Testcode als Kommentar direkt in die Methode selbst implementiert. An dieser Stelle verzichten wir auf einen genaueren Blick darauf, da das Vorgehen mithilfe des unittest-Moduls einen übersichtlicheren Ansatz darstellt. Darüber hinaus entspricht das Vorgehen, Tests in eine eigene Testdatei auszulagern, der Art und Weise wie es auch in anderen Sprachen gängige Praxis ist.

Ganz konkret am Beispiel von FizzBuzz: Wie schreibe ich einen Test?

Damit das Vorgehen klarer wird, erstellen wir ein kleines Projekt: Wir schreiben das beliebte „FizzBuzz“ und testen es gleichzeitig. In FizzBuzz lautet die Aufgabe, dass das Programm „Fizz“ zurückmeldet, wenn eine Zahl ohne Rest durch 3 teilbar ist. Es soll „Buzz“ ausgeben, wenn eine Zahl ohne Rest durch 5 teilbar ist und es soll „FizzBuzz“ schreiben, wenn eine Zahl sowohl durch 3 als auch durch 5 teilbar ist. Ist eine Zahl nicht restlos durch 3 und 5 teilbar, dann wird einfach die Zahl ausgegeben.

Dafür erstellen wir ein Verzeichnis namens „FizzBuzz“ und legen darin die Dateien „TestFizzBuzz.py“ und „FizzBuzz.py“ an. Da wir testgetrieben entwickeln (TDD), starten wir mit der TestFizzBuzz.py-Datei.

Erst die Tests

Wir beginnen mit dem unittest-Modul:

import unittest

Anschließend importieren wir, was wir testen möchten. Um das Testverfahren etwas offensichtlicher zu machen, gehen wir etwas umständlicher vor und schreiben für jeden der drei möglichen Fälle eine eigene Methode:

from FizzBuzz import fizz, buzz, fizzbuzz

Jetzt ist die Klasse an der Reihe. Wichtig ist hierbei, dass wir von TestCase erben:

class TestFizzBuzz(unittest.TestCase):

Nun formulieren wir die einzelnen Tests. Damit die einzelnen Methoden als Tests erkennbar sind, müssen sie mit dem Schlüsselwort test beginnen. Innerhalb dieser Methoden prüfen wir, ob die aufgerufene Methode in der zu testenden Datei auch das von uns erwartete Ergebnis zurückliefert.

Senden wir beispielsweise die Zahl 6 an die fizz-Methode, erwarten wir den String „Fizz“ zurück. Senden wir die Zahl 7, erwarten wir eine Zahl 7 zurück. Die Gleichheit überprüfen wir mit der assertEqual-Methode aus Testcase. Hinter den Wert, den wir erwarten, schreiben wir den Text, der ausgegeben werden soll, falls unser Test fehlschlägt.

def test_fizz(self):
    self.assertEqual(fizz(6), "Fizz", "Should be Fizz")
    self.assertEqual(fizz(7), 7, "Should be 7")

def test_buzz(self):
    self.assertEqual(buzz(10), "Buzz", "Should be Buzz")
    self.assertEqual(buzz(9), 9, "Should be 9")

def test_fizzbuzz(self):
    self.assertEqual(fizzbuzz(15), "FizzBuzz", "Should be FizzBuzz")
    self.assertEqual(fizzbuzz(14), 14, "Should be 14")

Wenn wir jetzt die Tests ausführen, dann scheitern sie alle, denn es gibt ja noch keine der aufgerufenen Funktionen:

Traceback (most recent call last):
File "TestFizzBuzz.py", line 2, in <module>
from FizzBuzz import fizz, buzz, fizzbuzz
ImportError: cannot import name 'fizz' from 'FizzBuzz' (FizzBuzz.py)

Anschließend die zu testenden Methoden

Damit unsere Tests zumindest zum Teil laufen, schreiben wir jeweils einen Rumpf der drei Methoden in unsere FizzBuzz.py-Datei:

def fizz(possible_fizz):
    return possible_fizz

def buzz(possible_buzz):
    return possible_buzz

def fizzbuzz(possible_fizzbuzz):
    return possible_fizzbuzz

Hiermit haben wir schon einmal die Vorgabe umgesetzt, dass die Zahl zurückgegeben werden soll, wenn sie nicht restlos durch 3 respektive 5 oder 3 und 5 teilbar ist. Dieser Teil unserer Tests müsste nun funktionieren. Also führen wir die Tests abermals aus und bekommen die Ausgabe:

FFF
======================================================================
FAIL: test_buzz (__main__.TestFizzBuzz)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "TestFizzBuzz.py", line 15, in test_buzz
    self.assertEqual(buzz(10), "Buzz", "Should be Buzz")
AssertionError: 10 != 'Buzz' : Should be Buzz
======================================================================
FAIL: test_fizz (__main__.TestFizzBuzz)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "TestFizzBuzz.py", line 11, in test_fizz
    self.assertEqual(fizz(6), "Fizz", "Should be Fizz")
AssertionError: 6 != 'Fizz' : Should be Fizz
======================================================================
FAIL: test_fizzbuzz (__main__.TestFizzBuzz)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "TestFizzBuzz.py", line 19, in test_fizzbuzz
    self.assertEqual(fizzbuzz(15), "FizzBuzz", "Should be FizzBuzz")
AssertionError: 15 != 'FizzBuzz' : Should be FizzBuzz
----------------------------------------------------------------------
Ran 3 tests in 0.001s
FAILED (failures=3)

Wenn wir also 7 an unsere fizz-Methode senden, bekommen wir keinen Fehler. Genauso verhält es sich, wenn wir 9 an buzz und 14 an fizzbuzz senden. Das bedeutet also, dass diese Tests erfolgreich verlaufen, nun müssen wir uns darum kümmern, dass auch der jeweils andere Test erfolgreich ist. Wir könnten die Methoden in der FizzBuzz.py-Datei auf folgende Weise ergänzen:

def fizz(possible_fizz):
    if possible_fizz % 3 == 0:
        return "Fizz"
    return possible_fizz

def buzz(possible_buzz):
    if possible_buzz % 5 == 0:
        return "Buzz"
    return possible_buzz

def fizzbuzz(possible_fizzbuzz):
    if (possible_fizzbuzz % 3 == 0) and (possible_fizzbuzz % 5 == 0):
        return "FizzBuzz"
    return possible_fizzbuzz

Führen wir unsere Tests nun aus, dann erhalten wir als Rückmeldung:

...
----------------------------------------------------------------------
Ran 3 tests in 0.000s
OK

Hurra, unsere Tests laufen erfolgreich durch. Jede einzelne unserer Methoden tut, was sie soll. Sind wir damit fertig? Leider noch nicht, denn die Aufgabe war ja, dass wir irgendeine Zahl prüfen und Fizz, Buzz beziehungsweise FizzBuzz oder die Zahl zurückerhalten, wenn keine der Regel greift. Wir brauchen also eine weitere Methode, die unsere bisherigen Methoden aufruft.

Wieder beginnen wir mit unserer TestFizzBuzz.py-Datei. Darin machen wir aus der Zeile

from FizzBuzz import fizz, buzz, fizzbuzz

die Zeile

from FizzBuzz import fizz, buzz, fizzbuzz, checkNumber

Anschließend ergänzen wir die bereits vorhandenen Testmethoden um eine weitere:

def test_checkNumber(self):
    self.assertEqual(checkNumber(6), "Fizz", "Should be Fizz")
    self.assertEqual(checkNumber(7), 7, "Should be 7")
    self.assertEqual(checkNumber(10), "Buzz", "Should be Buzz")
    self.assertEqual(checkNumber(9), 9, "Should be 9")
    self.assertEqual(checkNumber(15), "FizzBuzz", "Should be FizzBuzz")
    self.assertEqual(checkNumber(14), 14, "Should be 14")
    self.assertEqual(checkNumber(99), "Fizz", "Should be Fizz")
    self.assertEqual(checkNumber(100), "Buzz", "Should be Buzz")
    self.assertEqual(checkNumber(45), "FizzBuzz", "Should be FizzBuzz")
    self.assertEqual(checkNumber(101), 101, "Should be 101")

Wir können aus dem Vollen schöpfen. Wir können nicht nur, die bereits vorhandenen Tests abermals hinzuziehen, indem wir deren Inhalte einfach in unseren neuen Test kopieren, sondern wir können uns auch ein paar neue Testfälle mit Zahlen wie 45, 99, 100 und 101 ausdenken.

In unsere FizzBuzz.py-Datei fügen wir hinzu:

def checkNumber(numberToCheck):
    return numberToCheck

Wir speichern beide Dateien und führen die Tests in TestFizzBuzz.py aus.

Kurz innehalten: Was erwarten wir als Antwort?

Wir nehmen an, dass all diejenigen Tests erfolgreich sind, die von der Methode „checkNumber“ kein „Fizz“, kein „Buzz“ oder „FizzBuzz“ erwarten. Allerdings erwarten wir Fehler zurück, denn unsere checknumber-Methode liefert ja gerade kein „Fizz“, kein „Buzz“ oder „FizzBuzz“ zurück. Python schreibt uns als Ergebnis der Ausführung unserer Tests:

.F..
======================================================================
FAIL: test_checkNumber (__main__.TestFizzBuzz)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "TestFizzBuzz.py", line 23, in test_checkNumber
    self.assertEqual(checkNumber(6), "Fizz", "Should be Fizz")
AssertionError: 6 != 'Fizz' : Should be Fizz
----------------------------------------------------------------------
Ran 4 tests in 0.001s
FAILED (failures=1)

Der Test hat recht: Es wurde 6 gesendet und „Fizz“ erwartet, es kam aber 6 zurück, das ist nicht im Sinne der Aufgabe. Es hilft alles nichts, wir müssen die Methode „checkNumber“ in FizzBuzz.py implementieren. Dies können wir beispielsweise auf folgende Weise tun:

def checkNumber(numberToCheck):
    if fizzbuzz(numberToCheck) == "FizzBuzz":
        return "FizzBuzz"
    elif buzz(numberToCheck) == "Buzz":
        return "Buzz"
    elif fizz(numberToCheck) == "Fizz":
        return "Fizz"
    return numberToCheck

Nun sollten die Tests funktionieren, wieder speichern wir beide Dateien und führen die Tests in TestFizzBuzz.py aus. Das Resultat lautet:

.F..
======================================================================
FAIL: test_checkNumber (__main__.TestFizzBuzz)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "TestFizzBuzz.py", line 26, in test_checkNumber
    self.assertEqual(checkNumber(9), 9, "Should be 9")
AssertionError: 'Fizz' != 9 : Should be 9
----------------------------------------------------------------------
Ran 4 tests in 0.001s
FAILED (failures=1)

Ups? Was ist da los? Warum funktioniert unsere checknumber-Methode nicht?

Sehen wir uns die Meldung einmal genauer an, stellen wir fest, dass dort steht, dass in der Datei TestFizzBuzz.py in Zeile 26 die Methode „checknumber“ mit dem Wert 9 ausgeführt, die Zahl 9 erwartet wird, aber „Fizz“ zurückkommt und „Fizz“ ist schließlich nicht 9, also schlägt der Test fehl.

Denkt man kurz nach, stellt man fest, dass unser aus der Testmethode „test_buzz“ kopierter Testfall nicht den Anforderungen unserer allumfassenden „checkNumber“-Methode entspricht, denn hier sollte der Parameter 9 ein „Fizz“ zurückliefern.

Wir ändern die Zeile

    self.assertEqual(checkNumber(9), 9, "Should be 9")

in

    self.assertEqual(checkNumber(9), "Fizz", "Should be Fizz")

Das sollte es nun sein. Erneut speichern wir die Datei TestFizzBuzz.py und führen die Tests in TestFizzBuzz.py aus. Das Resultat lautet nun:

....
----------------------------------------------------------------------
Ran 4 tests in 0.000s
OK

Jippie! \o/

Fazit

Alle unsere Tests waren erfolgreich. Wir lehnen uns stolz zurück, denn wir haben nicht nur die Aufgabe gemeistert, einen FizzBuzz-Algorithmus zu schreiben, sondern wir haben es auch geschafft, das Ganze testgetrieben zu entwickeln. Alle von uns implementierten Methoden sind von Tests abgedeckt.

Photo by Sarah Pflug from Burst