Richtlinie: Testideen für Methodenaufrufe
Testideen basieren auf nachvollziehbaren Softwarefehlern und den besten Methoden zur Entdeckung dieser Fehler. Die vorliegende Richtlinie beschreibt eine Methode, mit der man Fälle erkennen kann, in denen der Code das Ergebnis eines Methodenaufrufs nicht adressiert.
Beziehungen
Zugehörige Elemente
Hauptbeschreibung

Einführung

Beispiel für fehlerhaften Code:

File file = new File(stringName);
file.delete();

Der Fehler besteht darin, dass file.delete scheitern kann, der Code dies jedoch nicht überprüft. Zur Behebung dieses Fehlers muss der Code hinzugefügt werden, der hier kursiv dargestellt ist:

File file = new File(stringName);



if (file.delete()


== false) {...}

Diese Richtlinie beschreibt eine Methode, mit der Sie Fälle erkennen können, in denen Ihr Code das Ergebnis eines Methodenaufrufs nicht adressiert. (Die Richtlinie geht davon aus, dass das Ergebnis der aufgerufenen Methode bei beliebigen Eingabewerten korrekt ist. Sie sollten dies überprüfen. Das Entwickeln von Testideen für die aufgerufene Methode hat damit jedoch nichts zu tun. Das Testen von file.delete gehört somit nicht hierher.)

Der wichtigste Punkt ist, dass Sie für jedes spezifische, relevante und nicht adressierte Ergebnis eines Methodenaufrufs eine Testidee entwickeln sollten. Lassen Sie uns zunächst dieses Ergebnis näher definieren. Wenn eine Methode ausgeführt wird, ändert sich ein Status. Beispiele:

  • Die aufgerufene Methode kann Rückgabewerte in den Laufzeit-Stack stellen.
  • Die aufgerufene Methode kann eine Ausnahme auslösen.
  • Die aufgerufene Methode kann eine globale Variable ändern.
  • Die aufgerufene Methode kann einen Datensatz in einer Datenbank aktualisieren.
  • Die aufgerufene Methode kann Daten über das Netz senden.
  • Die aufgerufene Methode kann eine Nachricht auf die Standardausgabe schreiben.

Im Folgenden wollen wir uns das Attribut relevant anhand einiger Beispiele näher anschauen.

  • Nehmen wir an, die aufgerufene Methode schreibt eine Nachricht auf die Standardausgabe. Dadurch ändert sich zwar ein Status, aber die weitere Verarbeitung des Programms wird davon nicht beeinflusst. Ob eine Ausgabe erfolgt und wie diese aussieht, hat keine Auswirkungen auf die Ausführung Ihres Codes.
  • Gibt die Methode bei einem Erfolg true und bei einem Fehler false zurück, sollte sich Ihr Programm entsprechend dem Ergebnis verzweigen. Dieser Rückgabewert ist demnach relevant.
  • Falls die aufgerufene Methode einen Datenbanksatz aktualisiert, den Ihr Code später liest und verwendet, ist das Ergebnis (die Aktualisierung) von Relevanz.

(Es gibt keine absolute Grenze zwischen relevant und irrelevant. Durch das Aufrufen von print kann Ihre Methode die Zuordnung von Puffern auslösen, und diese Zuordnung kann von Relevanz sein. Es ist vorstellbar, dass die Zuordnung von Puffern oder bestimmter Puffer einen Fehler bedeuten kann. Aber ist es deshalb auch plausibel?)

Eine Methode kann sehr viele Ergebnisse haben, von denen jedoch nur einige spezifisch sind. Nehmen wir als Beispiel eine Methode, die Bytes auf einen Datenträger schreibt. Die Rückgabe einer Zahl kleiner als null könnte einen Fehler anzeigen, und andernfalls wird die Anzahl der geschriebenen Bytes zurückgegeben (die niedriger als die erforderliche Anzahl sein kann). Aus der Vielzahl der Möglichkeiten können drei spezifische Ergebnisse herausgefiltert werden:

  • Es wird eine Zahl kleiner als null zurückgegeben.
  • Die Anzahl geschriebener Bytes entspricht der erforderlichen Anzahl.
  • Es werden Bytes geschrieben, jedoch weniger als erforderlich.

Alle Werte, die kleiner als null sind, werden in einem Ergebnis zusammengefasst, denn kein vernünftiges Programm würde diese Werte unterscheiden. Alle diese Werte (sofern überhaupt mehre Werte möglich sind) sollten als ein Fehler angesehen werden. Ganz ähnlich sieht es aus, wenn der Code das Schreiben von 500 Bytes anfordert. Ob tatsächlich 34 Bytes oder 340 geschrieben werden, macht keinen Unterschied, da das Nichtschreiben aller fehlenden Bytes wahrscheinlich dieselbe Ursache hat. (Falls für einen Wert, z. B. für 0, eine andere Vorgehensweise erforderlich ist, würde dieser Wert ein neues spezifisches Ergebnis darstellen.)

Ein letztes Attribut des Ergebnisses bleibt noch zu definieren. Dieses spezielle Testverfahren beschäftigt sich nicht mit spezifischen Ergebnissen, die bereits adressiert wurden. Schauen wir uns erneut den Code an:

File file = new File(stringName);
if (file.delete() == false) {...}

Es gibt zwei spezifische Ergebnisse (true und false). Der Code adressiert diese Ergebnisse. Sie werden möglicherweise nicht korrekt adressiert, aber damit beschäftigen sich die Richtlinie für Arbeitsergebnisse: Testideen für Boolesche Ausdrücke und Grenzbedingungen. Gegenstand dieses Testverfahrens sind spezifische Ergebnisse, die von einem bestimmten Code nicht erkennbar adressiert werden. Die Nichtadressierung kann zwei Ursachen haben: Sie haben ein spezifisches Ergebnis für nicht relevant gehalten oder es einfach übersehen. Das folgende Beispiel zeigt den ersten Fall:

result = m.method();
switch (result) {
    case FAIL:
    case CRASH:
       ...
       break;
    case DEFER:
       ...
       break;
    default:
       ...
       break;
}

FAIL CRASH werden von demselben Code adressiert. Es erscheint ratsam zu prüfen, ob dies tatsächlich angemessen ist. Das folgende Beispiel zeigt ein übersehenes spezifisches Ergebnis:

result = s.shutdown();
if (result == PANIC) {
   ...
} else {
   // Erfolg! Reaktor herunterfahren.
   ...
} 

Es erweist sich, dass shutdown ein weiteres spezifisches Ergebnis haben kann: RETRY. Der Code, so wie er geschrieben ist, behandelt diesen Fall genauso wie einen Erfolg, was mit größter Wahrscheinlichkeit falsch ist.

Testideen finden

Ihr Ziel ist es somit, an genau die spezifischen relevanten Ergebnisse zu denken, die Sie vorher übersehen haben. Auf den ersten Blick erscheint das unmöglich zu sein. Warum sollten Sie jetzt die Relevanz von Ergebnissen bemerken, wenn Sie es zuvor nicht getan haben?

Die Antwort lautet: Wenn Sie Ihren Code systematisch unter Testgesichtspunkten durchgehen und nicht aus der bloßen Sicht des Programmierers überprüfen, können Sie auf neue Ideen und Gedanken kommen. Sie können Ihre eigenen Annahmen in Frage stellen, wenn Sie Ihren Code methodisch Schritt für Schritt durchgehen, sich die aufgerufenen Methoden ansehen, die Dokumentation dieser Methoden erneut überprüfen und nachdenken. Achten Sie unter anderem auf folgende Fälle:

"Unmögliche" Fälle

Oft scheint es, als wären fehlerhafte Rückgabewerte nicht möglich. Unterziehen Sie eine derartige Annahme einer erneuten Überprüfung.

Das folgende Beispiel zeigt eine Java-Implementierung eines allgemeinen UNIX-Musters für die Handhabung temporärer Dateien.

File file = new File("tempfile");
FileOutputStream s;
try {
    // temporäre Datei öffnen
    s = new FileOutputStream(file);
} catch (IOException e) {...}
// sicherstellen, dass die temporäre Datei gelöscht wird
file.delete();

Es soll sichergestellt werden, dass eine temporäre Datei immer gelöscht wird, ganz gleich, wie das Programm beendet wird. Zu diesem Zweck erstellen Sie die temporäre Datei und löschen sie gleich im Anschluss. Unter UNIX können Sie weiter mit der gelöschten Datei arbeiten. Das Betriebssystem kümmert sich um die Bereinigung, wenn der Prozess endet. Ein sorgloser UNIX-Programmierer könnte den Code daher so schreiben, dass nicht überprüft wird, ob das Löschen der Datei gescheitert ist. Die Datei konnte erfolgreich erstellt werden, also kann sie auch gelöscht werden.

Unter Windows funktioniert dies jedoch nicht. Die Datei kann nicht gelöscht werden, weil sie geöffnet ist. Dies lässt sich nicht so ohne weiteres feststellen. Bis zum August des Jahres 2000 enthielt die Java-Dokumentation keine Angaben zu den Situationen, in denen delete fehlschlagen kann. Lediglich die Tatsache, dass ein Scheitern möglich ist, wird erwähnt. Ein Programmierer, der seinen Code durch die "Testbrille" betrachtet, stellt seine Annahme möglicherweise in Frage. Da sein einmal geschriebener Code überall ausführbar sein soll, könnte er einen Windows-Programmierer fragen, in welchen Fällen File.delete unter Windows scheitert, und so die bittere Wahrheit erkennen.

"Nicht relevante" Fälle

Ein spezifischer relevanter Werte wird auch übersehen, wenn man überzeugt ist, dass dieser Werte nicht ausschlaggebend sei. Die Methode compare eines Java-Vergleichsoperators (Comparator) gibt entweder eine Zahl <0, 0 oder eine Zahl >0 zurück. Diese drei spezifischen Fälle können überprüft werden. Im folgenden Code werden zwei dieser Fälle pauschal zu einem zusammengefasst:

void allCheck(Comparator c) {
   ...
   if (c.compare(o1, o2) <= 0) {
      ...
   } else {
      ...
   } 

Dies könnte jedoch falsch sein. Ob dem so ist, werden Sie nur feststellen, wenn Sie beide Fälle gesondert prüfen, auch wenn Sie überzeugt sind, dass dies keinen Unterschied machen wird. (Ihre Annahmen bestimmen, was Sie testen werden.) Sie werden vielleicht andere Gründe haben, den Fall then der Anweisung if mehrfach auszuführen. Warum sollten Sie diesen Fall also nicht einmal mit einem Ergebnis kleiner als 0 und einem Ergebnis gleich 0 austesten?

Nicht abgefangene Ausnahmen

Ausnahmen gehören zu den spezifischen Ergebnissen. Schauen Sie sich hierzu den folgenden Code an:

void process(Reader r) {
   ...
   try {
      ...
      int c = r.read();
      ...
   } catch (IOException e) {
      ...
   }
}

Sie werden erwarten, dass überprüft wird, ob der Handler-Code bei einem Lesefehler das Richtige tut. Was geschieht aber, wenn eine Ausnahme explizit nicht adressiert und so in dem zu testenden Code weitergegeben wird? In Java könnte das wie folgt aussehen:

void process(Reader r) 


throws IOException {
    ...
    int c = r.read();
    ...
}

Dieses Verfahren fordert Sie auf, diesen Testfall zu testen, obwohl er vom Code nicht explizit adressiert wird. Sie fragen sich, warum? Der Grund ist die folgende Art von Fehler:

void process(Reader r) throws IOException {
    ...
    


Tracker.hold(this);
    ...
    int c = r.read();
    ...
    


Tracker.release(this);
    ...
}

Der Code beeinflusst hier (mit Tracker.hold) den globalen Status. Wenn die Ausnahme ausgelöst wird, wird Tracker.release niemals aufgerufen.

(Beachten Sie, dass das Unterlassen der Freigabe wahrscheinlich keine sofort bemerkbaren Folgen haben wird. Das Problem wird höchstwahrscheinlich erst erkannt, wenn process erneut aufgerufen wird und der Versuch, das Objekt ein zweites Mal zu sperren (hold), scheitert. Keith Stobie hat mit "Testing for Exceptions" einen guten Artikel zu diesem Thema geschrieben (Adobe Reader herunterladen).

Nicht entdeckte Fehler

Mit diesem speziellen Verfahren können nicht alle Mängel im Zusammenhang mit Methodenaufrufen erfasst werden. So ist es unwahrscheinlich, dass die beiden folgenden Fehlertypen mit diesem Verfahren erkannt werden.

Falsche Argumente

Schauen Sie sich die beiden folgenden Zeilen C-Code an. Die erste Zeile ist falsch und die zweite ist korrekt.

... strncmp(s1, s2, strlen(s1)) ...
... strncmp(s1, s2, strlen(


s2)) ...

strncmp werden zwei Zeichenfolgen verglichen. Ist die erste Zeichenfolge lexikografisch kleiner als die zweite (d. h., kommt die erste Zeichenfolge in einem Wörterbuch vor der zweiten Zeichenfolge vor), wird eine Zahl kleiner als 0 zurückgegeben. Sind beide Zeichenfolgen gleich, wird 0 zurückgegeben. Ist die erste Zeichenfolge lexikografisch größer, wird eine Zahl größer als 0 zurückgegeben. Verglichen wird jedoch nur die vom dritten Argument angegebene Anzahl Zeichen. Das Problem besteht darin, dass die erste Zeichenfolge den Vergleich begrenzt, obwohl er durch die Länge der zweiten Zeichenfolge begrenzt werden sollte.

Das hier beschriebene Verfahren würde drei Tests vorsehen, einen für jeden spezifischen Rückgabewert. Die folgende Tabelle enthält drei Beispiele, die Sie verwenden könnten:

s1 s2 Erwartetes Ergebnis Tatsächliches Ergebnis
"a" "bbb" <0 <0
"bbb" "a" >0 >0
"foo" "foo" =0 =0

Der Fehler wird nicht erkannt, weil das dritte Argument in diesem Verfahren nicht zwingend einen bestimmten Wert haben muss. Benötigt wird also ein Testfall wie der folgende:

s1 s2 Erwartetes Ergebnis Tatsächliches Ergebnis
"foo" "food" <0 =0

Die Verfahren, die zur Feststellung solcher Mängel geeignet sind, werden in der Praxis nur selten angewendet. In den meisten Fällen werden umfangreiche Tests geplant, mit denen möglichst viele verschiedene Fehlerarten erkannt werden sollen (und bei denen zu hoffen bleibt, dass Fehler wie der hier beschriebene, am Rande mitfestgestellt werden).

Nicht spezifische Ergebnisse

Wenn Sie die Methoden Ihres Codes einzeln schreiben und testen, kann dies gewissen Gefahren in sich bergen. Schauen Sie sich dazu das folgende Beispiel mit zwei Methoden an. Die erste Methode (connect) will eine Netzverbindung herstellen:

void connect() {
   ...
   Integer portNumber = serverPortFromUser();
   if (portNumber == null) {
      // Dialog mit Nachricht über ungültige Port-Nummer
      return;
   }

Wenn eine Port-Nummer erforderlich ist, wird serverPortFromUser aufgerufen. Diese Methode kann zwei spezifische Werte zurückgeben. Zum einen wird eine vom Benutzer ausgewählte Port-Nummer zurückgegeben, falls diese gültig ist (1000 oder größer). Andernfalls wird null zurückgegeben. Wenn null zurückgegeben wird, zeigt der zu testende Code einen Dialog mit einer Fehlernachricht an und endet.

Die Methode connect verhält sich im Test wie geplant. Beim Vorhandensein einer gültigen Port-Nummer wird eine Verbindung hergestellt. Eine ungültige Port-Nummer führt zur Anzeige eines Dialogfensters.

Der Code für serverPortFromUser ist ein wenig komplizierter. Er zeigt zuerst ein Fenster an, das den Benutzer zur Eingabe einer Zeichenfolge auffordert und die Standardknöpfe OK und ABBRECHEN enthält. Abhängig davon, wie der Benutzer reagiert, sind vier Fälle möglich:

  1. Wenn der Benutzer eine gültige Nummer eingibt, wird diese Nummer zurückgegeben.
  2. Ist die Nummer zu klein (kleiner als 1000), wird null zurückgegeben (und die Nachricht zur ungültigen Port-Nummer angezeigt).
  3. Wenn die Nummer das falsche Format hat, wird auch null zurückgegeben (und dieselbe Nachricht angezeigt).
  4. Falls der Benutzer auf ABBRECHEN klickt, wird null zurückgegeben.

Dieser Code funktioniert also auch wie vorgesehen.

Die Kombination beider Codeblöcke hat jedoch die unerwünschte Folge, dass der Benutzer eine Nachricht zu einer ungültigen Port-Nummer sieht, wenn er auf ABBRECHEN klickt. Auch wenn jeder Codeblock für sich wie geplant funktioniert, kann das Gesamtergebnis dennoch falsch sein. Der Fehler wurde trotz gründlicher und vernünftiger Tests übersehen.

Das Problem besteht darin, dass null ein Ergebnis mit zwei spezifischen Bedeutungen ("falscher Wert" und "Abbruch durch Benutzer") ist. Mit dem hier beschriebenen Verfahren wird dieses Problem beim Entwerfen von serverPortFromUser nicht zwingend bemerkt.

Tests können jedoch Abhilfe schaffen. Wenn serverPortFromUser isoliert getestet wird, um lediglich festzustellen, ob für die genannten vier Fälle der beabsichtigte Wert zurückgegeben wird, bleibt der Verwendungskontext unberücksichtigt. Was würde dagegen passieren, wenn dieser Code zusammen mit connect getestet wird. Beide Methoden könnten mit vier Tests gleichzeitig ausgeführt werden:

Eingabe Erwartetes Ergebnis Denkprozess
Der Benutzer gibt "1000" ein. Die Verbindung zum Port 1000 wird geöffnet. serverPortFromUser gibt eine Nummer zurück, die verwendet wird.

Der Benutzer gibt "999" ein.

Dialogfenster mit Info zur ungültigen Port-Nummer

serverPortFromUser gibt null zurück, was zur Anzeige des Dialogfensters führt.

Der Benutzer gibt "i99" ein.

Dialogfenster mit Info zur ungültigen Port-Nummer serverPortFromUser gibt null zurück, was zur Anzeige des Dialogfensters führt.
Der Benutzer klickt auf ABBRECHEN. Die gesamte Verbindungsverarbeitung sollte abgebrochen werden. serverPortFromUser gibt null zurück und..., aber halt, das macht doch keinen Sinn!

Tests in einem größeren Kontext bringen oft Integrationsprobleme zum Vorschein, die bei einem kleineren Testrahmen übersehen werden. Ebenso können durch gründliches und sorgfältiges Nachdenken während des Testdesigns Probleme noch vor der eigentlichen Testausführung festgestellt werden. (Mängel, die bei derartigem Design unentdeckt bleiben, werden dann jedoch während der Testausführung festgestellt.)