Bessere Software durch Modultests

 

Bei der Entwicklung eigener (Astro-)Software versucht man logischerweise, Fehler weitgehend auszuschließen. Hierfür gibt es verschiedene Techniken, die entweder den Code selbst oder die eigene Arbeitsweise betreffen. Eine weitverbreitete und effektive Methode sind die Modultests, um die es in dieser Ausgabe des Zirkulars geht.

Obwohl sie Modultests heißen, testen sie nicht nur die Module einer Software, sondern sogar die einzelnen Funktionen. Wesentliches Merkmal ist, dass die von einer Funktion veränderten Daten mit erwarteten Werten verglichen werden. Liefert eine Funktion oder ein Modul für alle angesetzten Tests Daten, die mit den erwarteten Werten übereinstimmen, so gilt der Modultest für diese Funktion oder dieses Modul als bestanden. Das Bestehen eines Modultests bedeutet übrigens nicht, dass die Software an dieser Stelle fehlerfrei ist. Zum einen ist, von trivialen Programmen wie Hallo Welt vielleicht abgesehen, ein Nachweis der Fehlerfreiheit von Software praktisch nicht möglich; zum anderen kann das Modul oder die Funktion immer noch Denkfehler enthalten, die sich im Test fortsetzen.

Zur Umsetzung eines Modultests kann die eigentliche Software um einen Teil erweitert werden, der mit der eigentlichen Funktion nichts zu tun hat und für die Anwender für gewöhnlich auch nicht sichtbar ist, dafür aber die einzelnen Modultests nacheinander ausführt. Die Modultests können in einer Anwendung beispielsweise durch einen Menüeintrag gestartet werden, der nur für die Testdurchführung sichtbar gemacht wird, z.B. im Debug-Modus.

Für jede Funktion bzw. jedes Modul kann man sich mehrere Modultests ausdenken, die verschiedene Eingabedatensätze darstellen. Für jeden Eingabedatensatz notiert man sich Werte für Ausgabedaten, wie sie von der zu testenden Software erzeugt werden sollen. Für Modultests kann man eigene Funktionen schreiben, die nichts anderes machen als die Modultests durchzuführen, d.h. die zu testenden Funktionen mit den Eingabedaten aufzurufen und die Erwartungswerte mit den berechneten Werten zu vergleichen.

Bei der Gestaltung von Modultests versucht man, die Eingabedaten so zu setzen, dass alle Wertebereiche und Randbedingungen durchlaufen werden. Hierzu kann man sich den zu testenden Code betrachten und versuche, durch die Tests alle vorhandenen Zweige von if-Abfragen zu durchlaufen. Des Weiteren können Testfälle aufgesetzt werden, die die Grenzen der gültigen Wertebereiche abprüfen; ebenso kann geprüft werden, ob sich die Software korrekt verhält, wenn die Eingabedaten jenseits des gültigen (genauer: dem von der Programmlogik vorgesehenen) Wertebereichs liegen (korrekte Fehlerreaktion).

Hier nun ein Beispiel:

Angenommen, wir testen eine Funktion, die drei Wertebereiche für Eingabedaten kennt. Ein minimaler Modultest könnte wie folgt aussehen (Pseudocode):

Function Modultest {
  TestBestanden = TRUE;
 
  // Testschritt 1
  Eingabedatum1 = ...;
  Erwartungswert = ...;
  Resultat = BerechnePosition( Einagbedatum1 );
  if ( Resultat != Erwartungswert ) {
    TestBestanden = FALSE;
  }

  // Testschritt 2
  Eingabedatum1 = ...;  // andere Werte...
  Erwartungswert = ...;
  Resultat = BerechnePosition( Einagbedatum1 );
  if ( Resultat != Erwartungswert ) {
    TestBestanden = FALSE;
  }

  // Testschritt 3
  Eingabedatum1 = ...;  // nochmal andere Werte...
  Erwartungswert = ...;
  Resultat = BerechnePosition( Einagbedatum1 );
  if ( Resultat != Erwartungswert ) {
    TestBestanden = FALSE;
  }
}

Natürlich können die Eingabedaten in mehr als einem Parameter vorliegen:

  // Testschritt 1
  Eingabedatum1 = ...;
  Eingabedatum2 = ...;
  Eingabedatum3 = ...;
  Erwartungswert = ...;
  Resultat = BerechnePosition( Eingabedatum1, Eingabedatum2, Eingabedatum3 );
  if ( Resultat != Erwartungswert ) {
    TestBestanden = FALSE;
  }
  // usw.

Damit steigt die Zahl der Testfälle schon merklich an. Ganz analog kann es auch mehrere Ausgabeparameter geben, die zu überwachen sind.

Ein weiteres Beispiel:

Nehmen wir mal an, wir hätten ein Modul, welches eine Kometenposition (2 Ausgabedaten: Rektaszension und Deklination als Gleitkommazahlen) anhand seiner Bahndaten (7 Eingabedaten im Gleitkommaformat: a, e, i, M, klein Omega, groß Omega sowie Zeitstempel) vornimmt. Der Rechenweg unterscheidet sich jedoch, ob die Bahn elliptisch, parabolisch oder hyperbolisch ist. Für den Modultest bekommt man nun mindestens drei Testfälle (man kann natürlich noch weitere Prüfungen vornehmen). Man hat also in der Testfunktion drei Funktionsaufrufe: einen mit elliptischen, einen mit parabolischen und einen mit hyperbolischen Bahndaten. Man kann also nun für alle drei Testfälle zuverlässige Beispieldaten verschaffen, z.B. aus Jahrbüchern oder einschlägigen Websites (JPL Horizons Web Interface) und prüfen, ob sie mit der eigenen Software reproduziert werden können.

Dieses Beispiel führt uns zu einer wichtigen Sache: Bei Gleitkommazahlen kann man keinen direkten Vergleich durchführen. Infolge von Rundungen und Rechenungenauigkeiten an der letzten Dezimalstelle können formal gleiche Resultate im Detail unterschiedlich aussehen und bei direktem Vergleich einen Fehler produzieren, obwohl keiner vorliegt. Um dies zu vermeiden, sollte man bei Fliesskommazahlen ein kleines Intervall um den Erwartungswert legen und vielmehr prüfen, ob der errechnte Wert innerhalb des Intervalls liegt, z.B. so:

if ( ( Ergebnis > Erwartungswert + Toleranz ) OR ( ( Ergebnis < Erwartungswert - Toleranz ) ) {
  TestBestanden = FALSE;
}

Fazit

Es geht im Allgemeinen nicht darum, sämtlichen Code durch Modultests zu checken; dies wäre vom Aufwand her für uns programmierende Hobbyastronomen unvertretbar. Vielmehr empfiehlt es sich, sich beim Modultest auf die zentralen Teile der Software zu beschränken, z.B. die Rechenroutinen eines Programms, die eher komplex sind und nur schwer zu verstehen, damit der Aufwand überschaubar bleibt.

Das Gute an Modultests ist: Man überlegt sich beim Schreiben des Programms schon, welche Testfälle eigentlich auftreten können und versucht sie alle abzudecken. Man bekommt beim Programmieren schon sehr früh einen gut getesteten Code und gerät daher kaum noch in die Verlegenheit, einen Fehler oder eine Programmlücke erst dann zu entdecken, wenn die Entwicklung des Moduls schon lange zurückliegt und man sich an die zugrundeliegende Logik nicht mehr gut erinnern kann. Die Robustheit des Programms verbessert sich enorm, und das ist den Aufwand Wert!

Ein weiterer Bonus: wenn man den Code nochmal überarbeitet, kann man immer sofort auf Knopfdruck prüfen, ob das Modul noch funktioniert. Auf diesem Wege schleichen sich kaum noch neue Fehler ein und man bekommt eine gehörige Portion Sicherheit und eine gutes Gefühl bei der Umsetzung seiner Ideen.