Welche Gestaltungsprinzipien fördern testbaren Code? (Entwerfen von testbarem Code im Vergleich zum Fahrdesign durch Tests)

54

Die meisten Projekte, an denen ich arbeite, betrachten Entwicklung und Komponententests isoliert, was das Schreiben von Komponententests zu einem späteren Zeitpunkt zu einem Albtraum macht. Mein Ziel ist es, das Testen während der Entwurfsphasen auf hoher und niedriger Ebene im Auge zu behalten.

Ich möchte wissen, ob es gut definierte Designprinzipien gibt, die testbaren Code fördern. Ein solches Prinzip, das ich in letzter Zeit kennengelernt habe, ist die Abhängigkeitsumkehr durch Abhängigkeitsinjektion und die Umkehrung der Kontrolle.

Ich habe gelesen, dass es etwas gibt, das als SOLID bekannt ist. Ich möchte verstehen, ob das Befolgen der SOLID-Prinzipien indirekt zu Code führt, der leicht zu testen ist. Wenn nicht, gibt es gut definierte Designprinzipien, die für testbaren Code werben?

Mir ist bewusst, dass es etwas gibt, das als Test Driven Development bekannt ist. Obwohl ich mehr daran interessiert bin, Code mit Blick auf das Testen während der Entwurfsphase selbst zu entwerfen, als das Design durch Tests zu steuern. Ich hoffe das macht Sinn.

Eine weitere Frage zu diesem Thema ist, ob es in Ordnung ist, ein vorhandenes Produkt / Projekt neu zu faktorisieren und Änderungen an Code und Design vorzunehmen, damit für jedes Modul ein Einheitentestfall geschrieben werden kann.

CKing
quelle
3
Schauen Sie sich dies an: googletesting.blogspot.in/2008/08/…
VS1
Danke. Ich habe gerade erst angefangen, den Artikel zu lesen und es macht schon Sinn.
1
Dies ist eine meiner Interviewfragen ("Wie können Sie Code entwerfen, der sich problemlos als Einheit testen lässt?"). Es zeigt mir mit einer Hand, ob sie Unit-Tests, Mocking / Stubbing, OOD und potenziell TDD verstehen. Leider lauten die Antworten normalerweise so etwas wie "Testdatenbank erstellen".
Chris Pitman

Antworten:

56

Ja, SOLID ist eine sehr gute Möglichkeit, Code zu entwerfen, der einfach getestet werden kann. Als kurze Grundierung:

S - Prinzip der Einzelverantwortung: Ein Objekt sollte genau eines tun und sollte das einzige Objekt in der Codebasis sein, das das eine tut. Nehmen Sie zum Beispiel eine Domainklasse, zum Beispiel eine Rechnung. Die Invoice-Klasse sollte die Datenstruktur und Geschäftsregeln einer Rechnung darstellen, wie sie im System verwendet werden. Es sollte die einzige Klasse sein, die eine Rechnung in der Codebasis darstellt. Dies kann weiter unterteilt werden, um zu sagen, dass eine Methode einen einzigen Zweck haben und die einzige Methode in der Codebasis sein sollte, die diesen Bedarf erfüllt.

Durch Befolgen dieses Prinzips erhöhen Sie die Testbarkeit Ihres Entwurfs, indem Sie die Anzahl der Tests verringern, die Sie schreiben müssen, um die gleiche Funktionalität für verschiedene Objekte zu testen. Außerdem erhalten Sie in der Regel kleinere Teile der Funktionalität, die isoliert einfacher zu testen sind.

O - Open / Closed-Prinzip: Eine Klasse sollte offen für Erweiterungen, aber geschlossen für Änderungen sein . Sobald ein Objekt vorhanden ist und ordnungsgemäß funktioniert, sollte es im Idealfall nicht mehr erforderlich sein, zu diesem Objekt zurückzukehren, um Änderungen vorzunehmen, die neue Funktionen hinzufügen. Stattdessen sollte das Objekt erweitert werden, indem es abgeleitet wird oder indem neue oder andere Abhängigkeitsimplementierungen hinzugefügt werden, um diese neue Funktionalität bereitzustellen. Dies vermeidet eine Regression. Sie können die neue Funktionalität einführen, wann und wo sie benötigt wird, ohne das Verhalten des Objekts zu ändern, da es bereits an anderer Stelle verwendet wird.

Durch die Einhaltung dieses Prinzips erhöhen Sie im Allgemeinen die Fähigkeit des Codes, "Mocks" zu tolerieren, und Sie müssen auch keine Tests umschreiben, um neues Verhalten zu antizipieren. Alle vorhandenen Tests für ein Objekt sollten weiterhin auf der nicht erweiterten Implementierung funktionieren, während auch neue Tests für neue Funktionen unter Verwendung der erweiterten Implementierung funktionieren sollten.

L - Liskov-Substitutionsprinzip: Eine Klasse A, abhängig von Klasse B, sollte in der Lage sein, jedes X: B zu verwenden, ohne den Unterschied zu kennen. Dies bedeutet im Grunde, dass alles, was Sie als Abhängigkeit verwenden, ein ähnliches Verhalten haben sollte, wie es von der abhängigen Klasse gesehen wird. Nehmen wir als kurzes Beispiel an, Sie haben eine IWriter-Schnittstelle, die Write (Zeichenfolge) verfügbar macht, die von ConsoleWriter implementiert wird. Jetzt müssen Sie stattdessen in eine Datei schreiben, um FileWriter zu erstellen. Dabei müssen Sie sicherstellen, dass FileWriter auf dieselbe Weise wie ConsoleWriter verwendet werden kann (dh, dass die abhängige Person nur mit Write (string) interagieren kann), und dass dies möglicherweise zusätzliche Informationen erfordert, die FileWriter benötigt Job (wie der Pfad und die Datei, in die geschrieben werden soll) müssen von einer anderen als der abhängigen Stelle bereitgestellt werden.

Dies ist für das Schreiben von testbarem Code enorm wichtig, da bei einem Design, das dem LSP entspricht, ein "verspottetes" Objekt jederzeit durch das Original ersetzt werden kann, ohne das erwartete Verhalten zu ändern, sodass kleine Codeteile ohne Bedenken einzeln getestet werden können dass das System dann mit den eingesteckten realen Objekten arbeitet.

I - Prinzip der Schnittstellentrennung : Eine Schnittstelle sollte über so wenige Methoden verfügen, wie möglich, um die Funktionalität der durch die Schnittstelle definierten Rolle bereitzustellen . Einfach ausgedrückt, sind mehr kleinere Schnittstellen besser als weniger größere Schnittstellen. Dies liegt daran, dass eine große Schnittstelle mehr Änderungsgründe hat und an anderer Stelle in der Codebasis weitere Änderungen verursacht, die möglicherweise nicht erforderlich sind.

Die Einhaltung von ISP verbessert die Testbarkeit, indem die Komplexität der zu testenden Systeme und die Abhängigkeiten dieser SUTs verringert werden. Wenn das zu testende Objekt von einer Schnittstelle IDoThreeThings abhängt, die DoOne (), DoTwo () und DoThree () verfügbar macht, müssen Sie ein Objekt verspotten, das alle drei Methoden implementiert, auch wenn das Objekt nur die DoTwo-Methode verwendet. Wenn das Objekt jedoch nur von IDoTwo abhängt (wodurch nur DoTwo verfügbar gemacht wird), können Sie ein Objekt mit dieser einen Methode einfacher verspotten.

D - Abhängigkeitsinversion Prinzip: Konkretionen und Abstraktionen sollten niemals von anderen Konkretionen abhängen, sondern von Abstraktionen . Dieses Prinzip erzwingt direkt den Grundsatz der losen Kopplung. Ein Objekt sollte niemals wissen müssen, was ein Objekt IST; Es sollte sich stattdessen darum kümmern, was ein Objekt tut. Die Verwendung von Interfaces und / oder abstrakten Basisklassen ist daher bei der Definition von Eigenschaften und Parametern eines Objekts oder einer Methode immer der Verwendung konkreter Implementierungen vorzuziehen. Auf diese Weise können Sie eine Implementierung gegen eine andere austauschen, ohne die Verwendung ändern zu müssen (wenn Sie auch LSP befolgen, was mit DIP einhergeht).

Auch dies ist aus Gründen der Testbarkeit von großer Bedeutung, da Sie erneut eine Scheinimplementierung einer Abhängigkeit anstelle einer "Produktions" -Implementierung in das zu testende Objekt einfügen können, während Sie das Objekt weiterhin in der exakten Form testen, in der es sich befindet in Produktion. Dies ist der Schlüssel zum Testen von Einheiten "isoliert".

KeithS
quelle
16

Ich habe gelesen, dass es etwas gibt, das als SOLID bekannt ist. Ich möchte verstehen, ob das Befolgen der SOLID-Prinzipien indirekt zu Code führt, der leicht zu testen ist.

Bei richtiger Anwendung ja. Es gibt einen Blog-Beitrag von Jeff , in dem SOLID-Prinzipien auf sehr kurze Weise erklärt werden (der erwähnte Podcast ist auch hörenswert). Ich empfehle, dort einen Blick darauf zu werfen, wenn längere Beschreibungen Sie abschrecken.

Aus meiner Erfahrung spielen 2 Prinzipien von SOLID eine wichtige Rolle beim Entwerfen von testbarem Code:

  • Prinzip der Schnittstellentrennung - Sie sollten viele kundenspezifische Schnittstellen anstelle weniger universeller Schnittstellen bevorzugen. Dies passt zum Prinzip der Einzelverantwortung und hilft Ihnen, funktions- / aufgabenorientierte Klassen zu entwerfen, die im Gegenzug viel einfacher zu testen sind (im Vergleich zu allgemeineren oder häufig missbrauchten "Managern" und "Kontexten" ) - weniger Abhängigkeiten , weniger Komplexität, feinkörniger, offensichtliche Tests. Kurz gesagt, kleine Bauteile führen zu einfachen Tests.
  • Prinzip der Abhängigkeitsumkehrung - Entwurf nach Vertrag, nicht nach Implementierung. Dies kommt Ihnen am meisten zugute, wenn Sie komplexe Objekte testen und feststellen, dass Sie nicht nur eine ganze grafische Darstellung der Abhängigkeiten benötigen , um sie einzurichten , sondern einfach die Benutzeroberfläche verspotten und damit fertig sind.

Ich glaube, diese beiden helfen Ihnen am meisten, wenn Sie auf Testbarkeit ausgelegt sind. Die verbleibenden haben ebenfalls Auswirkungen, aber ich würde sagen, nicht so groß.

(...) ob es in Ordnung ist, ein vorhandenes Produkt / Projekt neu zu faktorisieren und Änderungen an Code und Design vorzunehmen, um für jedes Modul einen Komponententestfall schreiben zu können?

Ohne bestehende Unit-Tests ist es einfach gesagt - nach Problemen zu fragen. Unit-Test ist Ihre Garantie dafür, dass Ihr Code funktioniert . Die Einführung von Breaking Change wird sofort erkannt, wenn Sie über eine ausreichende Testabdeckung verfügen.

Nun, wenn Sie wollen vorhandenen Code ändern , um Unit - Tests hinzufügen , stellt dies eine Lücke , wo Sie Tests noch nicht haben, aber nicht geändert Code bereits . Natürlich haben Sie möglicherweise keine Ahnung, welche Änderungen Sie vorgenommen haben. Diese Situation möchten Sie vermeiden.

Unit-Tests sind es wert, geschrieben zu werden, auch gegen Code, der schwer zu testen ist. Wenn Ihr Code funktioniert , aber nicht Unit-getestet ist, besteht die geeignete Lösung darin, Tests für ihn zu schreiben und dann Änderungen einzuführen. Beachten Sie jedoch, dass das Ändern von getestetem Code, um das Testen zu vereinfachen, möglicherweise keine finanziellen Mittel für Ihr Management erfordert (Sie werden wahrscheinlich hören, dass dies nur wenig oder gar keinen geschäftlichen Nutzen bringt).

km
quelle
über hohe Kohäsion und niedrige Kopplung
jk.
8

IHRE ERSTE FRAGE:

SOLID ist in der Tat der richtige Weg. Ich finde, dass die beiden wichtigsten Aspekte des Akronyms SOLID in Bezug auf die Testbarkeit das S (Single Responsibility) und das D (Dependency Injection) sind.

Einzelverantwortung : Ihre Klassen sollten wirklich nur eine Sache tun und nur eine Sache. Eine Klasse, die eine Datei erstellt, Eingaben analysiert und in die Datei schreibt, führt bereits drei Aktionen aus. Wenn Ihre Klasse nur eine Sache tut, wissen Sie genau, was Sie von ihr erwarten können, und das Entwerfen der Testfälle dafür sollte ziemlich einfach sein.

Dependency Injection (DI): Hiermit können Sie die Testumgebung steuern. Anstatt forreign-Objekte in Ihrem Code zu erstellen, fügen Sie sie über den Klassenkonstruktor oder den Methodenaufruf ein. Wenn Sie nicht testen, ersetzen Sie echte Klassen einfach durch Stubs oder Mocks, die Sie vollständig steuern.

IHRE ZWEITE FRAGE: Idealerweise schreiben Sie Tests, die die Funktionsweise Ihres Codes dokumentieren, bevor Sie ihn überarbeiten. Auf diese Weise können Sie dokumentieren, dass Ihr Refactoring dieselben Ergebnisse wie der ursprüngliche Code liefert. Ihr Problem ist jedoch, dass der Funktionscode schwer zu testen ist. Dies ist eine klassische Situation! Mein Rat ist: Überlegen Sie sorgfältig, ob Sie eine Umgestaltung vornehmen möchten, bevor Sie die Einheit testen. Falls Sie können; Schreiben Sie Tests für den Arbeitscode, überarbeiten Sie den Code und überarbeiten Sie die Tests. Ich weiß, dass es Stunden kosten wird, aber Sie werden sicherer sein, dass der überarbeitete Code genauso funktioniert wie der alte. Trotzdem habe ich oft aufgegeben. Klassen können so hässlich und chaotisch sein, dass ein Umschreiben die einzige Möglichkeit ist, sie testbar zu machen.

Morten
quelle
4

Neben den anderen Antworten, die sich auf das Erreichen einer losen Kopplung konzentrieren, möchte ich ein Wort zum Testen komplizierter Logik sagen.

Ich musste einmal eine Klasse testen, deren Logik kompliziert war, mit vielen Bedingungen, und in der es schwierig war, die Rolle der Felder zu verstehen.

Ich habe diesen Code durch viele kleine Klassen ersetzt, die eine Zustandsmaschine darstellen . Die Logik wurde viel einfacher zu befolgen, da die verschiedenen Zustände der früheren Klasse explizit wurden. Jede staatliche Klasse war unabhängig von den anderen und daher leicht zu testen.

Die Tatsache, dass die Zustände explizit angegeben wurden, erleichterte es, alle möglichen Pfade des Codes (die Zustandsübergänge) aufzulisten und somit für jeden einen Komponententest zu schreiben.

Natürlich kann nicht jede komplexe Logik als Zustandsmaschine modelliert werden.

Barjak
quelle
3

Meiner Erfahrung nach ist SOLID ein ausgezeichneter Start, da vier Aspekte von SOLID beim Testen von Einheiten wirklich gut funktionieren.

  • Prinzip der Einzelverantwortung - Jede Klasse macht eine Sache und nur eine Sache. Einen Wert berechnen, eine Datei öffnen, einen String analysieren, was auch immer. Die Anzahl der Ein- und Ausgänge sowie der Entscheidungspunkte sollte daher sehr gering sein. Das macht es einfach, Tests zu schreiben.
  • Liskov-Substitutionsprinzip - Sie sollten in der Lage sein, in Stubs und Mocks zu substituieren, ohne die gewünschten Eigenschaften (die erwarteten Ergebnisse) Ihres Codes zu ändern.
  • Prinzip der Schnittstellentrennung - Durch die Trennung der Kontaktpunkte nach Schnittstellen ist es sehr einfach, ein spöttisches Framework wie Moq zu verwenden, um Stubs und Mocks zu erstellen. Anstatt sich auf die konkreten Klassen verlassen zu müssen, verlassen Sie sich einfach auf etwas, das die Schnittstelle implementiert.
  • Prinzip der Abhängigkeitsinjektion - Mit diesem Prinzip können Sie diese Stubs und Mocks über einen Konstruktor, eine Eigenschaft oder einen Parameter in der zu testenden Methode in Ihren Code einfügen.

Ich würde auch verschiedene Muster untersuchen, insbesondere das Fabrikmuster. Angenommen, Sie haben eine konkrete Klasse, die eine Schnittstelle implementiert. Sie würden eine Factory erstellen, um die konkrete Klasse zu instanziieren, aber stattdessen die Schnittstelle zurückgeben.

public interface ISomeInterface
{
    int GetValue();
}  

public class SomeClass : ISomeInterface
{
    public int GetValue()
    {
         return 1;
    }
}

public interface ISomeOtherInterface
{
    bool IsSuccess();
}

public class SomeOtherClass : ISomeOtherInterface
{
     private ISomeInterface m_SomeInterface;

     public SomeOtherClass(ISomeInterface someInterface)
     {
          m_SomeInterface = someInterface;
     }

     public bool IsSuccess()
     {
          return m_SomeInterface.GetValue() == 1;
     }
}

public class SomeFactory
{
     public virtual ISomeInterface GetSomeInterface()
     {
          return new SomeClass();
     }

     public virtual ISomeOtherInterface GetSomeOtherInterface()
     {
          ISomeInterface someInterface = GetSomeInterface();

          return new SomeOtherClass(someInterface);
     }
}

In Ihren Tests können Sie Moq oder ein anderes spöttisches Framework verwenden, um diese virtuelle Methode zu überschreiben und eine Schnittstelle Ihres Designs zurückzugeben. In Bezug auf den Implementierungscode hat sich das Werk jedoch nicht geändert. Sie können auch viele Implementierungsdetails auf diese Weise verbergen. Es ist Ihrem Implementierungscode egal, wie die Schnittstelle aufgebaut ist. Es geht nur darum, eine Schnittstelle zurückzugewinnen.

Wenn Sie dies ein wenig erweitern möchten, empfehle ich dringend, The Art of Unit Testing zu lesen . Es gibt einige großartige Beispiele für die Verwendung dieser Prinzipien, und es ist eine ziemlich schnelle Lektüre.

bwalk2895
quelle
1
Es wird das Abhängigkeits- "Inversions" -Prinzip genannt, nicht das "Injektions" -Prinzip.
Mathias Lykkegaard Lorenzen