Richtlinie: Automatisierte Testsuites verwalten
Diese Richtlinie beschreibt die Design- und Managementprinzipien, die die Verwaltung von Testsuites vereinfachen.
Beziehungen
Hauptbeschreibung

Einführung

Wie Gegenstände können Tests kaputtgehen. Es ist nicht so, dass sie sich abnutzen, sondern vielmehr, dass sich irgendetwas in ihrer Umgebung geändert hat. Möglicherweise wurden die Tests auf ein neues Betriebssystem portiert oder, was wahrscheinlicher ist, der Code, den die Tests ausführen, hat sich so geändert, dass die Tests berechtigterweise scheitern. Angenommen, Sie arbeiten an Version 2.0 einer Anwendung für Online-Banking. In Version 1.0 wurde die folgende Methode für die Anmeldung verwendet:

public boolean login (String Benutzername);

In Version 2.0 hat der Vertrieb festgestellt, dass Kennwortschutz eine gute Idee sein könnte. Deshalb wurde die Methode wie folgt geändert:

public boolean login (String Benutzername, String Kennwort);

Alle Tests, die "login" verwenden, scheitern und lassen sich nicht einmal kompilieren. Da zu diesem Zeitpunkt ohne Anmeldung nicht viel getestet werden kann, können nicht viele sinnvolle Tests ohne die Methode "login" geschrieben werden. Sie stehen unter Umständen Hunderte oder Tausenden von Tests gegenüber, die scheitern.

Diese Tests können korrigiert werden, indem ein Tool für globales Suchen und Ersetzen verwendet wird, das alle Vorkommen von login(xxx) findet und durch login(xxx, "Euer Kennwort") ersetzt. Anschließend müssen Sie dafür sorgen, dass alle Testkonten dieses Kennwort verwenden, und schon funktioniert die Sache.

Wenn der Vertrieb jetzt entscheidet, dass Kennwörter keine Leerzeichen enthalten dürfen, können Sie wieder von vorn anfangen.

Solche Änderungen sind verschwendete Zeit, insbesondere, wenn die Änderungen nicht so leicht durchzuführen sind, wie es häufig der Fall ist. Es gibt eine bessere Lösung.

Angenommen, die Tests rufen ursprünglich gar nicht die Methode login des Produkts auf. Stattdessen rufen sie eine Bibliotheksmethode auf, die alles Erforderliche tut, damit sich der Test anmelden und seine Arbeit tun kann. Diese Methode könnte zunächst wie folgt aussehen:

public boolean testLogin (String Benutzername) {
  return product.login(Benutzername);
}

Wenn die Änderung in Version 2.0 vorgenommen wird, wird die Dienstprogrammbibliothek wie folgt geändert:

public Boolean testLogin (String Benutzername) {
  return  product.login(Benutzername

, "Euer Kennwort");
}

Anstatt Tausende von Tests zu ändern, ändern Sie genau eine Methode.

Im Idealfall sind alle erforderlichen Bibliotheksmethoden bereits am Anfang der Tests vorhanden. In der Praxis ist es jedoch so, dass nicht alle Methoden vorhersehbar sind. Dass Sie eine Bibliotheksmethode testLogin benötigen, erkennen Sie wahrscheinlich erst, wenn sich die Methode login für das Produkt ändert. Dienstprogrammmethoden für Tests ergeben sich häufig aus vorhandenen Tests. Es ist sehr wichtig, dass Sie diese Testkorrekturen fortlaufend durchführen, selbst wenn der Zeitplan sehr eng ist. Wenn Sie es nicht tun, verschwenden Sie viel Zeit mit einer hässlichen und nicht verwaltbaren Testsuite. Unter Umständen müssen Sie sie sogar völlig verwerfen, oder Sie sind nicht in der Lage, die erforderlichen neuen Tests zu schreiben, weil Sie Ihre gesamte verfügbare Zeit damit verbringen, alte Tests zu verwalten.

Anmerkung: Die Tests der Methode login des Produkts rufen die Methode weiterhin direkt auf. Wenn sich ihr Verhalten ändert, müssen einige oder alle dieser Tests aktualisiert werden. (Wenn keiner der Tests für die Methode login scheitert, sind sie wahrscheinlich nicht besonders gut darin, Mängel aufzuspüren.)

Komplexität durch Abstraktion verringern

Das vorherige Beispiel zeigt, wie Tests von der konkreten Anwendung abstrahieren können. Wahrscheinlich können Sie noch erheblich mehr abstrahieren. Sie stellen möglicherweise fest, dass eine Reihe von Tests mit einer gemeinsamen Abfolge von Methodenaufrufen beginnen. Sie melden sich an, richten einen Zustand ein und navigieren zu dem Teil der Anwendung, den Sie testen. Erst dann führt jeder Test etwas Spezifisches aus. Die gesamte Konfiguration kann und sollte in einer einzigen Methode mit einem sinnträchtigen Namen wie readyAccountForWireTransfer enthalten sein. Damit sparen Sie viel Zeit ein, wenn neue Tests eines bestimmten Typs geschrieben werden, und machen gleichzeitig den Zweck jedes einzelnen Tests wesentlich verständlicher.

Verständliche Tests sind wichtig. Alte Testsuites haben häufig das Problem, dass niemand weiß, was die Tests eigentlich tun und warum. Wenn sie scheitern, werden sie in der Regel auf die einfachste Weise korrigiert. Das Ergebnis sind häufig Tests, die weniger Mängel finden. Sie testen nicht mehr das, was sie ursprünglich testen sollten.

Weiteres Beispiel

Angenommen, Sie testen einen Compiler. Einige der ersten geschriebenen Klassen definieren den internen Syntaxanalysebaum des Compilers und die entsprechenden Transformationen. Sie haben eine Reihe von Tests, die Syntaxanalysebäume erstellen und die Transformationen testen. Ein solcher Test können wie folgt aussehen:

/*
 * Vorgabe:
 *   while (i<0) { f(a+i); i++;}
 * "a+i" kann nicht aus der Schleife verschoben werden, weil
 * der Ausdruck eine Variable enthält, die in der Schleife geändert wird.
 */
loopTest = new LessOp(new Token("i"), new Token("0"));
aPlusI = new PlusOp(new Token("a"), new Token("i"));
statement1 = new Statement(new Funcall(new Token("f"), aPlusI));
statement2 = new Statement(new PostIncr(new Token("i"));
loop = new While(loopTest, new Block(statement1, statement2));
expect(false, loop.canHoist(aPlusI))

Dieser Test ist schwierig zu lesen. Die Zeit vergeht. Es werden Änderungen vorgenommen, die eine Aktualisierung der Tests erfordern. Jetzt haben Sie mehr Produktinfrastruktur, auf die Sie sich stützen können. Möglicherweise haben Sie sogar eine Parsing-Routine, die Zeichenfolgen in die Syntaxanalysebäume umwandelt. In diesem Fall wäre es besser, die Tests komplett umzuschreiben. Beispiel:

loop=Parser.parse("while (i<0) { f(a+i); i++; }");
// Zeiger auf den Teil "a+i" der Schleife abrufen.
aPlusI = loop.body.statements[0].args[0];
expect(false, loop.canHoist(aPlusI));

Solche Tests sind wesentlich einfacher zu verstehen und führen zu einer direkten und künftigen Zeitersparnis. Die Verwaltungskosten für diese Tests sind so viel niedriger, dass es Sinn machen kann, die meisten so lange aufzuschieben, bis der Parser verfügbar ist.

Dieser Ansatz hat jedoch einen kleinen Nachteil: Solche Tests können einen Mangel im Transformationscode (was beabsichtigt ist) oder im Parser (was nicht beabsichtigt ist) finden. Somit können Problemeingrenzung und Debugging ein wenig schwieriger werden. Auf der anderen Seite ist das Aufspüren eines Problems, das die Parsertests nicht erkennen, gar keine so schlechte Sache.

Es besteht außerdem die Gefahr, dass ein Mangel im Parser einen Mangel im Transformationscode verdeckt. Die Gefahr ist nur sehr klein, und die daraus entstehenden Kosten wahrscheinlich geringer als die Kosten für die Verwaltung der komplizierteren Tests.

Konzentration auf Testverbesserung

Eine umfangreiche Testsuite enthält einige Testblöcke, die sich nicht ändern. Dies betrifft die stabilen Bereiche in der Anwendung. Andere Testblöcke ändern sich häufig. Diese beziehen sich auf die Bereiche in der Anwendung, in denen sich das Verhalten oft ändert. Diese letzteren Testblöcke machen in der Regel einen intensiveren Gebrauch von Dienstprogrammbibliotheken. Jeder Test testet spezifische Verhalten in dem veränderlichen Bereich. Die Dienstprogrammbibliotheken sind so konzipiert, dass sie einen solchen Test zulassen, um die gewünschten Verhalten zu prüfen, sind aber relativ immun gegen Änderungen in nicht getestetem Verhalten.

Der zuvor gezeigte Schleifentest ist jetzt immun gegenüber den Details für die Erstellung von Syntaxanalysebäumen. Er ist weiterhin sensibel für die Struktur eines Syntaxanalysebaums einer while-Schleife (wegen der Abfolge der Zugriffe, die zum Abrufen der untergeordneten Baumstruktur für a+i erforderlich sind). Wenn sich diese Struktur als veränderlich herausstellt, kann der Test abstrakter gemacht werden, indem eine Dienstprogrammmethode fetchSubtree erstellt wird:

loop=Parser.parse("while (i<0) { f(a+i); i++; }");


aPlusI = fetchSubtree(loop, "a+i");

expect(false, loop.canHoist(aPlusI));

Der Test ist jetzt nur für zwei Dinge sensibel: die Definition der Sprache (z. B., dass Integer mit ++ inkrementiert werden können) und die Regeln, die Codeverschiebung (Hoisting) für Schleifen regeln (das Verhalten, das auf Richtigkeit getestet wird).

Tests verwerfen

Selbst mit Dienstprogrammbibliotheken kann ein Test durch Verhaltensänderungen, die nichts mit dem zu tun haben, was getestet werden soll, regelmäßig scheitern. Eine Korrektur des Tests verspricht nicht viel Chancen, einen Mangel zu finden, der auf die Änderung zurückzuführen ist, sondern ist lediglich eine Maßnahme, um mit dem Test möglicherweise irgendwann einmal irgendeinen anderen Mangel zu finden. Die Kosten für eine solche Serie von Korrekturen übersteigen jedoch möglicherweise den hypothetischen Nutzen (findet er einen Mangel oder nicht) des Tests. Es ist unter Umständen besser, den Test einfach zu verwerfen und sich der Erstellung neuer Tests mit einem größeren Nutzen zu widmen.

Die meisten Leute zögern, einen Test zu verwerfen, zumindest so lange, bis sie von der Verwaltungslast so überwältigt werden, dass sie alle Tests verwerfen. Es ist besser, die Entscheidung sorgfältig und Test für Test zu treffen, indem Sie sich die folgenden Fragen stellen:

  1. Wie viel Arbeit ist es, diesen Test wirklich zu korrigieren und möglicherweise zur Dienstprogrammbibliothek hinzuzufügen?
  2. Kann die Zeit anderweitig genutzt werden?
  3. Wie wahrscheinlich ist es, dass der Test künftig schwerwiegende Mängel findet? Wie war die bisherige Erfolgsquote dieses und der zugehörigen Tests.
  4. Wie lange dauert es, bis der Test erneut scheitert?

Die Antworten auf diese Fragen sind grobe Schätzungen, möglicherweise sogar nur Vermutungen. Aber diese Fragen zu stellen, liefert bessere Ergebnisse, als einfach die Richtlinie zu verfolgen, alle Tests zu korrigieren.

Ein weiterer Grund, Tests zu verwerfen, ist Redundanz. In frühen Stadien der Entwicklung kann es eine Vielzahl einfacher Tests geben, die grundlegende Methoden für den Aufbau eines Syntaxanalysebaums (Konstruktor LessOp usw.) testen. Später, beim Schreiben des Parsers gibt es eine Reihe von Parsertests. Da der Parser die Konstruktionsmethoden verwendet, werden diese von den Parsertests auch indirekt getestet. Wenn Codeänderungen die Konstruktionstests zum Scheitern bringen, ist es angebracht, einige dieser Tests zu verwerfen, da sie redundant sind. Natürlich erfordert jede neue oder geänderte Konstruktion neue Tests. Sie können direkt implementiert werden (falls der Parser sie nur unzureichend testen kann) oder indirekt (wenn Tests durch den Parser ausreichend und besser verwaltbar sind).