Zum Inhalt
wannacode

Tests mit unittest

Kurz gesagt: Ein Unit-Test ist Code, der bei jeder Änderung ein kleines Stück prüft, meist eine Funktion; in Python schreibst du ihn mit unittest und assertEqual.

Problem

Der Rabatt, der leise kaputtging

Die Regel des Shops lautet: Der Rabatt ist in Prozent, und bei einem Einkauf ab 100 € kommen noch 5% dazu. Du schreibst die Funktion und probierst sie von Hand aus:

main.py
def apply_discount(price, percent):
# Rabatt in Prozent. Ab 100 € kommen noch 5% dazu.
if price > 100:
percent = percent + 5
return price * (100 - percent) / 100
print(apply_discount(50, 10))
print(apply_discount(200, 10))
Ausgabe
45.0
170.0

50 € mit 10% Rabatt sind 45 €, und 200 € mit 15% sind 170 €. Das sieht richtig aus, also gibst du es frei. Eine Woche später zahlt eine Kundin mit einem Einkauf von genau 100 € 90 € statt 85 €. Die Bedingung ist price > 100, sie müsste price >= 100 sein.

Niemand hat genau 100 € ausprobiert. Und bei jeder weiteren Änderung an der Funktion musst du alle Fälle wieder von Hand ausprobieren.

Die Idee

Code, der Code prüft

Statt die Ausgabe mit eigenen Augen zu lesen, schreibst du kleine Methoden, die deinen Code aufrufen und das Ergebnis mit dem erwarteten vergleichen. Das ist ein Test. Du führst alle Tests mit einem Klick aus, nach jeder Änderung, und sie sagen dir, was kaputtgegangen ist.

Art Was sie prüft Beispiel
Unit-Test ein kleines Stück, meist eine Funktion, getrennt vom Rest apply_discount(100, 10) gibt 85 zurück
Integrationstest wie die Stücke zusammenarbeiten: mit einer Datenbank, einer Datei, einem anderen Dienst die Bestellung wird in der Datenbank gespeichert und wieder gelesen
End-to-End-Test die ganze Anwendung, so wie ein Mensch sie benutzt die Kundin legt etwas in den Warenkorb, bezahlt und bekommt eine E-Mail
In dieser Lektion schreiben wir Unit-Tests. Python hat dafür das Modul unittest in der Standardbibliothek: Es muss nichts installiert werden.

Warum es sich lohnt:

  • Du siehst den Fehler sofort, solange du noch weißt, was du geändert hast, und nicht erst, wenn sich die Kundin beschwert;
  • der Test zeigt, wie der Code benutzt wird: welcher Fall was zurückgibt;
  • du kannst den Code mutig aufräumen. Wenn die Tests nach der Änderung immer noch grün sind, hat sich das Verhalten nicht geändert.
Genauer, für Neugierige

Es gibt auch andere Arten von Tests: funktionale (erfüllt das Programm die Anforderungen), Performancetests (wie viele Anfragen es aushält), Sicherheitstests (gibt es Lücken) und Tests der Benutzeroberfläche (die Buttons, die Formulare). Alle bauen auf Unit-Tests auf: Von ihnen gibt es die meisten, und sie sind am schnellsten und am billigsten zu pflegen.

Neben unittest wirst du in Python oft pytest begegnen: eine eigene Bibliothek (pip install pytest), in der Tests normale Funktionen mit assert sind. Hier nehmen wir unittest, weil es mit Python selbst kommt.

Sieh es dir an

Der erste Test

apply_discount steht in der Datei discount.py. Ihre Tests stehen in einer eigenen Datei, test_discount.py, in der Klasse DiscountTest:
test_discount.py
import unittest
from discount import apply_discount
class DiscountTest(unittest.TestCase):
def test_ten_percent_off(self):
self.assertEqual(apply_discount(50, 10), 45.0)
def test_extra_five_percent_from_100(self):
self.assertEqual(apply_discount(100, 10), 85.0)
  • Die Testklasse erbt von unittest.TestCase. Jede Methode, deren Name mit test beginnt, ist ein Test: unittest findet sie von selbst über den Namen. Wie genau, siehst du in der letzten Lektion des Moduls.
  • self.assertEqual(a, b) vergleicht die beiden Werte. Wenn sie verschieden sind, schlägt der Test fehl und sagt a != b. Hier schreiben wir zuerst, was der Code zurückgegeben hat, und dann den erwarteten Wert.

Wir führen die beiden Tests gegen den Code aus der ersten Karte aus:

Ergebnis
FEHLER test_extra_five_percent_from_100: 90.0 != 85.0
OK test_ten_percent_off
Tests: 2, bestanden: 1, fehlgeschlagen: 1

Der erste Test besteht. Der zweite schlägt fehl und sagt genau, was passiert ist: Er hat 85.0 erwartet, und die Funktion hat 90.0 zurückgegeben. Du änderst die Bedingung in price >= 100, führst sie noch einmal aus, und beide sind grün:

Ergebnis
OK test_extra_five_percent_from_100
OK test_ten_percent_off
Tests: 2, bestanden: 2, fehlgeschlagen: 0

Jeder Test macht drei Dinge: Er bereitet die Werte vor, ruft den Code auf und prüft das Ergebnis.

class DiscountTest(unittest.TestCase):
def test_ten_percent_off(self):
self.assertEqual(apply_discount(50, 10), 45.0)
def test_extra_five_percent_from_100(self):
self.assertEqual(apply_discount(100, 10), 85.0)
Schritt für Schritt, in Worten
  1. DiscountTest hat zwei Tests. unittest führt jeden einzeln aus und schaltet ein Lämpchen daneben ein.
  2. Test 1, vorbereiten: ein Preis von 50 € und 10% Rabatt.
  3. Ausführen: apply_discount(50, 10) gibt 45.0 zurück.
  4. Prüfen: assertEqual vergleicht den erwarteten Wert 45.0 mit dem zurückgegebenen 45.0. Sie sind gleich, also besteht der Test.
  5. Test 2: genau 100 € und 10%. Wir erwarten 85.0, weil es ab 100 € noch 5% gibt.
  6. Die Bedingung in der Funktion ist price > 100, und 100 ist nicht größer als 100. Die Funktion gibt 90.0 zurück.
  7. Erwartet 85.0, zurückgegeben 90.0: Der Test schlägt fehl und sagt genau das.
  8. Du änderst die Bedingung in price >= 100 und führst die Tests noch einmal aus.
  9. Jetzt erfüllt 100 die Bedingung, und die Funktion gibt 85.0 zurück.
  10. Erwartet und zurückgegeben stimmen überein. Beide Tests sind grün.
Genauer, für Neugierige
  • Im Projekt sind der Code und die Tests Dateien nebeneinander. Testdateien beginnen mit test, damit unittest sie findet:
Struktur
discount.py der Code
test_discount.py die Tests dazu
  • Du führst sie im Terminal aus. Ohne -v gibt unittest für jeden bestandenen Test einen Punkt aus und für jeden fehlgeschlagenen ein F, danach die Einzelheiten zu den fehlgeschlagenen:
Befehle
python -m unittest findet test*.py und führt sie aus
python -m unittest -v mit einer Zeile für jeden Test
python -m unittest test_discount.DiscountTest.test_ten_percent_off
  • Hier auf der Seite stehen der Code und die Tests in einer Datei, und die Tests werden über die Programmierschnittstelle von unittest ausgeführt: Ein eigenes TestResult sammelt das Ergebnis jedes Tests, und run_tests gibt sie nach Namen sortiert aus. Das Sortieren hält die Ausgabe bei jedem Lauf gleich:
Der Test-Runner
def name_of(test):
# Der Docstring des Tests oder der Name der Methode (mit dem Fall aus subTest).
return test.shortDescription() or test.id().split(".", 2)[2]
class Report(unittest.TestResult):
def __init__(self):
super().__init__()
self.lines = []
self.failed = 0
self.with_cases = set()
def passed(self, name):
self.lines.append(f"OK {name}")
def fell(self, name, err):
self.failed += 1
self.lines.append(f"FEHLER {name}: {err[1]}")
def addSuccess(self, test):
if test.id() not in self.with_cases:
self.passed(name_of(test))
def addFailure(self, test, err):
self.fell(name_of(test), err)
addError = addFailure
def addSubTest(self, test, subtest, err):
self.with_cases.add(test.id())
name = subtest.id().split(".", 2)[2]
if err is None:
self.passed(name)
else:
self.fell(name, err)
def run_tests(*tests):
loader = unittest.TestLoader()
suite = unittest.TestSuite()
for t in tests:
suite.addTest(t if isinstance(t, unittest.TestSuite) else loader.loadTestsFromTestCase(t))
report = Report()
suite.run(report)
for line in sorted(report.lines):
print(line)
total = len(report.lines)
print(f"Tests: {total}, bestanden: {total - report.failed}, fehlgeschlagen: {report.failed}")

Probier es aus

Mehrwertsteuer mit float

Der Preis mit Mehrwertsteuer ist der Preis mal 1,2. Zwei Tests:

Was zeigen die Tests?

def with_vat(price):
return price * 1.2
class VatTest(unittest.TestCase):
def test_coffee(self):
self.assertEqual(with_vat(10), 12.0)
def test_tea(self):
self.assertEqual(with_vat(3), 3.6)
Antwort

Die Idee

Ja oder nein, und eine erwartete Exception

Neben Gleichheit prüfst du oft eine Bedingung oder dass der Code ablehnt. Hier ist Account aus dem Modul über Exceptions, mit einer neuen Methode can_pay:

account.py
class InsufficientFundsError(Exception):
def __init__(self, shortfall):
super().__init__(f"Es fehlen {shortfall} €")
class Account:
def __init__(self, balance):
self._balance = balance
@property
def balance(self):
return self._balance
def can_pay(self, amount):
return amount <= self._balance
def withdraw(self, amount):
if amount <= 0:
raise ValueError("Der Betrag muss positiv sein")
if amount > self._balance:
raise InsufficientFundsError(amount - self._balance)
self._balance -= amount
test_account.py
import unittest
from account import Account, InsufficientFundsError
class AccountTest(unittest.TestCase):
def test_can_pay_what_is_there(self):
account = Account(100)
self.assertTrue(account.can_pay(100))
self.assertFalse(account.can_pay(101))
def test_withdraw_too_much(self):
account = Account(100)
with self.assertRaises(InsufficientFundsError) as caught:
account.withdraw(130)
self.assertEqual(str(caught.exception), "Es fehlen 30 €")
self.assertEqual(account.balance, 100)
Ausgabe
OK test_can_pay_what_is_there
OK test_withdraw_too_much
Tests: 2, bestanden: 2, fehlgeschlagen: 0
  • assertTrue und assertFalse prüfen eine Bedingung.
  • with self.assertRaises(Art): schlägt fehl, wenn der Code im Block keine Exception dieser Art auslöst. Mit as caught bekommst du die Exception selbst: caught.exception, und str() davon ist die Meldung.

Der zweite Test prüft auch etwas, das man leicht vergisst: Nach der Ablehnung ist der Kontostand derselbe.

Die Idee

Jeder Test beginnt sauber

Hier teilen sich die beiden Tests ein Konto mit 100 €:

test_account.py
class AccountTest(unittest.TestCase):
account = Account(100)
def test_withdraw_40(self):
"""40 € abheben lässt 60 € übrig"""
self.account.withdraw(40)
self.assertEqual(self.account.balance, 60)
def test_withdraw_70(self):
"""70 € abheben lässt 30 € übrig"""
self.account.withdraw(70)
self.assertEqual(self.account.balance, 30)
Ausgabe
FEHLER 70 € abheben lässt 30 € übrig: Es fehlen 10 €
OK 40 € abheben lässt 60 € übrig
Tests: 2, bestanden: 1, fehlgeschlagen: 1

account ist ein Klassenattribut: ein Objekt für alle Tests. unittest führt die Tests in alphabetischer Reihenfolge ihrer Namen aus, deshalb ist der Test für 40 € der erste. Beim zweiten sind noch 60 € übrig, und er hebt 70 € ab. Es reicht, dass jemand einen Test umbenennt oder einen neuen hinzufügt, und ein anderer Test schlägt fehl. Ein Test, der von einem anderen Test abhängt, ist eine Falle. Deshalb wird das Konto vor jedem Test neu erstellt, in der Methode setUp:

test_account.py
import unittest
from account import Account
class AccountTest(unittest.TestCase):
def setUp(self):
self.account = Account(100)
def test_withdraw_40(self):
"""40 € abheben lässt 60 € übrig"""
self.account.withdraw(40)
self.assertEqual(self.account.balance, 60)
def test_withdraw_70(self):
"""70 € abheben lässt 30 € übrig"""
self.account.withdraw(70)
self.assertEqual(self.account.balance, 30)
Ausgabe
OK 40 € abheben lässt 60 € übrig
OK 70 € abheben lässt 30 € übrig
Tests: 2, bestanden: 2, fehlgeschlagen: 0
unittest erstellt für jeden Test ein neues Objekt der Klasse und ruft davor setUp auf, deshalb ist self.account jedes Mal ein neues Konto. Der String unter def (der Docstring) beschreibt den Test in Worten: Seine erste Zeile wird im Ergebnis statt des Methodennamens angezeigt.
Genauer, für Neugierige
Weitere Dinge aus unittest, denen du begegnen wirst:
unittest
setUp / tearDown vor / nach jedem Test (Aufräumen)
setUpClass / tearDownClass einmal für die ganze Klasse (@classmethod)
@unittest.skip("...") der Test wird vorerst übersprungen
@unittest.skipIf(Bedingung, "...") übersprungen, wenn die Bedingung wahr ist
@unittest.expectedFailure es wird erwartet, dass er fehlschlägt
with self.subTest(...): ein eigener Fall innerhalb eines Tests
self.addCleanup(Funktion) Aufräumen nach dem Test, auch wenn er fehlgeschlagen ist
python -m unittest -k withdraw nur die Tests mit „withdraw“ im Namen
python -m unittest -f hält beim ersten fehlgeschlagenen Test an

Die Idee

Eine Prüfung, viele Fälle

Vier Fälle für den Rabatt bedeuten vier fast gleiche Methoden. Stattdessen schreibst du eine Methode mit einer Schleife über die Fälle „Preis, Prozent, erwartet“. with self.subTest(...) macht jeden Fall zu einer eigenen Prüfung:

test_discount.py
import unittest
from discount import apply_discount
class DiscountTest(unittest.TestCase):
def test_apply(self):
cases = [
(50, 10, 45.0),
(99, 0, 99.0),
(100, 10, 85.0),
(200, 20, 150.0),
]
for price, percent, expected in cases:
with self.subTest(f"{price} € mit {percent}% = {expected} €"):
self.assertEqual(apply_discount(price, percent), expected)
Ausgabe
OK test_apply [100 € mit 10% = 85.0 €]
OK test_apply [200 € mit 20% = 150.0 €]
OK test_apply [50 € mit 10% = 45.0 €]
OK test_apply [99 € mit 0% = 99.0 €]
Tests: 4, bestanden: 4, fehlgeschlagen: 0

Jeder Fall hat ein eigenes Ergebnis, und der Text in subTest ist sein Name. Ohne subTest stoppt der erste fehlgeschlagene Fall die ganze Methode, und du erfährst nicht, ob die anderen bestehen. Ein neuer Fall ist eine neue Zeile in der Liste, keine neue Methode. Die wichtigen Fälle liegen an den Grenzen: genau 100 €, 99 €, 0%.

Probier es aus

Finde den Fehler anhand des Tests

Die Lieferung kostet 4 €, ab 50 € ist sie kostenlos. Der Test:

test_delivery.py
class DeliveryTest(unittest.TestCase):
def test_price(self):
for total, expected in [(20, 4), (50, 0), (80, 0)]:
with self.subTest(f"Bestellung über {total} €: Lieferung {expected} €"):
self.assertEqual(delivery_price(total), expected)
Ausgabe
FEHLER test_price [Bestellung über 50 €: Lieferung 0 €]: 4 != 0
OK test_price [Bestellung über 20 €: Lieferung 4 €]
OK test_price [Bestellung über 80 €: Lieferung 0 €]
Tests: 3, bestanden: 2, fehlgeschlagen: 1

In welcher Zeile des Codes ist der Fehler?

Wähle die Zeile mit dem Fehler.

Die Idee

Zuerst der Test

Die Kasse soll den Gutscheincode HERBST10 für 10% Rabatt annehmen, egal ob er klein oder groß geschrieben ist. Diesmal schreibst du zuerst die Tests:

test_promo.py
class PromoTest(unittest.TestCase):
def test_known_code(self):
self.assertEqual(promo_percent("HERBST10"), 10)
def test_code_is_case_insensitive(self):
self.assertEqual(promo_percent("herbst10"), 10)
def test_unknown_code(self):
self.assertEqual(promo_percent("KEINER"), 0)
  1. Rot. Vorerst gibt die Funktion 0 zurück. Die Tests schlagen fehl, und das ist gut: Es bedeutet, dass sie wirklich etwas prüfen.
def promo_percent(code):
return 0
Ausgabe
FEHLER test_code_is_case_insensitive: 0 != 10
FEHLER test_known_code: 0 != 10
OK test_unknown_code
Tests: 3, bestanden: 1, fehlgeschlagen: 2
  1. Grün. Du schreibst den einfachsten Code, mit dem sie bestehen:
def promo_percent(code):
if code.upper() == "HERBST10":
return 10
return 0
Ausgabe
OK test_code_is_case_insensitive
OK test_known_code
OK test_unknown_code
Tests: 3, bestanden: 3, fehlgeschlagen: 0
  1. Aufräumen. Jetzt kannst du die Codes in ein Dictionary (dict) verschieben, neue hinzufügen und die Funktion aufräumen. Die Tests sagen dir, wenn du etwas kaputt machst.

Das ist TDD (test-driven development, testgetriebene Entwicklung). Wenn du den Test zuerst schreibst, musst du entscheiden, was genau die Funktion tun soll, bevor du darüber nachdenkst, wie.

Genauer

Suites und Ersatzobjekte

Eine Suite (unittest.TestSuite) ist eine Gruppe von Tests, die zusammen ausgeführt werden, zum Beispiel alle Tests der Kasse. Die Funktion baut sie aus den drei Testklassen zusammen, und TextTestRunner führt sie aus:

test_checkout.py
def checkout_suite():
loader = unittest.TestLoader()
suite = unittest.TestSuite()
suite.addTests(loader.loadTestsFromTestCase(DiscountTest))
suite.addTests(loader.loadTestsFromTestCase(AccountTest))
suite.addTests(loader.loadTestsFromTestCase(DeliveryTest))
return suite
unittest.TextTestRunner().run(checkout_suite())
Ausgabe
OK 40 € abheben lässt 60 € übrig
OK 70 € abheben lässt 30 € übrig
OK test_extra_five_percent_from_100
OK test_price [Bestellung über 20 €: Lieferung 4 €]
OK test_price [Bestellung über 50 €: Lieferung 0 €]
OK test_price [Bestellung über 80 €: Lieferung 0 €]
OK test_ten_percent_off
Tests: 7, bestanden: 7, fehlgeschlagen: 0

Die Ausgabe stammt vom Test-Runner der Seite. python -m unittest macht dasselbe ohne Suite: Es sammelt die Tests von selbst aus allen test*.py-Dateien.

Genauer, für Neugierige
  • Manchmal hängt der Code von etwas ab, das du in einem Test nicht aufrufen willst: ein Wechselkurs von einer Bank, eine Zahlung, eine E-Mail. Dann übergibst du ein Ersatzobjekt (Mock), das zurückgibt, was du ihm sagst. In Python ist es in der Standardbibliothek: unittest.mock.Mock:
test_converter.py
import unittest
from unittest.mock import Mock
class Converter:
def __init__(self, rates):
self.rates = rates
def to_usd(self, eur):
return eur * self.rates.eur_to("USD")
class PriceInUsdTest(unittest.TestCase):
def test_converts_with_todays_rate(self):
rates = Mock()
rates.eur_to.return_value = 1.10
converter = Converter(rates)
self.assertAlmostEqual(converter.to_usd(10), 11.0, delta=0.001)
rates.eur_to.assert_called_once_with("USD")
Ausgabe
OK test_converts_with_todays_rate
Tests: 1, bestanden: 1, fehlgeschlagen: 0

Mock() ist ein Objekt, das jede Methode annimmt. return_value sagt, was eur_to zurückgibt, und assert_called_once_with prüft, dass es genau einmal aufgerufen wurde, mit "USD". Ein Interface brauchst du nicht: Converter ruft eur_to auf allem auf, was du ihm übergibst (Duck Typing).

  • Weitere Assertions neben assertEqual, assertTrue und assertRaises:
Assertions
assertNotEqual(a, b) sind sie verschieden
assertIs(a, b), assertIsNot(a, b) ist es dasselbe Objekt (is), nicht nur gleich
assertIsNone(x), assertIsNotNone(x)
assertIn(x, items), assertNotIn(x, items)
assertCountEqual(a, b) dieselben Elemente, in beliebiger Reihenfolge
assertListEqual(a, b) zwei Listen mit denselben Elementen in derselben Reihenfolge
assertRegex(text, pattern) der Text passt zu einem regulären Ausdruck
assertGreater(a, b), assertLess(a, b)
assertAlmostEqual(a, b, delta=0.01)
self.fail("Meldung") der Test schlägt sofort fehl

Übung

Schreib die Tests

Löse die Aufgaben in deinem Editor, mit unittest (es kommt mit Python selbst).

1. Der Rabattrechner. Schreib test_discount.py für apply_discount aus der ersten Karte. Deck diese Fälle ab: 80 € mit 10% Rabatt sind 72 €, 100 € mit 0% sind 95 €, 150 € mit 20% sind 112,50 €, 0 € mit 30% sind 0 €, und genau 100 € mit 10% sind 85 €. Führ sie auch gegen die kaputte Version mit price > 100 aus. Genau die beiden Fälle mit 100 € sollen fehlschlagen:

Ergebnis gegen die kaputte Version
FEHLER test_apply [100 € mit 0% = 95.0 €]: 100.0 != 95.0 within 0.001 delta (5.0 difference)
FEHLER test_exactly_100_gets_the_extra: 90.0 != 85.0 within 0.001 delta (5.0 difference)
OK test_apply [0 € mit 30% = 0.0 €]
OK test_apply [150 € mit 20% = 112.5 €]
OK test_apply [80 € mit 10% = 72.0 €]
Tests: 5, bestanden: 3, fehlgeschlagen: 2

2. Das Konto. Schreib AccountTest für Account aus der Karte „Ja oder nein, und eine erwartete Exception“. Erstelle das Konto mit 100 € in setUp. Prüfe, dass:

  • 30 € abheben 70 € übrig lässt;
  • du alles abheben kannst und 0 übrig bleibt;
  • 150 € abheben einen InsufficientFundsError mit der Meldung „Es fehlen 50 €“ wirft und der Kontostand 100 € bleibt;
  • 0 und -20 € einen ValueError werfen (ein Test mit subTest für die beiden Beträge).

3. Der Fehler im Notendurchschnitt. Die Funktion sieht richtig aus:

grades.py
def average(grades):
total = 0
for grade in grades:
total += grade
return total // len(grades)
Schreib zwei Tests: Der Durchschnitt von 4 und 6 ist 5,0, und der Durchschnitt von 5 und 6 ist 5,5 (mit einer erlaubten Abweichung von 0,001). Einer davon schlägt fehl:
Ergebnis
FEHLER test_average_with_a_fraction: 5 != 5.5 within 0.001 delta (0.5 difference)
OK test_whole_average
Tests: 2, bestanden: 1, fehlgeschlagen: 1

Finde die Ursache und korrigiere die Funktion, damit beide bestehen.

Test

Teste dich

1. Was prüft ein Unit-Test?
2. self.assertEqual(apply_discount(100, 10), 85.0) schlägt fehl mit „90.0 != 85.0“. Was hat die Funktion zurückgegeben?
3. Warum wird das Konto in setUp erstellt und nicht einmal als Klassenattribut?
4. Wie prüfst du, dass withdraw(130) einen InsufficientFundsError auslöst?
5. Was ist die Reihenfolge bei TDD?

Erstellt von Ivo Gergov im Rahmen der Kampagne von Evo Tech für zugängliche Programmierausbildung ·