it-swarm.com.de

Sollte ich in TDD Unit-Tests zum überarbeiteten Code hinzufügen?

Sollte ich beim Umgestalten meines Codes mithilfe von Test Driven Development (TDD) weiterhin neue Testfälle für den neuen überarbeiteten Code erstellen, den ich schreibe?

Diese Frage basiert auf den folgenden TDD-Schritten:

  1. Schreiben Sie gerade genug von einem Test, damit der Code fehlschlägt
  2. Schreiben Sie gerade genug Code, damit der Test bestanden werden kann
  3. Refactor

Mein Zweifel liegt im Refactor-Schritt. Sollten neue Unit-Testfälle für überarbeiteten Code geschrieben werden?

Um dies zu veranschaulichen, werde ich ein vereinfachtes Beispiel geben:


Angenommen, ich mache ein Rollenspiel und ein HPContainer-System, das Folgendes tun sollte:

  • Erlaube dem Spieler, HP zu verlieren.
  • HP sollte nicht unter Null gehen.

Um das zu beantworten, schreibe ich folgende Tests:

[Test]
public void LoseHP_LosesHP_DecreasesCurrentHPByThatAmount()
{
    int initialHP = 100;
    HPContainer hpContainer= new HPContainer(initialHP);
    hpContainer.Lose(5)
    int currentHP = hpContainer.Current();
    Assert.AreEqual(95, currentHP);
}
[Test]
public void LoseHP_LosesMoreThanCurrentHP_CurrentHPIsZero()
{
    int initialHP = 100;
    HPContainer hpContainer= new HPContainer(initialHP);
    hpContainer.Lose(200)
    int currentHP = hpContainer.Current();
    Assert.AreEqual(0, currentHP);
}

Um die Anforderungen zu erfüllen, implementiere ich den folgenden Code:

public class HPContainer
{
    private int currentHP = 0;

    public void HPContainer(int initialHP)
    {
        this.currentHP = initialHP; 
    }

    public int Current()
    {
        return this.currentHP;
    }

    public void Lose(int value)
    {
        this.currentHP -= value;
        if (this.currentHP < 0)
            this.currentHP = 0;
    }
}

Gut!

Die Tests bestehen.

Wir haben unseren Job gemacht!


Nehmen wir nun an, der Code wächst und ich möchte diesen Code umgestalten. Ich entscheide, dass das Hinzufügen einer Clamper - Klasse wie folgt eine gute Lösung ist.

public static class Clamper
{
    public static int ClampToNonNegative(int value)
    {
        if(value < 0)
            return 0;
        return value;
    }
}

Ändern der HPContainer-Klasse:

public class HPContainer
{
    private int currentHP = 0;

    public void HPContainer(int initialHP)
    {
        this.currentHP = initialHP; 
    }

    public int Current()
    {
        return this.currentHP;
    }

    public void Lose(int value)
    {
        this.currentHP = Clamper.ClampToNonNegative(this.currentHP - value);
    }
}

Die Tests bestehen immer noch, daher sind wir sicher, dass wir keine Regression in unseren Code eingeführt haben.

Aber meine Frage ist:

Sollten Unit-Tests zur Klasse Clamper hinzugefügt werden?


Ich sehe zwei gegensätzliche Argumente:

  1. Ja, Tests sollten hinzugefügt werden, da wir Clamper von der Regression abdecken müssen. Es wird sichergestellt, dass wir, wenn Clamper jemals geändert werden muss, dies sicher mit Testabdeckung tun können.

  2. Nein, Clamper ist nicht Teil der Geschäftslogik und wird bereits von den Testfällen von HPContainer abgedeckt. Das Hinzufügen von Tests führt nur zu unnötigem Durcheinander und verlangsamt das zukünftige Refactoring.

Was ist die richtige Argumentation nach den TDD-Grundsätzen und bewährten Praktiken?

35
Albuquerque

Vorher und nachher testen

Sollte ich in TDD Unit-Tests zum überarbeiteten Code hinzufügen?

"Refactored Code" bedeutet, dass Sie die Tests hinzufügen after Sie haben Refactored. Hier fehlt der Punkt zum Testen Ihrer Änderungen. TDD ist sehr darauf angewiesen, vor und nach der Implementierung/Umgestaltung/Korrektur von Code zu testen.

  • Wenn Sie nachweisen können, dass die Ergebnisse der Unit-Tests vor und nach dem Refactoring gleich sind, haben Sie nachgewiesen, dass das Refactoring das Verhalten nicht geändert hat.
  • Wenn Ihre Tests vom Fehlschlagen (vorher) zum Bestehen (nachher) übergegangen sind, haben Sie bewiesen, dass Ihre Implementierungen/Korrekturen das vorliegende Problem gelöst haben.

Sie sollten Ihre Unit-Tests nicht hinzufügen nach Sie Refactor, sondern vor (vorausgesetzt, diese Tests sind natürlich gerechtfertigt).


Refactoring bedeutet unverändertes Verhalten

Sollten neue Unit-Testfälle für überarbeiteten Code geschrieben werden?

Das Definition von Refactoring besteht darin, den Code zu ändern, ohne sein Verhalten zu ändern.

Refactoring ist eine disziplinierte Technik zur Umstrukturierung eines vorhandenen Codekörpers, bei der die interne Struktur geändert wird, ohne das externe Verhalten zu ändern.

Da Unit-Tests speziell zum Testen des Verhaltens geschrieben wurden, ist es für Sie nicht sinnvoll, nach dem Refactoring zusätzliche Unit-Tests zu verlangen.

  • Wenn diese neuen Tests relevant sind, waren sie bereits vor dem Refactoring relevant.
  • Wenn diese neuen Tests nicht relevant sind, werden sie offensichtlich nicht benötigt.
  • Wenn diese neuen Tests nicht relevant waren, aber jetzt relevant sind, muss Ihr Refactoring ausnahmslos das Verhalten geändert haben, was bedeutet, dass Sie mehr als nur Refactoring durchgeführt haben.

Refactoring kann von Natur aus niemals dazu führen, dass zusätzliche Komponententests erforderlich sind, die zuvor nicht benötigt wurden.


Das Hinzufügen von Tests muss manchmal erfolgen

Davon abgesehen, wenn es Tests gab, die Sie von Anfang an hätten haben sollen, die Sie aber bis jetzt vergessen haben, können Sie sie natürlich hinzufügen. Verstehe meine Antwort nicht so, dass du keine Tests hinzufügen kannst, nur weil du vergessen hast, sie vorher zu schreiben.

In ähnlicher Weise vergessen Sie manchmal, einen Fall abzudecken, und dies wird erst sichtbar, nachdem Sie auf einen Fehler gestoßen sind. Es empfiehlt sich, dann einen neuen Test zu schreiben, der nun nach diesem Problemfall sucht.


Unit Testing andere Dinge

Sollten Unit-Tests zur Klasse Clamper hinzugefügt werden?

Es scheint mir, dass Clamper eine internal Klasse sein sollte, da es eine versteckte Abhängigkeit von Ihrem HPContainer ist. Der Verbraucher Ihrer HPContainer -Klasse weiß nicht, dass Clamper existiert, und muss das nicht wissen.

Unit-Tests konzentrieren sich nur auf externes (öffentliches) Verhalten gegenüber Verbrauchern. Da Clamperinternal sein sollte, sind keine Komponententests erforderlich.

Wenn sich Clamper insgesamt in einer anderen Assembly befindet, müssen Unit-Tests durchgeführt werden, da diese öffentlich sind. Ihre Frage macht jedoch unklar, ob dies relevant ist.

Nebenbemerkung
Ich werde hier nicht auf eine ganze IoC-Predigt eingehen. Einige versteckte Abhängigkeiten sind akzeptabel, wenn sie rein sind (d. H. Staatenlos) und nicht verspottet werden müssen - z. Niemand erzwingt wirklich, dass die .NET-Klasse Math injiziert wird, und Ihre Clamper unterscheidet sich funktional nicht von Math.
Ich bin sicher, dass andere anderer Meinung sind und den Ansatz "Alles injizieren" verfolgen. Ich bin nicht anderer Meinung, dass dies möglich ist, aber es steht nicht im Mittelpunkt dieser Antwort, da es meiner Meinung nach nicht für die gestellte Frage relevant ist.


Klemmen?

Ich denke nicht, dass die Klemmmethode von Anfang an alles ist, was nötig ist.

public static int ClampToNonNegative(int value)
{
    if(value < 0)
        return 0;
    return value;
}

Was Sie hier geschrieben haben, ist eine eingeschränktere Version der vorhandenen Methode Math.Max(). Jede Verwendung:

this.currentHP = Clamper.ClampToNonNegative(this.currentHP - value);

kann durch Math.Max ersetzt werden:

this.currentHP = Math.Max(this.currentHP - value, 0);

Wenn Ihre Methode nichts anderes als ein Wrapper um eine einzelne vorhandene Methode ist, ist es sinnlos, sie zu haben.

50
Flater

Dies kann als zwei Schritte angesehen werden:

  • Zuerst erstellen Sie eine neue öffentliche Klasse Clamper (ohne HPContainer zu ändern). Dies ist eigentlich kein Refactoring, und wenn Sie TDD streng und buchstäblich nach den Nano-Zyklen von TDD anwenden, dürfen Sie nicht einmal die erste Codezeile für diese Klasse schreiben, bevor Sie mindestens schreiben ein Unit-Test dafür.

  • Dann beginnen Sie mit der Umgestaltung der HPContainer mithilfe der Klasse Clamper. Unter der Annahme, dass die vorhandenen Komponententests für diese Klasse bereits eine ausreichende Abdeckung bieten, müssen in diesem Schritt keine weiteren Komponententests hinzugefügt werden.

Also ja Wenn Sie eine wiederverwendbare Komponente erstellen, um sie in naher Zukunft für ein Refactoring zu verwenden, sollten Sie Komponententests für die Komponente hinzufügen. Und nein, während des Refactorings fügen Sie normalerweise keine weiteren Unit-Tests hinzu.

Ein anderer Fall ist, wenn Clamper immer noch privat/intern bleibt und nicht zur Wiederverwendung vorgesehen ist. Dann kann die gesamte Extraktion als ein Refactoring-Schritt angesehen werden, und das Hinzufügen neuer Komponententests bringt nicht unbedingt einen Nutzen. In diesen Fällen würde ich jedoch auch berücksichtigen, wie komplex die Komponenten sind. Wenn die beiden Komponenten so komplex sind, dass die Hauptursache für einen fehlgeschlagenen Test, bei dem beide Tests schwer zu erkennen sind, eine gute Idee ist Stellen Sie einzelne Komponententests für beide bereit: einen Satz von Tests, die Clamper selbst testen, und einen Test HPContainer mit einem injizierten Mock für Clamper.

21
Doc Brown

Clamper ist eine eigene Einheit - und Einheiten sollten mit Unit-Tests getestet werden - da Einheiten an anderer Stelle verwendet werden können. Was großartig ist, wenn Clamper Ihnen auch bei der Implementierung von ManaContainer, FoodContainer, DamageCalculator usw. hilft.

Wenn Clamper nur ein Implementierungsdetail wäre, kann es nicht direkt getestet werden. Dies liegt daran, dass wir nicht als Einheit darauf zugreifen können, um es zu testen.

In Ihrem ersten Beispiel wird die Prüfung als Implementierungsdetail behandelt. Aus diesem Grund haben Sie keinen Test geschrieben, in dem überprüft wird, ob die Anweisung if isoliert funktioniert. Als Implementierungsdetail besteht die einzige Möglichkeit, es zu testen, darin, das beobachtbare Verhalten der Einheit zu testen, von der es sich um ein Implementierungsdetail handelt (in diesem Fall ist das Verhalten von HPContainer um Lose(...) zentriert). .

Um das Refactoring beizubehalten, aber ein Implementierungsdetail zu hinterlassen:

public class HPContainer
{
    private int currentHP = 0;

    public void HPContainer(int initialHP)
    {
        this.currentHP = initialHP; 
    }

    public int Current()
    {
        return this.currentHP;
    }

    public void Lose(int value)
    {
        this.currentHP = ClampToNonNegative(this.currentHP - value);
    }

    private static int ClampToNonNegative(int value)
    {
        if(value < 0)
            return 0;
        return value;
    }
}

Gibt Ihnen die Ausdruckskraft, überlässt aber die Entscheidung, später eine neue Einheit einzuführen. Hoffentlich, wenn Sie mehrere Fälle von Duplizierung haben, aus denen Sie eine wiederverwendbare Lösung vernünftigerweise verallgemeinern können. Im Moment (Ihr zweites Beispiel) wird davon ausgegangen, dass es benötigt wird.

4
Kain0_0

Nein, schreibe keine Tests für die Klasse Clamper,
, da es bereits durch Tests für die Klasse HPContainer getestet wurde.

Wenn Sie eine einfachste und schnellstmögliche Lösung schreiben, um Tests zu bestehen, erhalten Sie eine große Klasse/Funktion, die alles kann.

Wenn Sie mit dem Refactoring beginnen, können Sie Duplikate oder einige Muster in der Logik erkennen, da Sie jetzt das gesamte Bild der Implementierung sehen können.
Während des Refactorings entfernen Sie Duplikate, indem Sie Duplikate in dedizierte Methoden oder Klassen extrahieren.

Wenn Sie neu eingeführte Klassen über den Konstruktor übergeben möchten, müssen Sie nur eine Stelle in den Tests ändern, an der Sie die Klasse unter dem Test einrichten, um neue Abhängigkeiten zu übergeben. Dies sollte nur eine Änderung des Testcodes "erlaubt" während des Refactorings sein.

Wenn Sie Tests für die während des Refactorings eingeführten Klassen schreiben, gelangen Sie in eine "Endlosschleife".
Sie können nicht mit verschiedenen Implementierungen "spielen", weil Sie "gezwungen" wurden, Tests für neue Klassen zu schreiben, was albern ist, da diese Klassen bereits durch Tests für die Hauptklasse getestet werden.

In den meisten Fällen extrahiert Refactoring eine doppelte oder komplizierte Logik auf lesbarere und strukturiertere Weise.

2
Fabio

Sollten Unit-Tests zur Klasse Clamper hinzugefügt werden?

Noch nicht.

Das Ziel ist sauberer Code, der funktioniert. Rituale, die nicht zu diesem Ziel beitragen, sind Verschwendung.

Ich werde für Code bezahlt, der funktioniert, nicht für Tests. Meine Philosophie ist es daher, so wenig wie möglich zu testen, um ein bestimmtes Vertrauensniveau zu erreichen - Kent Beck, 2008

Ihr Refactoring ist ein Implementierungsdetail. Das externe Verhalten des zu testenden Systems hat sich überhaupt nicht geändert. Das Schreiben einer neuen Sammlung von Tests für dieses Implementierungsdetail wird Ihr Vertrauen überhaupt nicht verbessern.

Verschieben der Implementierung in eine neue Funktion, eine neue Klasse oder eine neue Datei - wir tun dies aus einer Reihe von Gründen, die nicht mit dem Verhalten des Codes zusammenhängen. Wir müssen noch keine neue Testsuite einführen. Dies sind Änderungen in der Struktur, nicht im Verhalten

Programmiertests sollten empfindlich gegenüber Verhaltensänderungen und unempfindlich gegenüber Strukturänderungen sein. - Kent Beck, 2019

Der Punkt, an dem wir anfangen, über Veränderungen nachzudenken, ist, wenn wir daran interessiert sind, das Verhalten von Clamper zu ändern, und die zusätzliche Zeremonie, ein HPContainer zu erstellen, beginnt sich in den Weg zu stellen.

Sie wollten eine Banane, aber Sie bekamen einen Gorilla, der die Banane und den gesamten Dschungel hielt. - Joe Armstrong

Wir versuchen, die Situation zu vermeiden, in der unsere Tests (die als Dokumentation des erwarteten Verhaltens eines Moduls in unserer Lösung dienen) mit einer Reihe irrelevanter Details verschmutzt sind. Sie haben wahrscheinlich Beispiele für Tests gesehen, bei denen ein Testobjekt mit einer Reihe von Nullobjekten erstellt wurde, da für den aktuellen Anwendungsfall keine echten Implementierungen erforderlich sind, Sie den Code jedoch nicht ohne sie aufrufen können.

Für rein strukturelle Refactorings müssen Sie jedoch keine neuen Tests einführen.

2
VoiceOfUnreason

Persönlich bin ich fest davon überzeugt, nur gegen stabile Schnittstellen (ob extern oder intern) zu testen, die wahrscheinlich nicht von Refactoring betroffen sind. Ich mag es nicht, Tests zu erstellen, die das Refactoring verhindern (ich habe Fälle gesehen, in denen Leute ein Refactoring nicht implementieren konnten, weil es zu viele Tests brechen würde). Wenn eine Komponente oder ein Subsystem mit anderen Komponenten oder Subsystemen einen Vertrag über die Bereitstellung einer bestimmten Schnittstelle abgeschlossen hat, testen Sie diese Schnittstelle. Wenn eine Schnittstelle rein intern ist, testen Sie sie nicht und werfen Sie Ihre Tests nicht weg, sobald sie ihre Arbeit erledigt haben.

1
Michael Kay

Unit-Tests geben Ihnen die Gewissheit, dass Ihre Refactoring-Bemühungen keine Fehler verursacht haben.

Sie schreiben also Komponententests und stellen sicher, dass sie bestanden werden, ohne den vorhandenen Code zu ändern.

Anschließend überarbeiten Sie, um sicherzustellen, dass Ihre Komponententests dabei nicht fehlschlagen.

Auf diese Weise haben Sie ein gewisses Maß an Sicherheit, dass Ihr Refactoring die Dinge nicht kaputt gemacht hat. Dies gilt natürlich nur, wenn Ihre Komponententests korrekt sind und alle möglichen Codepfade im Originalcode abdecken. Wenn Sie in den Tests etwas verpassen, laufen Sie immer noch Gefahr, dass Ihr Refactoring kaputt geht.

0
jwenting

So strukturiere und denke ich im Allgemeinen gerne über meine Tests und meinen Code nach. Code sollte in Ordnern organisiert sein, Ordner können Unterordner haben, die ihn weiter unterteilen, und Ordner, die Blätter sind (keine Unterordner haben), werden als Datei bezeichnet. Die Tests sollten auch in einer entsprechenden Hierarchie organisiert sein, die die Hierarchie des Hauptcodes widerspiegelt.

In Sprachen, in denen Ordner keinen Sinn ergeben, können Sie sie durch Pakete/Module/etc oder andere ähnliche hierarchische Strukturen in Ihrer Sprache ersetzen. Es spielt keine Rolle, was das hierarchische Element in Ihrem Projekt ist. Der wichtige Punkt hierbei ist, Ihre Tests und den Hauptcode mit übereinstimmenden Hierarchien zu organisieren.

Die Tests für einen Ordner innerhalb der Hierarchie sollten jeden Code unter dem entsprechenden Ordner der Hauptcodebasis vollständig abdecken. Ein Test, der indirekt Code aus verschiedenen Teilen der Hierarchie testet, ist zufällig und zählt nicht für die Abdeckung dieses anderen Ordners. Im Idealfall sollte es keinen Code geben, der nur durch Tests aus verschiedenen Teilen der Hierarchie aufgerufen und getestet wird.

Ich empfehle nicht, die Testhierarchie in die Klassen-/Funktionsebene zu unterteilen. Es ist normalerweise zu feinkörnig und es gibt Ihnen nicht viel Vorteil, Dinge in diesem Detail zu unterteilen. Wenn eine Hauptcodedatei groß genug ist, um mehrere Testdateien zu rechtfertigen, weist dies normalerweise darauf hin, dass die Datei zu viel tut und hätte beschädigt werden müssen.

Wenn sich Ihre neue Klasse/Funktion unter dieser Organisationsstruktur unter demselben Blattordner befindet wie der gesamte Code, der sie verwendet, benötigt sie keine eigenen Tests, solange die Tests für diese Datei sie bereits abdecken. Wenn Sie andererseits die neue Klasse/Methode als groß genug oder unabhängig genug betrachten, um eine eigene Datei/einen eigenen Ordner in der Hierarchie zu gewährleisten, sollten Sie auch die entsprechende Testdatei/den entsprechenden Testordner erstellen.

Im Allgemeinen sollte eine Datei ungefähr so ​​groß sein, dass Sie den groben Umriss in Ihren Kopf einfügen können und wo Sie einen Absatz schreiben können, um den Inhalt der Dateien zu erläutern und zu beschreiben, was sie zusammenbringt. Als Faustregel gilt für mich normalerweise ein Screenful (ein Ordner sollte nicht mehr als einen Screenful von Unterordnern haben, eine Datei sollte nicht mehr als einen Screenful von Klassen/Funktionen der obersten Ebene haben, eine Funktion sollte nicht mehr als einen Bildschirm voller Zeilen haben). Wenn es schwierig ist, sich den Umriss der Datei vorzustellen, ist die Datei wahrscheinlich zu groß.

0
Lie Ryan

Wie andere Antworten festgestellt haben, klingt das, was Sie beschreiben, nicht nach Refactoring. Das Anwenden von TDD auf das Refactoring würde folgendermaßen aussehen:

  1. Identifizieren Sie Ihre API-Oberfläche. Per Definition ändert Refactoring Ihre API-Oberfläche nicht. Wenn der Code ohne eine klar gestaltete API-Oberfläche geschrieben wurde und die Verbraucher von Implementierungsdetails abhängig sind, treten größere Probleme auf, die durch Refactoring nicht behoben werden können. Hier definieren Sie entweder eine API-Oberfläche, sperren alles andere und erhöhen die Hauptversionsnummer, um anzuzeigen, dass die neue Version nicht abwärtskompatibel ist, oder werfen das gesamte Projekt weg und schreiben es von Grund auf neu.

  2. Schreiben Sie Tests gegen die API-Oberfläche. Stellen Sie sich die API als Garantien vor, z. B. gibt die Methode Foo ein aussagekräftiges Ergebnis zurück, wenn ein Parameter angegeben wird, der die angegebenen Bedingungen erfüllt, und löst ansonsten eine bestimmte Ausnahme aus. Schreiben Sie Tests für jede Garantie, die Sie identifizieren können. Überlegen Sie, was die API tun soll und nicht, was sie tatsächlich tut. Wenn es eine Originalspezifikation oder -dokumentation gab, studieren Sie diese. Wenn nicht, schreiben Sie welche. Code ohne Dokumentation ist weder richtig noch falsch. Schreiben Sie keine Tests für etwas, das nicht in der API-Spezifikation enthalten ist.

  3. Ändern Sie den Code und führen Sie Ihre Tests häufig aus, um sicherzustellen, dass Sie keine Garantien der API verletzt haben.

In vielen Organisationen besteht eine Trennung zwischen Entwicklern und Testern. Entwickler, die TDD zumindest informell nicht praktizieren, sind sich häufig der Eigenschaften nicht bewusst, die Code testbar machen. Wenn alle Entwickler testbaren Code schreiben würden, müssten keine Frameworks verspottet werden. Code, der nicht auf Testbarkeit ausgelegt ist, verursacht ein Henne-Ei-Problem. Sie können ohne Tests nicht umgestalten und keine Tests schreiben, bis Sie den Code repariert haben. Die Kosten, TDD von Anfang an nicht zu üben, sind enorm. Änderungen kosten wahrscheinlich mehr als das ursprüngliche Projekt. Auch hier geben Sie sich damit ab, entweder bahnbrechende Änderungen vorzunehmen oder das Ganze wegzuwerfen.

0
StackOverthrow