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.
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).
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:
-
Wenn der Benutzer eine gültige Nummer eingibt, wird diese Nummer zurückgegeben.
-
Ist die Nummer zu klein (kleiner als 1000), wird null zurückgegeben (und die Nachricht zur ungültigen Port-Nummer
angezeigt).
-
Wenn die Nummer das falsche Format hat, wird auch null zurückgegeben (und dieselbe Nachricht angezeigt).
-
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.)
|