Wie vermeide ich fragile Unit Tests?

24

Wir haben fast 3.000 Tests geschrieben - die Daten wurden fest codiert, die Wiederverwendung von Code ist sehr gering. Diese Methode hat begonnen, uns in den Arsch zu beißen. Wenn sich das System ändert, verbringen wir mehr Zeit damit, kaputte Tests zu reparieren. Wir haben Unit-, Integrations- und Funktionstests.

Was ich suche, ist eine definitive Möglichkeit, verwaltbare und wartbare Tests zu schreiben.

Frameworks

Chuck Conway
quelle
Dies ist viel besser für Programmierer geeignet.StackExchange, IMO ...
IAbstract
BDD
Robbie Dee

Antworten:

21

Betrachten Sie sie nicht als "defekte Komponententests", da dies nicht der Fall ist.

Dies sind Spezifikationen, die Ihr Programm nicht mehr unterstützt.

Betrachten Sie es nicht als "Fixieren der Tests", sondern als "Definieren neuer Anforderungen".

Bei den Tests sollte zunächst Ihre Anwendung angegeben werden, nicht umgekehrt.

Sie können nicht sagen, dass Sie eine funktionierende Implementierung haben, bis Sie wissen, dass sie funktioniert. Sie können nicht sagen, dass es funktioniert, bis Sie es testen.

Ein paar andere Hinweise, die Sie leiten könnten:

  1. Die Tests und die zu testenden Klassen sollten kurz und einfach sein . Jeder Test sollte nur auf eine zusammenhängende Funktionalität prüfen. Das heißt, es kümmert sich nicht um Dinge, die andere Tests bereits prüfen.
  2. Die Tests und Ihre Objekte sollten lose miteinander verbunden sein, sodass Sie beim Ändern eines Objekts nur dessen Abhängigkeitsgraph nach unten ändern und andere Objekte, die dieses Objekt verwenden, nicht davon betroffen sind.
  3. Möglicherweise erstellen und testen Sie das falsche Material . Sind Ihre Objekte für eine einfache Schnittstelle oder Implementierung konzipiert? In letzterem Fall werden Sie feststellen, dass Sie viel Code ändern, der die Schnittstelle der alten Implementierung verwendet.
  4. Halten Sie sich im besten Fall strikt an das Prinzip der Einzelverantwortung. Halten Sie sich im schlimmsten Fall an das Prinzip der Schnittstellentrennung. Siehe SOLID-Prinzipien .
Yam Marcovic
quelle
5
+1 fürDon't think of it as "fixing the tests", but as "defining new requirements".
StuperUser
2
+1 Bei den Tests sollte zuerst Ihre Anwendung angegeben werden, nicht umgekehrt
treecoder
11

Was Sie beschreiben, ist vielleicht gar nicht so schlecht, aber ein Hinweis auf tiefere Probleme, die Ihre Tests entdecken

Wenn sich das System ändert, verbringen wir mehr Zeit damit, kaputte Tests zu reparieren. Wir haben Unit-, Integrations- und Funktionstests.

Wenn Sie Ihren Code ändern könnten und Ihre Tests nicht abbrechen würden, wäre das für mich verdächtig. Der Unterschied zwischen einer legitimen Änderung und einem Fehler besteht nur in der Tatsache, dass sie angefordert wird. Was angefordert wird, wird von Ihren Tests definiert (vorausgesetzt TDD).

Daten wurden fest codiert.

Fest codierte Daten in Tests sind imho eine gute Sache. Tests funktionieren als Fälschungen, nicht als Beweise. Wenn zu viel berechnet wird, sind Ihre Tests möglicherweise Tautologien. Beispielsweise:

assert sum([1,2,3]) == 6
assert sum([1,2,3]) == 1 + 2 + 3
assert sum([1,2,3]) == reduce(operator.add, [1,2,3])

Je höher die Abstraktion, desto näher kommt man dem Algorithmus und damit dem Vergleich der tatsächlichen Implementierung mit sich selbst.

sehr wenig Wiederverwendung von Code

Die beste Wiederverwendung von Code in Tests ist imho 'Checks', wie in jUnits assertThat, weil sie die Tests einfach halten. Wenn die Tests so umgestaltet werden können, dass sie Code gemeinsam nutzen, ist dies wahrscheinlich auch der tatsächlich getestete Code , wodurch die Tests auf diejenigen reduziert werden, die die umgestaltete Basis testen.

keppla
quelle
Ich würde gerne wissen, wo der Downvoter anderer Meinung ist.
Keppla
keppla - Ich bin nicht der Abwähler, aber im Allgemeinen bevorzuge ich, je nachdem, wo ich mich im Modell befinde, das Testen der Objektinteraktion gegenüber dem Testen von Daten auf Einheitenebene. Das Testen von Daten funktioniert auf Integrationsebene besser.
Ritch Melton
@keppla Ich habe eine Klasse, die eine Bestellung an einen anderen Kanal weiterleitet, wenn deren Gesamtzahl bestimmte eingeschränkte Artikel enthält. Ich erstelle eine gefälschte Bestellung und fülle sie mit 4 Artikeln, von denen zwei die eingeschränkten sind. Soweit die eingeschränkten Elemente hinzugefügt werden, ist dieser Test einzigartig. Die Schritte zum Erstellen einer gefälschten Bestellung und zum Hinzufügen von zwei regulären Artikeln sind jedoch die gleichen, die ein anderer Test verwendet, um den Workflow für nicht eingeschränkte Artikel zu testen. In diesem Fall, zusammen mit Artikeln, wenn für die Bestellung die Einrichtung von Kundendaten und Adressen usw. erforderlich ist, ist dies kein guter Fall für die Wiederverwendung von Einrichtungshilfen. Warum nur die Wiederverwendung geltend machen?
Asif Shiraz
6

Ich hatte auch dieses Problem. Mein verbesserter Ansatz war wie folgt:

  1. Schreiben Sie keine Komponententests, es sei denn, dies ist der einzig gute Weg, um etwas zu testen.

    Ich bin voll und ganz bereit zuzugeben, dass Unit-Tests die niedrigsten Kosten für Diagnose und Reparatur aufweisen. Das macht sie zu einem wertvollen Werkzeug. Das Problem ist, dass Unit-Tests angesichts der offensichtlichen Abweichungen Ihrer Laufleistung oft zu kleinlich sind, um die Kosten für die Aufrechterhaltung der Codemasse zu verdienen. Ich habe unten ein Beispiel geschrieben, schauen Sie es sich an.

  2. Verwenden Sie Aussagen, wo immer sie dem Komponententest für diese Komponente entsprechen. Assertions haben die nette Eigenschaft, dass sie bei jedem Debugbuild immer überprüft werden. Anstatt die Klasseneinschränkungen für "Mitarbeiter" in einer separaten Testeinheit zu testen, testen Sie die Klasse "Mitarbeiter" effektiv für jeden Testfall im System. Behauptungen haben auch die nette Eigenschaft, dass sie die Codemasse nicht so stark erhöhen wie Unit-Tests (die schließlich ein Gerüst / Verspotten / was auch immer erfordern).

    Bevor mich jemand umbringt: Produktions-Builds sollten nicht aufgrund von Behauptungen abstürzen. Sie sollten stattdessen auf der Ebene "Fehler" protokollieren.

    Machen Sie als Warnung für jemanden, der noch nicht darüber nachgedacht hat, keine Angaben zu Benutzer- oder Netzwerkeingaben. Es ist ein großer Fehler ™.

    In meinen neuesten Codebasen habe ich Unit-Tests mit Bedacht entfernt, wo immer ich eine offensichtliche Möglichkeit für Behauptungen sehe. Dies hat die Wartungskosten insgesamt erheblich gesenkt und mich zu einer viel glücklicheren Person gemacht.

  3. Bevorzugen Sie System- / Integrationstests und implementieren Sie diese für alle Ihre primären Abläufe und Benutzererfahrungen. Eckkoffer müssen wohl nicht dabei sein. Ein Systemtest überprüft das Verhalten auf Benutzerseite, indem alle Komponenten ausgeführt werden. Aus diesem Grund ist ein Systemtest notwendigerweise langsamer. Schreiben Sie daher die wichtigsten auf (nicht mehr und nicht weniger), und Sie werden die wichtigsten Probleme erkennen. Systemtests haben einen sehr geringen Wartungsaufwand.

    Da Sie Behauptungen verwenden, werden bei jedem Systemtest gleichzeitig ein paar hundert "Komponententests" ausgeführt. Sie können sich auch ziemlich sicher sein, dass die wichtigsten mehrmals ausgeführt werden.

  4. Schreiben Sie starke APIs, die funktional getestet werden können. Funktionstests sind umständlich und (seien wir ehrlich) bedeutungslos, wenn Ihre API es zu schwierig macht, funktionierende Komponenten selbst zu überprüfen. Ein gutes API-Design a) macht die Testschritte einfach und b) erzeugt klare und wertvolle Aussagen.

    Funktionstests sind am schwierigsten durchzuführen, insbesondere wenn Komponenten über Prozessgrenzen hinweg miteinander kommunizieren. Je mehr Ein- und Ausgänge an eine einzelne Komponente angeschlossen sind, desto schwieriger gestaltet sich die Funktionsprüfung, da Sie einen davon isolieren müssen, um seine Funktionalität wirklich zu testen.


Zum Thema "Komponententests nicht schreiben" stelle ich ein Beispiel vor:

TEST(exception_thrown_on_null)
{
    InternalDataStructureType sink;
    ASSERT_THROWS(sink.consumeFrom(NULL), std::logic_error);
    try {
        sink.consumeFrom(NULL);
    } catch (const std::logic_error& e) {
        ASSERT(e.what() == "You must not pass NULL as a parameter!");
    }
}

Der Autor dieses Tests hat sieben Zeilen hinzugefügt, die nicht dazu beitragen , überhaupt auf die Überprüfung des Endprodukts. Der Benutzer sollte dies niemals sehen, entweder weil a) niemand dort NULL übergeben sollte (schreiben Sie dann eine Behauptung) oder b) der NULL-Fall ein anderes Verhalten hervorrufen sollte. Wenn der Fall (b) ist, schreiben Sie einen Test, der dieses Verhalten tatsächlich überprüft.

Meine Philosophie ist, dass wir keine Implementierungsartefakte testen sollten. Wir sollten nur alles testen, was als tatsächliche Ausgabe betrachtet werden kann. Andernfalls kann nicht vermieden werden, dass zwischen den Komponententests (die eine bestimmte Implementierung erzwingen) und der Implementierung selbst die doppelte Menge an Code geschrieben wird.

Hierbei ist zu beachten, dass es gute Kandidaten für Unit-Tests gibt. In der Tat gibt es sogar mehrere Situationen, in denen ein Komponententest das einzig angemessene Mittel ist, um etwas zu verifizieren, und in denen es von hohem Wert ist, diese Tests zu schreiben und aufrechtzuerhalten. Von Anfang an enthält diese Liste nicht-triviale Algorithmen, offen gelegte Datencontainer in einer API und hochoptimierten Code, der "kompliziert" erscheint (auch bekannt als "der Nächste wird es wahrscheinlich vermasseln").

Mein spezifischer Rat an Sie: Starten Sie das Löschen von Unit-Tests mit Bedacht, wenn sie brechen, und stellen Sie sich die Frage: "Ist dies eine Ausgabe oder verschwende ich Code?" Es wird Ihnen wahrscheinlich gelingen, die Anzahl der Dinge zu reduzieren, die Ihre Zeit verschwenden.

Andres Jaan Tack
quelle
3
Bevorzugen Sie System- / Integrationstests - Das ist unglaublich schlecht. Ihr System erreicht den Punkt, an dem es diese (langsamen!) Tests verwendet, um die Dinge zu testen, die auf Geräteebene schnell erkannt werden könnten, und es dauert Stunden, bis sie ausgeführt werden, weil Sie so viele ähnliche und langsame Tests haben.
Ritch Melton
1
@RitchMelton Völlig unabhängig von der Diskussion scheint es, als bräuchten Sie einen neuen CI-Server. C Ich sollte mich nicht so verhalten.
Andres Jaan Tack
1
Ein abstürzendes Programm (wie es Behauptungen tun) sollte Ihren Testrunner (CI) nicht töten. Deshalb haben Sie einen Testläufer. So kann etwas solche Fehler erkennen und melden.
Andres Jaan Tack
1
Die Debug-Only-Assertions (keine Test-Assertions) rufen ein Dialogfeld auf, in dem das CI hängt, da es auf die Interaktion mit dem Entwickler wartet.
Ritch Melton
1
Ah, das würde viel über unsere Meinungsverschiedenheit erklären. :) Ich beziehe mich auf Behauptungen im C-Stil. Mir ist gerade erst aufgefallen, dass dies eine .NET-Frage ist. cplusplus.com/reference/clibrary/cassert/assert
Andres Jaan Tack
5

Scheint mir, als ob Ihre Unit-Tests wie ein Zauber wirken. Es ist eine gute Sache, dass es so anfällig für Veränderungen ist, denn das ist der springende Punkt. Kleine Änderungen bei Code-Break-Tests, damit Sie die Möglichkeit von Fehlern in Ihrem gesamten Programm beseitigen können.

Denken Sie jedoch daran, dass Sie nur wirklich auf Bedingungen testen müssen, die dazu führen, dass Ihre Methode fehlschlägt oder unerwartete Ergebnisse liefert. Dies würde dazu führen, dass Ihre Einheitstests anfälliger für "Brüche" sind, wenn es eher ein echtes Problem als triviale Dinge gibt.

Allerdings scheint es mir, dass Sie das Programm stark umgestalten. Führen Sie in solchen Fällen alle erforderlichen Schritte aus, entfernen Sie die alten Tests und ersetzen Sie sie anschließend durch neue. Das Reparieren von Unit-Tests lohnt sich nur, wenn Sie sie aufgrund grundlegender Änderungen in Ihrem Programm nicht reparieren. Andernfalls wird möglicherweise zu viel Zeit für das Umschreiben von Tests aufgewendet, um in Ihrem neu geschriebenen Abschnitt des Programmcodes angewendet zu werden.

Neil
quelle
3

Ich bin sicher, dass andere viel mehr Input haben werden, aber meiner Erfahrung nach sind dies einige wichtige Dinge, die Ihnen helfen werden:

  1. Verwenden Sie eine Testobjekt-Factory, um Eingabedatenstrukturen zu erstellen, damit Sie diese Logik nicht duplizieren müssen. Schauen Sie sich vielleicht eine Hilfsbibliothek wie AutoFixture an, um den Code zu reduzieren, der für die Testeinrichtung benötigt wird.
  2. Zentralisieren Sie die Erstellung des SUT für jede Testklasse, damit es leicht geändert werden kann, wenn die Dinge überarbeitet werden.
  3. Denken Sie daran, dass der Testcode genauso wichtig ist wie der Produktionscode. Es sollte auch überarbeitet werden, wenn Sie feststellen, dass Sie sich wiederholen, wenn sich der Code nicht mehr erreichbar anfühlt, usw. usw.
driis
quelle
Je häufiger Sie Code für Tests wiederverwenden, desto anfälliger werden sie, da das Ändern eines Tests jetzt einen anderen beschädigen kann. Das mag im Gegenzug für die Wartbarkeit ein angemessener Preis sein - ich komme hier nicht auf dieses Argument zurück -, aber zu argumentieren, dass die Punkte 1 und 2 die Tests weniger anfällig machen (was die Frage war), ist einfach falsch.
pdr
@driis - Richtig, Testcode hat andere Redewendungen als laufender Code. Das Ausblenden von Dingen durch Umgestalten von 'häufig verwendetem' Code und die Verwendung von Dingen wie IoC-Containern maskiert lediglich Designprobleme, die durch Ihre Tests aufgedeckt werden.
Ritch Melton
Während der Punkt, den @pdr macht, wahrscheinlich für Komponententests gültig ist, würde ich argumentieren, dass es für Integrations- / Systemtests nützlich sein könnte, in Bezug auf "die Anwendung für Aufgabe X vorbereiten" zu denken. Dies kann das Navigieren zum richtigen Ort, das Festlegen bestimmter Laufzeiteinstellungen, das Öffnen einer Datendatei usw. umfassen. Wenn mehrere Integrationstests an derselben Stelle beginnen, ist es möglicherweise nicht schlecht, diesen Code zu überarbeiten, um ihn für mehrere Tests wiederzuverwenden, wenn Sie die Risiken und Einschränkungen eines solchen Ansatzes kennen.
ein Lebenslauf vom
2

Behandeln Sie Tests so, wie Sie es mit Quellcode tun.

Versionskontrolle, Checkpoint-Releases, Issue-Tracking, "Feature Ownership", Planung und Aufwandsschätzung usw. Ich denke, dies ist der effizienteste Weg, um mit Problemen umzugehen, die Sie beschreiben.

Mücke
quelle
1

Schauen Sie sich unbedingt die XUnit-Testmuster von Gerard Meszaros an . Es gibt einen großartigen Abschnitt mit vielen Rezepten, mit denen Sie Ihren Testcode wiederverwenden und Doppelungen vermeiden können.

Wenn Ihre Tests spröde sind, kann es auch sein, dass Sie nicht genug zurückgreifen, um Doppel zu testen. Insbesondere wenn Sie zu Beginn jedes Komponententests ganze Diagramme von Objekten neu erstellen, werden die Arrangierabschnitte in Ihren Tests möglicherweise zu groß und Sie befinden sich häufig in Situationen, in denen Sie die Arrangierabschnitte in einer beträchtlichen Anzahl von Tests neu schreiben müssen, nur weil Eine Ihrer am häufigsten verwendeten Klassen hat sich geändert. Mocks und Stubs können Ihnen dabei helfen, indem sie die Anzahl der Objekte reduzieren, die Sie rehydrieren müssen, um einen relevanten Testkontext zu erhalten.

Wenn Sie die unwichtigen Details aus Ihren Testaufbauten über Mocks und Stubs entfernen und Testmuster anwenden, um Code wiederzuverwenden, sollte dies deren Fragilität erheblich verringern.

guillaume31
quelle