Unit-Test-Code mit einer Dateisystemabhängigkeit

138

Ich schreibe eine Komponente, die angesichts einer ZIP-Datei Folgendes benötigt:

  1. Entpacken Sie die Datei.
  2. Suchen Sie eine bestimmte DLL unter den entpackten Dateien.
  3. Laden Sie diese DLL durch Reflektion und rufen Sie eine Methode darauf auf.

Ich möchte diese Komponente einem Unit-Test unterziehen.

Ich bin versucht, Code zu schreiben, der sich direkt mit dem Dateisystem befasst:

void DoIt()
{
   Zip.Unzip(theZipFile, "C:\\foo\\Unzipped");
   System.IO.File myDll = File.Open("C:\\foo\\Unzipped\\SuperSecret.bar");
   myDll.InvokeSomeSpecialMethod();
}

Aber die Leute sagen oft: "Schreiben Sie keine Komponententests, die auf dem Dateisystem, der Datenbank, dem Netzwerk usw. beruhen."

Wenn ich dies auf Unit-Test-freundliche Weise schreiben würde, würde es wahrscheinlich so aussehen:

void DoIt(IZipper zipper, IFileSystem fileSystem, IDllRunner runner)
{
   string path = zipper.Unzip(theZipFile);
   IFakeFile file = fileSystem.Open(path);
   runner.Run(file);
}

Yay! Jetzt ist es testbar; Ich kann der DoIt-Methode Test-Doubles (Mocks) hinzufügen. Aber zu welchen Kosten? Ich musste jetzt 3 neue Schnittstellen definieren, um dies testbar zu machen. Und was genau teste ich? Ich teste, ob meine DoIt-Funktion ordnungsgemäß mit ihren Abhängigkeiten interagiert. Es wird nicht getestet, ob die Zip-Datei ordnungsgemäß entpackt wurde usw.

Es fühlt sich nicht mehr so ​​an, als würde ich die Funktionalität testen. Es fühlt sich an, als würde ich nur Klasseninteraktionen testen.

Meine Frage lautet : Was ist der richtige Weg, um etwas zu testen, das vom Dateisystem abhängig ist?

Bearbeiten Ich bin mit .NET, aber das Konzept könnte Java oder nativen Code beantragen.

Judah Gabriel Himango
quelle
8
Die Leute sagen, schreiben Sie nicht in einem Unit-Test in das Dateisystem, denn wenn Sie versucht sind, in das Dateisystem zu schreiben, verstehen Sie nicht, was einen Unit-Test ausmacht. Ein Komponententest interagiert normalerweise mit einem einzelnen realen Objekt (der zu testenden Einheit), und alle anderen Abhängigkeiten werden verspottet und übergeben. Die Testklasse besteht dann aus Testmethoden, die die logischen Pfade durch die Methoden des Objekts und NUR die logischen Pfade in validieren das zu testende Gerät.
Christopher Perry
1
In Ihrer Situation wäre der einzige Teil, der Unit-Tests benötigt, der Ort, an myDll.InvokeSomeSpecialMethod();dem Sie überprüfen würden, ob er sowohl in Erfolgs- als auch in Misserfolgssituationen korrekt funktioniert, sodass ich keinen Unit-Test durchführen würde. Dies bedeutet DoItjedoch DllRunner.Run, dass ein UNIT-Test missbraucht wird, um zu überprüfen, ob der gesamte Prozess funktioniert ein akzeptabler Missbrauch und da es sich um einen Integrationstest handelt, der einen Unit-Test maskiert, müssen die normalen Unit-Test-Regeln nicht so streng angewendet werden
MikeT

Antworten:

47

Daran ist wirklich nichts auszusetzen, es ist nur eine Frage, ob Sie es einen Unit-Test oder einen Integrationstest nennen. Sie müssen nur sicherstellen, dass bei der Interaktion mit dem Dateisystem keine unbeabsichtigten Nebenwirkungen auftreten. Stellen Sie insbesondere sicher, dass Sie nach sich selbst bereinigen - alle von Ihnen erstellten temporären Dateien löschen - und dass Sie nicht versehentlich eine vorhandene Datei überschreiben, die zufällig denselben Dateinamen wie eine von Ihnen verwendete temporäre Datei hat. Verwenden Sie immer relative Pfade und keine absoluten Pfade.

Es wäre auch eine gute Idee, chdir()vor dem Ausführen des Tests in ein temporäres Verzeichnis zu wechseln und chdir()danach wieder zurückzukehren.

Adam Rosenfield
quelle
27
+1 Beachten Sie jedoch, dass dies prozessweit chdir()ist, sodass Sie möglicherweise die Möglichkeit beeinträchtigen, Ihre Tests parallel auszuführen, wenn Ihr Testframework oder eine zukünftige Version davon dies unterstützt.
69

Yay! Jetzt ist es testbar; Ich kann der DoIt-Methode Test-Doubles (Mocks) hinzufügen. Aber zu welchen Kosten? Ich musste jetzt 3 neue Schnittstellen definieren, um dies testbar zu machen. Und was genau teste ich? Ich teste, ob meine DoIt-Funktion ordnungsgemäß mit ihren Abhängigkeiten interagiert. Es wird nicht getestet, ob die Zip-Datei ordnungsgemäß entpackt wurde usw.

Sie haben den Nagel direkt auf den Kopf getroffen. Was Sie testen möchten, ist die Logik Ihrer Methode, nicht unbedingt, ob eine echte Datei adressiert werden kann. Sie müssen (in diesem Komponententest) nicht testen, ob eine Datei korrekt entpackt wurde. Ihre Methode nimmt dies als selbstverständlich an. Die Schnittstellen sind für sich genommen wertvoll, da sie Abstraktionen bereitstellen, gegen die Sie programmieren können, anstatt sich implizit oder explizit auf eine konkrete Implementierung zu verlassen.

andreas buykx
quelle
12
Die angegebene testbare DoItFunktion muss nicht einmal getestet werden. Wie der Fragesteller zu Recht betonte, gibt es nichts mehr zu testen. Jetzt ist es die Implementierung von IZipper, IFileSystemund IDllRunnerdas muss getestet werden, aber genau diese Dinge wurden für den Test verspottet!
Ian Goldby
56

Ihre Frage enthüllt einen der schwierigsten Teile des Testens für Entwickler, die gerade erst damit anfangen:

"Was zum Teufel teste ich?"

Ihr Beispiel ist nicht sehr interessant, da es nur einige API-Aufrufe zusammenklebt. Wenn Sie also einen Komponententest dafür schreiben würden, würden Sie am Ende nur behaupten, dass Methoden aufgerufen wurden. Tests wie dieser koppeln Ihre Implementierungsdetails eng mit dem Test. Dies ist schlecht, da Sie den Test jetzt jedes Mal ändern müssen, wenn Sie die Implementierungsdetails Ihrer Methode ändern, da das Ändern der Implementierungsdetails Ihre Tests unterbricht!

Schlechte Tests zu haben ist tatsächlich schlimmer als überhaupt keine Tests zu haben.

In Ihrem Beispiel:

void DoIt(IZipper zipper, IFileSystem fileSystem, IDllRunner runner)
{
   string path = zipper.Unzip(theZipFile);
   IFakeFile file = fileSystem.Open(path);
   runner.Run(file);
}

Während Sie Mocks übergeben können, gibt es keine Logik in der zu testenden Methode. Wenn Sie einen Unit-Test dafür versuchen würden, könnte dies ungefähr so ​​aussehen:

// Assuming that zipper, fileSystem, and runner are mocks
void testDoIt()
{
  // mock behavior of the mock objects
  when(zipper.Unzip(any(File.class)).thenReturn("some path");
  when(fileSystem.Open("some path")).thenReturn(mock(IFakeFile.class));

  // run the test
  someObject.DoIt(zipper, fileSystem, runner);

  // verify things were called
  verify(zipper).Unzip(any(File.class));
  verify(fileSystem).Open("some path"));
  verify(runner).Run(file);
}

Herzlichen Glückwunsch, Sie haben die Implementierungsdetails Ihrer DoIt()Methode im Grunde genommen in einen Test kopiert . Viel Spaß beim Unterhalten.

Wenn Sie Tests schreiben, möchten Sie das WAS und nicht das WIE testen . Weitere Informationen finden Sie unter Black-Box-Tests .

Das WAS ist der Name Ihrer Methode (oder sollte es zumindest sein). Das WIE sind all die kleinen Implementierungsdetails, die in Ihrer Methode enthalten sind. Gute Tests ermöglichen es Ihnen, das WIE auszutauschen, ohne das WAS zu brechen .

Denken Sie so darüber nach, fragen Sie sich:

"Wenn ich die Implementierungsdetails dieser Methode ändere (ohne den öffentlichen Auftrag zu ändern), werden meine Tests dadurch unterbrochen?"

Wenn die Antwort ja lautet, testen Sie das WIE und nicht das WAS .

Um Ihre spezielle Frage zum Testen von Code mit Dateisystemabhängigkeiten zu beantworten, nehmen wir an, Sie hatten etwas Interessanteres mit einer Datei vor und wollten den Base64-codierten Inhalt von a byte[]in einer Datei speichern. Sie können Streams verwenden, um zu testen, ob Ihr Code das Richtige tut, ohne überprüfen zu müssen, wie er es tut. Ein Beispiel könnte so etwas sein (in Java):

interface StreamFactory {
    OutputStream outStream();
    InputStream inStream();
}

class Base64FileWriter {
    public void write(byte[] contents, StreamFactory streamFactory) {
        OutputStream outputStream = streamFactory.outStream();
        outputStream.write(Base64.encodeBase64(contents));
    }
}

@Test
public void save_shouldBase64EncodeContents() {
    OutputStream outputStream = new ByteArrayOutputStream();
    StreamFactory streamFactory = mock(StreamFactory.class);
    when(streamFactory.outStream()).thenReturn(outputStream);

    // Run the method under test
    Base64FileWriter fileWriter = new Base64FileWriter();
    fileWriter.write("Man".getBytes(), streamFactory);

    // Assert we saved the base64 encoded contents
    assertThat(outputStream.toString()).isEqualTo("TWFu");
}

Der Test verwendet eine ByteArrayOutputStreamaber in der Anwendung (mit Dependency Injection) dem eigentlichen StreamFactory (vielleicht FileStreamFactory genannt) zurückkehren würde FileOutputStreamvon outputStream()und zu einem schreiben würde File.

Das Interessante an der writeMethode hier war, dass sie den Inhalt aus Base64-codiert herausschrieb, also haben wir darauf getestet. Für Ihre DoIt()Methode würde dies mit einem Integrationstest angemessener getestet .

Christopher Perry
quelle
1
Ich bin mir nicht sicher, ob ich Ihrer Nachricht hier zustimme. Wollen Sie damit sagen, dass es nicht notwendig ist, diese Art von Methode einem Unit-Test zu unterziehen? Sie sagen also im Grunde, TDD ist schlecht? Als ob Sie TDD machen, können Sie diese Methode nicht schreiben, ohne vorher einen Test zu schreiben. Oder können Sie sich auf die Vermutung verlassen, dass Ihre Methode keinen Test erfordert? Der Grund dafür, dass ALLE Unit-Test-Frameworks eine "Verifizierungs" -Funktion enthalten, besteht darin, dass die Verwendung in Ordnung ist. "Das ist schlecht, weil Sie jetzt den Test jedes Mal ändern müssen, wenn Sie die Implementierungsdetails Ihrer Methode ändern" ... Willkommen in der Welt der Unit-Tests.
Ronnie
2
Sie sollten den VERTRAG einer Methode testen, nicht deren Implementierung. Wenn Sie Ihren Test jedes Mal ändern müssen, wenn sich die Implementierung dieses Vertrags ändert, werden Sie eine schreckliche Zeit damit verbringen, sowohl Ihre App-Codebasis als auch die Testcodebasis zu pflegen.
Christopher Perry
@Ronnie Blind Unit Unit Testing ist nicht hilfreich. Es gibt Projekte sehr unterschiedlicher Art, und Unit-Tests sind nicht in allen von ihnen effektiv. Als ein Beispiel, ich bin an einem Projekt arbeiten , in denen 95% des Codes über die Nebenwirkungen (beachten Sie , diese Nebenwirkung lastigen Natur ist durch Anforderung , es ist wesentlich Komplexität, nicht zufällig , da sie Daten aus sammeln eine Vielzahl von Stateful-Quellen und präsentiert es mit sehr wenig Manipulation, so dass es kaum eine reine Logik gibt). Unit-Tests sind hier nicht effektiv, Integrationstests jedoch.
Vicky Chijwani
Nebenwirkungen sollten an die Ränder Ihres Systems gedrückt werden, sie sollten nicht über die Ebenen hinweg miteinander verflochten sein. An den Rändern testen Sie die Nebenwirkungen, die Verhaltensweisen sind. Überall sonst sollten Sie versuchen, reine Funktionen ohne Nebenwirkungen zu haben, die leicht zu testen und leicht zu überlegen, wiederzuverwenden und zu komponieren sind.
Christopher Perry
24

Ich bin zurückhaltend, meinen Code mit Typen und Konzepten zu verschmutzen, die nur existieren, um das Testen von Einheiten zu erleichtern. Sicher, wenn es das Design sauberer und besser macht als großartig, aber ich denke, das ist oft nicht der Fall.

Ich gehe davon aus, dass Ihre Unit-Tests so viel wie möglich bewirken würden, was möglicherweise keine 100% ige Abdeckung darstellt. In der Tat kann es nur 10% sein. Der Punkt ist, Ihre Unit-Tests sollten schnell sein und keine externen Abhängigkeiten haben. Sie können Fälle wie "Diese Methode löst eine ArgumentNullException aus, wenn Sie für diesen Parameter null übergeben" testen.

Ich würde dann Integrationstests hinzufügen (ebenfalls automatisiert und wahrscheinlich mit demselben Unit-Test-Framework), die externe Abhängigkeiten aufweisen können, und End-to-End-Szenarien wie diese testen.

Bei der Messung der Codeabdeckung messe ich sowohl Unit- als auch Integrationstests.

Kent Boogaart
quelle
5
Ja, ich höre dich. Es gibt diese bizarre Welt, die Sie erreichen, in der Sie so viel entkoppelt haben, dass Sie nur noch Methodenaufrufe für abstrakte Objekte haben. Luftiger Flaum. Wenn Sie diesen Punkt erreichen, haben Sie nicht das Gefühl, wirklich etwas Reales zu testen. Sie testen nur die Interaktionen zwischen Klassen.
Judah Gabriel Himango
6
Diese Antwort ist falsch. Unit-Tests sind nicht wie Zuckerguss, sondern eher wie Zucker. Es ist in den Kuchen gebacken. Es ist Teil des Schreibens Ihres Codes ... eine Designaktivität. Daher "verschmutzen" Sie Ihren Code niemals mit etwas, das das Testen "erleichtern" würde, da das Testen Ihnen das Schreiben Ihres Codes erleichtert. In 99% der Fälle
Christopher Perry,
1
@Christopher: Um Ihre Analogie zu erweitern, möchte ich nicht, dass mein Kuchen einer Vanillescheibe ähnelt, nur damit ich Zucker verwenden kann. Ich befürworte nur Pragmatismus.
Kent Boogaart
1
@Christopher: Ihre Biografie sagt alles: "Ich bin ein TDD-Fanatiker". Ich dagegen bin pragmatisch. Ich mache TDD dort, wo es passt und nicht dort, wo es nicht passt - nichts in meiner Antwort deutet darauf hin, dass ich kein TDD mache, obwohl Sie anscheinend glauben, dass es funktioniert. Und ob es sich um TDD handelt oder nicht, ich werde keine großen Komplexitäten einführen, um das Testen zu erleichtern.
Kent Boogaart
3
@ChristopherPerry Können Sie erklären, wie das ursprüngliche Problem des OP dann auf TDD-Weise gelöst werden kann? Ich stoße die ganze Zeit darauf; Ich muss eine Funktion schreiben, deren einziger Zweck darin besteht, eine Aktion mit einer externen Abhängigkeit auszuführen, wie in dieser Frage. Was wäre dieser Test also selbst im Szenario, in dem der Test zuerst geschrieben wird?
Dax Fohl
8

Es ist nichts Falsches daran, das Dateisystem zu treffen. Betrachten Sie es einfach als Integrationstest und nicht als Komponententest. Ich würde den fest codierten Pfad gegen einen relativen Pfad austauschen und einen TestData-Unterordner erstellen, der die Reißverschlüsse für die Komponententests enthält.

Wenn die Ausführung Ihrer Integrationstests zu lange dauert, trennen Sie sie, damit sie nicht so oft ausgeführt werden wie Ihre schnellen Komponententests.

Ich stimme zu, manchmal denke ich, dass interaktionsbasierte Tests zu viel Kopplung verursachen können und oft nicht genug Wert liefern. Sie möchten das Entpacken der Datei hier wirklich testen und nicht nur überprüfen, ob Sie die richtigen Methoden aufrufen.

JC.
quelle
Wie oft sie laufen, ist von geringer Bedeutung. Wir verwenden einen Continuous Integration Server, der sie automatisch für uns ausführt. Es ist uns egal, wie lange sie dauern. Gibt es einen Grund, zwischen Unit- und Integrationstests zu unterscheiden, wenn "wie lange läuft" keine Rolle spielt?
Judah Gabriel Himango
4
Nicht wirklich. Wenn Entwickler jedoch alle Komponententests schnell lokal ausführen möchten, ist es hilfreich, eine einfache Möglichkeit zu haben, dies zu tun.
JC.
6

Eine Möglichkeit wäre, die Entpackungsmethode zu schreiben, um InputStreams aufzunehmen. Dann könnte der Komponententest einen solchen InputStream aus einem Byte-Array unter Verwendung von ByteArrayInputStream erstellen. Der Inhalt dieses Byte-Arrays kann eine Konstante im Unit-Test-Code sein.

nsayer
quelle
Ok, das ermöglicht die Injektion des Streams. Abhängigkeitsinjektion / IOC. Wie wäre es mit dem Teil des Entpackens des Streams in Dateien, des Ladens einer DLL unter diesen Dateien und des Aufrufs einer Methode in dieser DLL?
Judah Gabriel Himango
3

Dies scheint eher ein Integrationstest zu sein, da Sie von einem bestimmten Detail (dem Dateisystem) abhängen, das sich theoretisch ändern könnte.

Ich würde den Code, der sich mit dem Betriebssystem befasst, in ein eigenes Modul (Klasse, Assembly, JAR, was auch immer) abstrahieren. In Ihrem Fall möchten Sie eine bestimmte DLL laden, wenn sie gefunden wird. Erstellen Sie daher eine IDllLoader-Schnittstelle und eine DllLoader-Klasse. Lassen Sie Ihre App die DLL vom DllLoader über die Benutzeroberfläche abrufen und testen Sie, ob Sie nicht für den Entpackungscode verantwortlich sind, oder?

Zapfhahn
quelle
2

Angenommen, "Dateisysteminteraktionen" sind im Framework selbst gut getestet, erstellen Sie Ihre Methode für die Arbeit mit Streams und testen Sie diese. Das Öffnen und Übergeben eines FileStream an die Methode kann in Ihren Tests nicht berücksichtigt werden, da FileStream.Open von den Framework-Erstellern gut getestet wird.

Sunny Milenov
quelle
Sie und nsayer haben im Wesentlichen den gleichen Vorschlag: Lassen Sie meinen Code mit Streams arbeiten. Wie wäre es mit dem Teil über das Entpacken des Stream-Inhalts in DLL-Dateien, das Öffnen dieser DLL und das Aufrufen einer Funktion darin? Was würdest du da tun?
Judah Gabriel Himango
3
@ JudahHimango. Diese Teile müssen nicht unbedingt testbar sein. Sie können nicht alles testen. Abstrahieren Sie die nicht testbaren Komponenten in ihre eigenen Funktionsblöcke und gehen Sie davon aus, dass sie funktionieren. Wenn Sie auf einen Fehler bei der Funktionsweise dieses Blocks stoßen, erstellen Sie einen Test dafür und voila. Unit-Tests bedeuten NICHT, dass Sie alles testen müssen. Eine 100% ige Codeabdeckung ist in einigen Szenarien unrealistisch.
Zoran Pavlovic
1

Sie sollten die Klasseninteraktion und den Funktionsaufruf nicht testen. Stattdessen sollten Sie Integrationstests in Betracht ziehen. Testen Sie das gewünschte Ergebnis und nicht den Ladevorgang.

Dror Helfer
quelle
1

Für Unit-Tests würde ich vorschlagen, dass Sie die Testdatei in Ihr Projekt aufnehmen (EAR-Datei oder gleichwertig) und dann einen relativen Pfad in den Unit-Tests verwenden, dh "../testdata/testfile".

Solange Ihr Projekt korrekt exportiert / importiert wurde, sollte Ihr Komponententest funktionieren.

James Anderson
quelle
0

Wie andere gesagt haben, ist der erste als Integrationstest in Ordnung. Der zweite Test testet nur, was die Funktion tatsächlich tun soll, was alles ist, was ein Komponententest tun sollte.

Wie gezeigt, sieht das zweite Beispiel etwas sinnlos aus, bietet Ihnen jedoch die Möglichkeit zu testen, wie die Funktion auf Fehler in einem der Schritte reagiert. Sie haben im Beispiel keine Fehlerprüfung, aber im realen System, das Sie möglicherweise haben, und die Abhängigkeitsinjektion würde es Ihnen ermöglichen, alle Antworten auf Fehler zu testen. Dann haben sich die Kosten gelohnt.

David Sykes
quelle