Automatische Tests

Bei komplexen Pythonprojekten ist es sinnvoll, automatische Tests einzusetzen, um die Qualität der Software zu steigern.

1Python Programmierkurs
2Python: Methoden
3Kontrollstrukturen
4Strings in Python
5Container
6Objekte in Python
7Module
8Exceptions in Python
9Typkonvertierung
10Python und Dateien
11Datum und Zeit mit Python verarbeiten
12Multithreading
13Netzwerk in Python
14Logging in Python
15GPIO
16Automatische Tests
17Datenbanken mit Python
18Python: Generatoren und List Comprehension
19Python: Webseiten mit Flask
20Python virtuelle Umgebungen
21Interrupts & Signale
22NumPy
23Matplotlib
24match

automatische Tests

Ein menschlicher Tester hat Schwächen, zum einen kennt er nicht unbedingt alle Anforderungen, die an die Software gestellt werden oder er lässt Randbedingungen bei Algorithmen aus. Wodurch Fehler erst im produktiven Umfeld erkannt werden. Diese zu beheben ist dann meistens aufwendig und die Fehlerfälle können richtig Geld kosten, wenn es z.B. um Vertragsabschlüsse geht.

Nehmen wir als Beispiel mal die Fibonacci-Folge aus dem Kapitel Netzwerk. Um die Methode zu testen, müssten wir von Hand das Ergebnis für einige Elemente berechnen und dann sehen, ob die Methode die korrekten Resultate zurückliefert. Das ist sehr mühsam, vor allem für Elemente mit n > 10 ist es schon schwieriger das im Kopf auszurechnen. Wir könnten ein einfaches Testprogramm schreiben.

from fib import fib
import logging

logging.basicConfig( format='%(asctime)-15s [%(levelname)s] %(funcName)s: %(message)s', level=logging.DEBUG)

logging.debug('Test startet')

result5 = fib(5)

result8 = fib(8)


if result5 == 8 and result8 == 21:
  logging.info('Tests erfolgreich')
else:
  logging.error('Tests fehlerhaft')

logging.debug('Testende')

Dieser Test testet genau zwei Elemente einer unendlichen Reihe, dies ist nicht sonderlich aussagekräftig. Abgesehen davon ist das Testverfahren nicht sehr normiert.
Letzteres kann durch das Paket unittest von Python behoben werden.

Unit-Test

Ein Unit-Test ist ein automatischer Test, der ein bestimmtes Modul oder eine besondere Funktion einem Test unterzieht. Glücklicherweise gibt es Frameworks in verschiedenen Programmiersprachen, die uns die Arbeit erleichtern (Java: JUnit, Python: unittest).

# coding: utf-8
import unittest
import numbers
from fib import fib
import logging
import pandas as pd

logging.basicConfig( format='%(asctime)-15s [%(levelname)s] %(funcName)s: %(message)s', level=logging.DEBUG)

class TestFib(unittest.TestCase):

# Methode zur Division a/b.
  def divide(self, a,b):
   if b == 0:
     raise ValueError('Dividend darf nicht 0 sein.')

   return int(a/b)


# Die setUp() Methode wird zu Beginn jedes Testcases aufgrufen
  def setUp(self):
    logging.debug('setting up test')
    self.results = [0,1,1,2,3,5,8,13,21,34,55]

#die beiden Tests aus ./testfib.py mit unittest
  def test_fib(self):

# assertEqual(n,r) testet, ob das Resultat r der Vorgabe n entspricht.
    self.assertEqual(5,fib(5))
    self.assertEqual(21,fib(8))

  def test_mult_fib(self):
    results =[0,1,1,2,3,5,8,13,21,34,55]

    for n in range(0,9):
     self.assertEqual(results[n],fib(n))

  def test_divide(self):

    self.assertEqual(2, self.divide(2,1))
    self.assertEqual(2, self.divide(4,2))
    self.assertEqual(3, self.divide(6,2))

    with self.assertRaises(ValueError):
      self.divide(1,0)

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


Ein Unittest wird in einer Testklasse implementiert, die von unittest.TestCase ableitet.Die Testmethoden beginnen immer mit test_. Du solltest jeden Testfall in eine separate Methode verpacken. die Testmethoden werden vom Framework automatisch rausgesucht und abgearbeitet. Ich vermute, dass die Reihenfolge nicht garantiert ist.
In unserem Beispiel oben haben wir zwei Testfälle test_fib() bildet die Funktionalität von testfib.py ab, test_mult_fib(), testet die ersten zehn Fibonacci-Zahlen durch, das ist zwar immer noch nicht sehr viel, aber deutlich mehr als vorher.

Um ein Ergebnis gegen die Erwartung zu testen, erbt deine Testklasse mehrere assert()-Methoden.Diese gibt es für int, float, String und weitere Basisdatentypen, außerdem gibt es sie zum Testen auf Gleichheit assertEqual(), Ungleichheit und boolesche Werte

MethodeVergleich
assertEqual(v,r)v == r
assertNotEqual(v,r)v != r
assertTrue(v)bool(v) == True
assertFalse(v)bool(v) == False
assertIsInstance(v,r)isinstance(v,r)
assertIn(v,r)v in r
assertNotIn(v,r)v not in r
assertIs(v,r)v is r
assertIsNot(v,r)v is not r
assertIsNone(v)v is None
assertIsNotNone(v)v is not None

Negative Ergebnisse

Ich habe es mehr als nur einmal erlebt, dass Tester nur den Idealfall testen, nicht aber Fälle, die vom definierten Ergebnis abweichen. Sieh die mal die Methode test_divide() an. Es sieht so aus, als würde sie zu mindestens stichprobenartig die Funktionalität von divide() testen.Allerdings wird ein wichtiger Ausnahmefall, der uns von der Mathematik vorgeben wird, nicht getestet, nämlich der Fall, dass der Parameter b als 0 übergeben wurde. Die Methode wird in diesem Fall korrekterweise einen ValueError, getestet wird dieses Verhalten allerdings nicht. Deshalb ist die Methode test_divide() vollständig wie folgt.

 def test_divide(self):

    self.assertEqual(2, self.divide(2,1))
    self.assertEqual(2, self.divide(4,2))
    self.assertEqual(3, self.divide(6,2))

    with self.assertRaises(ValueError):
      self.divide(1,0)

Trennung von Testdaten und Code

Der Nachteil der bisherigen Unit-Tests liegt darin, dass die Testdaten im Programmcode statisch im Programmcode eingebettet sind. Besser wäre es, dass du den Unit-Test programmierst und jemand mit dem fachlichen Hintergrund die Testdaten liefert. Zum einen spart es dir Arbeit, zum anderen muss bei Änderungen nicht unbedingt immer der Test neu angepackt werden ( was allerdings notwendig ist, wenn sich der zu testende Algorithmus geändert wird).

Die einfachsten Möglichkeiten, die Testdaten über eine externe Datei in den Test zu zu lesen sind CSV- und Excel-Dateien. Letztere haben den Vorteil, dass sie von fast jedem bearbeitet werden können. Das einlesen von CSV-Dateien, habe ich dir bereits gezeigt. Hier wollen wir mal eine Exceldatei benutzen. Zum Lesen dieses Dateiformats, benutzen wir das Pandas-Framework. Dies müssen wir zunächst mit dem Python Package Installer installieren. Falls dieser auf deinem Raspberry Pi noch nicht installiert ist, musst du ihn mit

Pandas

sudo apt install python3-pip

installieren. Was pip genau tut, erzähle ich in einem späteren Beitrag.

Jetzt kannst du das Pandas Framework installieren.

pip3 install Pandas

Der Vorgang dauert eine ganze Weile, da Pandas einige andere Pakete wie numpy, pytz und tzdata mitinstalliert.

Unter DietPiOS lief die Installation deutlich schneller ab als unter Raspberry Pi OS.

In beiden Fällen wurde dabei das Framework openpyxl nicht automatisch installiert und ich musste es manuell nachinstallieren

pip3 install openpyxl

Pandas ist ein unter BSD-Lizenz stehendes Framework, dass uns die auch die Funktionalität zum Lesen von Excel Dateien zur Verfügung stellt.

Ich habe eine Excel-Datei namens Fib-Testdaten.xlsx im Git-Repository abgelegt, die in der neuen Testmethode test_excel() geladen wird. Wegen der Imports hier nochmal der gesamte Unit-Test

# coding: utf-8
import unittest
import numbers
from fib import fib
import logging
import pandas as pd

logging.basicConfig( format='%(asctime)-15s [%(levelname)s] %(funcName)s: %(message)s', level=logging.DEBUG)

class TestFib(unittest.TestCase):

# Methode zur Division a/b.
  def divide(self, a,b):
   if b == 0:
     raise ValueError('Dividend darf nicht 0 sein.')

   return int(a/b)


# Die setUp() Methode wird zu Beginn jedes Testcases aufgrufen
  def setUp(self):
    logging.debug('setting up test')
    self.results = [0,1,1,2,3,5,8,13,21,34,55]

#die beiden Tests aus ./testfib.py mit unittest
  def test_fib(self):

# assertEqual(n,r) testet, ob das Resultat r der Vorgabe n entspricht.
    self.assertEqual(5,fib(5))
    self.assertEqual(21,fib(8))

  def test_mult_fib(self):
    results =[0,1,1,2,3,5,8,13,21,34,55]

    for n in range(0,9):
     self.assertEqual(results[n],fib(n))

  def test_divide(self):

    self.assertEqual(2, self.divide(2,1))
    self.assertEqual(2, self.divide(4,2))
    self.assertEqual(3, self.divide(6,2))

    with self.assertRaises(ValueError):
      self.divide(1,0)

  def test_excel(self):
    data = pd.read_excel('./Fib-Testdaten.xlsx')
    results  = data["Ergebnis"]

    for key, result in data.items():
      if isinstance(key, numbers.Number): #Überspringen der Überschriftenzeile
        self.assertEqual(result, fib(int(key)))


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

Wichtig ist die Zeile if isinstance(key, numbers.Number): Da die Excel-Datei eine Überschriftenzeile enthält, wird diese durch diese Abfrage überlesen.

false-positives

Mit false-positives werden Testdaten bezeichnet, die falsch sind, aber als korrekt verarbeitet werden.

Dazu ein Beispiel aus meinem Berufsleben. Eine Kollegin hatte die Aufgabe eine Methode zu schreiben, die prüft, ob eine eingegebene Postleitzahl korrekt ist. Die Regeln sind überschaubar: Eine Postleitzahl ist momentan immer 5-stellig und besteht nur aus Ziffern. Die Kollegin implementierte die Methode wie folgt:

# Korrektes Format der PLZ prüfen. Falsche Version

  def checkPLZ_falsch(self,plz):
    try:
      if len(pl) == 5 and int(plz) > 0:
        return True
    except ValueError:
      pass

    return False

Die Methode durchlief zunächst alle Tests erfolgreich, bis ich dann mal ‚-1111‘ als PLZ eingab, was klaglos als gültig durchgelassen wurde.

An diesem Beispiel erkennst du, wie wichtig die Auswahl der Testdaten für einen Testcase ist. Beim Testen benötigt man manchmal auch etwas Fantasie und Hang zur „Destruktion“.

Korrekt wäre die check_plz() Methode nur, in dem du nicht nur prüfst, dass die Länge von plz genau 5 ist, sondern jedes einzelne Zeichen eine Ziffer ist. Entweder lässt du dir in einer Schleife jedes Zeichen geben oder machst es eleganter mit einer regular Expression. Die Funktionalität für regular expressions findest du im re Modul.

In diesem Unit-Test sind die falsche und die korrekte check_plz() Methode enthalten.

# coding: utf-8
import unittest
import logging
import re #regular expressions

logging.basicConfig( format='%(asctime)-15s [%(levelname)s] %(funcName)s: %(message)s', level=logging.DEBUG)

class TestPLZ(unittest.TestCase):

# Korrektes Format der PLZ prüfen. Falsche Version

  def checkPLZ_falsch(self,plz):
    try:
      if len(pl) == 5 and int(plz) > 0:
        return True
    except ValueError:
      pass

    return False
       
  def checkPLZ_korrekt(self,plz):
#regulären Audruck aufbauen, genau 5 Ziffern.
   pattern = re.compile('\d\d\d\d\d')
#Ausdruck auf String anwenden.
   return pattern.match(plz)
  

# Die setUp() Methode wird zu Beginn jedes Testcases aufgrufen
  def setUp(self):
    logging.debug('setting up test')
    self.testdaten = ['42287','42289','42119','42277','44139','-1111']

  def test_false_positive(self):

   for plz in self.testdaten:
     self.assertTrue(self.checkPLZ_falsch(plz))
  
  def test_plz(self):
   
   logging.debug('starte test_plz()')
   for plz in self.testdaten:
     logging.debug(f'teste ${plz}')
     self.assertTrue(self.checkPLZ_korrekt(plz))

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

Auswirkungen von unzureichenden Tests

Es gibt einige prominente Beispiele, welche Auswirkungen das unzureichende Testen von Software haben kann.

  • Beim Erstflug der Ariane 5 musste die Rakete gesprengt werden, da man die Steuerungssoftware von der Ariane 4 übernommen hatte. Da die Ariane 5 aber höher ist, lieferten die Sensoren Werte, die bei der Typumwandlung zu einem Fehler führten und die Rakete vom Kurs abbrachte.
  • Der Auto-Pilot des F16 Kampfflugzeug brachte das Flugzeug bei Überquerung des Äquators in Rückenlage, da die Software „negative“ Breitengrade nicht verarbeiten konnte. Dieser Fehler wurde zum Glück bei Tests im Simulator entdeckt und vor dem Erstflug korrigiert.
  • Der befürchte „Millenium-Bug“ kam dadurch zustande, dass aus Speicherersparniss in vielen Cobol-Programmen das Jahr nur mit zwei Stellen gespeichert wurde. Dieses Verhalten wurde rechtzeitig erkannt, so dass 1998 und 1999 sehr viel Arbeitszeit investiert wurde, um dieses Problem rechtzeitig auszumerzen.
  • Die NASA Sonde Mars Climate Orbiter konnte 1999 nicht in den korrekten Orbit um den Mars eintreten, da der Hersteller der Sonde im amerikanischen Einheitssystem rechnete, der NASA aber im internationalen SI-System.

Fazit

automatische Tests können die häufigen und meist langweiligen Wiederholungen des manuellen Testens vereinfachen und vereinheitlichen. Allerdings solltest du daran denken, dass ein automatischer Test immer nur die vorgegeben Testfälle abspult. Zufallstreffer, die ein menschlicher Tester haben könnte, in dem er die Eingabemenge variiert, gibt es mit einem automatischen Test nicht. Bei jedem Test solltest du dir genau überlegen, wie groß der Testumfang ist und welche Randfälle getestet werden müssen.Du solltest dir von den Menschen mit dem entsprechenden Fachwissen die Testdaten definieren lassen, das entlastet dich bei der Arbeit und enthebt dich der Verantwortung, falls in den Testdaten ein Fehler steckt.

Das Schreiben von automatischen Tests scheint zunächst viel Zeit zu kosten, aber meiner Erfahrung nach spart es später viel Zeit bei der Fehlersuche und macht die Softwarewartung einfacher.

Ich hoffe, ich konnte dir die Wichtigkeit von automatischen Tests vermitteln. Das nächste Kapitel wird sich mit der Datenbankanbindung beschäftigen.

Schreibe einen Kommentar

Cookie Consent Banner von Real Cookie Banner