Verwenden Sie beim Schreiben von Unit-Tests keine Mocks

Veröffentlicht: 2018-05-02

Hinweis: Dies ist unser neuster technischer Beitrag, der von Chefingenieur Seth Ammons geschrieben wurde. Besonderer Dank geht an Sam Nguyen, Kane Kim, Elmer Thomas und Kevin Gillette für die Peer-Review dieses Beitrags . Weitere Beiträge wie diesen finden Sie in unserer technischen Blogrolle .

Ich schreibe wirklich gerne Tests für meinen Code, insbesondere Unit-Tests. Das Vertrauen, das es mir gibt, ist großartig. Etwas aufzugreifen, an dem ich lange nicht gearbeitet habe, und die Einheiten- und Integrationstests ausführen zu können, gibt mir das Wissen, dass ich bei Bedarf rücksichtslos umgestalten kann, solange meine Tests eine gute und aussagekräftige Abdeckung haben und weiterhin bestehen , habe ich auch danach noch funktionierende Software.

Komponententests leiten das Codedesign und ermöglichen es uns, schnell zu überprüfen, ob Fehlermodi und Logikflüsse wie beabsichtigt funktionieren. Damit möchte ich über etwas schreiben, das vielleicht etwas umstrittener ist: Verwenden Sie beim Schreiben von Komponententests keine Mocks.

Lassen Sie uns einige Definitionen auf den Tisch legen

Was ist der Unterschied zwischen Unit- und Integrationstests? Was meine ich mit Mocks und was sollten Sie stattdessen verwenden? Dieser Beitrag konzentriert sich auf die Arbeit in Go, und daher steht meine Neigung zu diesen Wörtern im Kontext von Go.

Wenn ich Unit-Tests sage, beziehe ich mich auf Tests, die eine ordnungsgemäße Fehlerbehandlung sicherstellen und das Design des Systems leiten, indem kleine Codeeinheiten getestet werden. Mit Einheit können wir uns auf ein ganzes Paket, eine Schnittstelle oder eine einzelne Methode beziehen.

Beim Integrationstest interagieren Sie tatsächlich mit abhängigen Systemen und/oder Bibliotheken. Wenn ich „Mocks“ sage, beziehe ich mich speziell auf den Begriff „Mock Object“, bei dem wir „Domänencode durch Dummy-Implementierungen ersetzen, die sowohl echte Funktionalität emulieren als auch Behauptungen über das Verhalten unseres Codes erzwingen [1]“ (Hervorhebung Bergwerk).

Etwas kürzer ausgedrückt: Mocks behaupten Verhalten, wie:

MyMock.Method("foo").Called(1).WithArgs("bar").Returns("raz")

Ich plädiere für „Fakes statt Mocks“.

Ein Fake ist eine Art Testdouble, das Geschäftsverhalten enthalten kann [2]. Fälschungen sind lediglich Strukturen, die zu einer Schnittstelle passen und eine Form der Abhängigkeitsinjektion darstellen, bei der wir das Verhalten steuern. Der Hauptvorteil von Fälschungen besteht darin, dass sie die Kopplung im Code verringern, während Mocks die Kopplung erhöhen und die Kopplung das Refactoring schwieriger macht [3].

In diesem Beitrag möchte ich zeigen, dass Fälschungen Flexibilität bieten und ein einfaches Testen und Refactoring ermöglichen. Sie reduzieren Abhängigkeiten im Vergleich zu Mocks und sind einfach zu warten.

Lassen Sie uns mit einem Beispiel eintauchen, das etwas fortgeschrittener ist als das „Testen einer Summenfunktion“, wie Sie es vielleicht in einem typischen Beitrag dieser Art sehen. Ich muss Ihnen jedoch etwas Kontext geben, damit Sie den Code, der in diesem Beitrag folgt, leichter verstehen können.

Bei SendGrid hatte eines unserer Systeme traditionell Dateien im lokalen Dateisystem, aber aufgrund der Notwendigkeit einer höheren Verfügbarkeit und eines besseren Durchsatzes verschieben wir diese Dateien auf S3.

Wir haben eine Anwendung, die in der Lage sein muss, diese Dateien zu lesen, und wir haben uns für eine Anwendung entschieden, die je nach Konfiguration in zwei Modi „lokal“ oder „entfernt“ ausgeführt werden kann. Eine Einschränkung, die in vielen Codebeispielen ausgelassen wird, ist, dass wir im Falle eines Remotefehlers darauf zurückgreifen, die Datei lokal zu lesen.

Damit das aus dem Weg geräumt ist, hat diese Anwendung einen Paket-Getter. Wir müssen sicherstellen, dass der Paket-Getter Dateien entweder vom entfernten Dateisystem oder vom lokalen Dateisystem abrufen kann.

Naiver Ansatz: Rufen Sie einfach Aufrufe auf Bibliotheks- und Systemebene auf

Der naive Ansatz besteht darin, dass unser Implementierungspaket getter.New(...) aufruft und ihm die Informationen übergibt, die zum Einrichten des Remote- oder lokalen Abrufens von Dateien erforderlich sind, und einen Getter zurückgibt . Der zurückgegebene Wert kann dann MyGetter.GetFile(...) mit den Parametern aufrufen, die zum Auffinden der entfernten oder lokalen Datei erforderlich sind.

Dies gibt uns unsere Grundstruktur. Wenn wir den neuen Getter erstellen, initialisieren wir Parameter, die für jeden potenziellen Remote-Dateiabruf benötigt werden (einen Zugriffsschlüssel und ein Geheimnis), und wir übergeben auch einige Werte, die aus unserer Anwendungskonfiguration stammen, wie z. B. useRemoteFS , die den Code anweisen, es zu versuchen das entfernte Dateisystem.

Wir müssen einige grundlegende Funktionen bereitstellen. Schauen Sie sich den naiven Code hier an [4]; Unten ist eine reduzierte Version. Beachten Sie, dass dies ein nicht fertiges Beispiel ist und wir Dinge umgestalten werden.

Die Grundidee hier ist, dass wir, wenn wir zum Lesen aus dem Remote-Dateisystem konfiguriert sind und Details zum Remote-Dateisystem (Host, Bucket und Schlüssel) erhalten, versuchen sollten, aus dem Remote-Dateisystem zu lesen. Nachdem wir uns darauf verlassen haben, dass das System remote liest, verschieben wir das gesamte Lesen von Dateien in das Remote-Dateisystem und entfernen Verweise auf das Lesen aus dem lokalen Dateisystem.

Dieser Code ist nicht sehr komponententestfreundlich; Beachten Sie, dass wir, um zu überprüfen, wie es funktioniert, nicht nur das lokale Dateisystem, sondern auch das Remote-Dateisystem treffen müssen. Jetzt könnten wir einfach einen Integrationstest durchführen und etwas Docker-Magie einrichten, um eine s3-Instanz zu haben, mit der wir den glücklichen Pfad im Code überprüfen können.

Nur Integrationstests zu haben, ist jedoch alles andere als ideal, da Unit-Tests uns helfen, robustere Software zu entwickeln, indem sie einfach alternativen Code und Fehlerpfade testen. Wir sollten Integrationstests für größere „Funktioniert es wirklich“-Tests aufsparen. Konzentrieren wir uns zunächst auf die Komponententests.

Wie können wir diesen Code einheitentestbarer machen? Es gibt zwei Denkschulen. Eine besteht darin, einen Mock-Generator (wie https://github.com/vektra/mocky oder https://github.com/golang/mock) zu verwenden, der Boilerplate-Code zum Testen von Mocks erstellt.

Sie könnten diesen Weg gehen und die Dateisystemaufrufe und die Minio-Clientaufrufe generieren. Oder vielleicht möchten Sie eine Abhängigkeit vermeiden, also generieren Sie Ihre Mocks von Hand. Es stellt sich heraus, dass das Verspotten des Minio-Clients nicht einfach ist, da Sie einen konkret typisierten Client haben, der ein konkret typisiertes Objekt zurückgibt.

Ich sage, dass es einen besseren Weg gibt, als zu spotten. Wenn wir unseren Code so umstrukturieren, dass er besser testbar ist, benötigen wir keine zusätzlichen Importe für Mocks und verwandten Cruft, und es besteht keine Notwendigkeit, zusätzliche Test-DSLs zu kennen, um die Schnittstellen zuverlässig zu testen. Wir können unseren Code so einrichten, dass er nicht übermäßig gekoppelt ist, und der Testcode wird einfach normaler Go-Code sein, der die Schnittstellen von Go verwendet. Machen wir das!

Schnittstellenansatz: Größere Abstraktion, einfacheres Testen

Was müssen wir testen? Hier machen einige neue Gophers etwas falsch. Ich habe gesehen, dass Leute den Wert der Nutzung von Schnittstellen verstehen, aber das Gefühl haben, dass sie Schnittstellen brauchen, die der konkreten Implementierung des Pakets entsprechen, das sie verwenden.

Sie könnten sehen, dass wir einen Minio-Client haben, also könnten sie damit beginnen, Schnittstellen zu erstellen, die ALLEN Methoden und Verwendungen des Minio-Clients (oder eines anderen s3-Clients) entsprechen. Sie vergessen das Go-Sprichwort [5][6] „Je größer die Schnittstelle, desto schwächer die Abstraktion“.

Wir müssen nicht gegen den Minio-Client testen. Wir müssen testen, ob wir Dateien remote oder lokal abrufen können (und einige Fehlerpfade überprüfen, z. B. Remotefehler). Lassen Sie uns diesen anfänglichen Ansatz umgestalten und den Minio-Client in einen Remote-Getter ziehen. Während wir das tun, machen wir dasselbe mit unserem Code zum Lesen lokaler Dateien und erstellen einen lokalen Getter. Hier sind die grundlegenden Schnittstellen, und wir haben Typ, um jede zu implementieren:

Mit diesen Abstraktionen können wir unsere anfängliche Implementierung umgestalten. Wir werden localFetcher und remoteFetcher in die Getter -Struktur einfügen und GetFile umgestalten, um sie zu verwenden. Schauen Sie sich die Vollversion des umgestalteten Codes hier [7] an. Unten ist ein leicht vereinfachter Ausschnitt, der die neue Schnittstellenversion verwendet:

Dieser neue, umgestaltete Code ist viel besser testbar, da wir Schnittstellen als Parameter für die Getter -Struktur verwenden und die konkreten Typen für Fälschungen austauschen können. Anstatt Betriebssystemaufrufe zu verspotten oder den Minio-Client oder große Schnittstellen vollständig zu verspotten, brauchen wir nur zwei einfache Fälschungen: fakeLocalFetcher und fakeRemoteFetcher .

Diese Fälschungen haben einige Eigenschaften, mit denen wir angeben können, was sie zurückgeben. Wir können die Dateidaten oder beliebige Fehler zurückgeben, und wir können überprüfen, ob die aufrufende GetFile- Methode die Daten und Fehler wie beabsichtigt behandelt.

Vor diesem Hintergrund wird das Herzstück der Tests:

Mit dieser Grundstruktur können wir alles in tabellengesteuerten Tests zusammenfassen [8]. Jeder Fall in der Testtabelle wird entweder auf lokalen oder Remote-Dateizugriff getestet. Wir können einen Fehler entweder beim Remote- oder lokalen Dateizugriff einschleusen. Wir können propagierte Fehler überprüfen, dass der Dateiinhalt weitergegeben wird und dass erwartete Protokolleinträge vorhanden sind.

Ich ging weiter und nahm alle potenziellen Testfälle und Permutationen in den hier verfügbaren tabellengesteuerten Test auf [9] (Sie werden vielleicht bemerken, dass einige Methodensignaturen etwas anders sind – es erlaubt uns, Dinge wie das Einfügen eines Loggers und Assertion gegen Log-Anweisungen zu tun ).

Geil, oder? Wir haben die volle Kontrolle darüber, wie sich GetFile verhalten soll, und wir können uns gegen die Ergebnisse behaupten. Wir haben unseren Code so gestaltet, dass er für Unit-Tests geeignet ist, und können jetzt Erfolgs- und Fehlerpfade überprüfen, die in der GetFile- Methode implementiert sind.

Der Code ist lose gekoppelt und das Refactoring in der Zukunft sollte ein Kinderspiel sein. Dazu haben wir einfachen, alten Go-Code geschrieben, den jeder Entwickler, der mit Go vertraut ist, verstehen und bei Bedarf erweitern können sollte.

Mocks: Was ist mit kleinen, grobkörnigen Implementierungsdetails?

Was würden uns die Mocks kaufen, das wir in der vorgeschlagenen Lösung nicht bekommen? Eine gute Frage, die einen Vorteil gegenüber einem traditionellen Mock darstellt, könnte lauten: „Woher wissen Sie, dass Sie den s3-Client mit den richtigen Parametern aufgerufen haben? Mit Mocks kann ich sicherstellen, dass ich den Schlüsselwert an den Schlüsselparameter und nicht den Bucket-Parameter übergeben habe.“

Dies ist ein berechtigtes Anliegen und sollte irgendwo in einem Test behandelt werden. Der Testansatz, den ich hier befürworte, überprüft nicht, ob Sie den Minio-Client mit dem Bucket und den Schlüsselparametern in der richtigen Reihenfolge aufgerufen haben.

Ein großartiges Zitat, das ich kürzlich gelesen habe, lautete: „Mocking führt Annahmen ein, was Risiken einführt [10]“. Sie gehen davon aus, dass die Client-Bibliothek richtig implementiert ist, Sie gehen davon aus, dass alle Grenzen solide sind, Sie gehen davon aus, dass Sie wissen, wie sich die Bibliothek tatsächlich verhält.

Das Verspotten der Bibliothek verspottet nur Annahmen und macht Ihre Tests spröder und anfälliger für Änderungen, wenn Sie den Code aktualisieren (was Martin Fowler in Mocks Aren't Stubs [3] zu dem Schluss kam). Wenn der Gummi auf die Straße trifft, müssen wir überprüfen, ob wir den Minio-Client tatsächlich korrekt verwenden, und das bedeutet Integrationstests (diese können in einem Docker-Setup oder einer Testumgebung vorhanden sein). Da wir sowohl Komponenten- als auch Integrationstests haben, ist kein Komponententest erforderlich, um die genaue Implementierung abzudecken, da der Integrationstest dies abdecken wird.

In unserem Beispiel leiten Einheitentests unser Codedesign und ermöglichen es uns, schnell zu testen, ob Fehler und logische Abläufe wie vorgesehen funktionieren und genau das tun, was sie tun müssen.
Einige sind der Meinung, dass dies nicht genug Unit-Test-Abdeckung ist. Sie sind besorgt über die oben genannten Punkte. Einige werden auf Schnittstellen im russischen Puppenstil bestehen, bei denen eine Schnittstelle eine andere Schnittstelle zurückgibt, die eine andere Schnittstelle zurückgibt, vielleicht wie die folgende:

Und dann könnten sie jeden Teil des Minio-Clients in jeden Wrapper ziehen und dann einen Scheingenerator verwenden (Abhängigkeiten zu Builds und Tests hinzufügen, Annahmen erhöhen und die Dinge spröder machen). Am Ende wird der Mockist in der Lage sein, so etwas zu sagen wie:

myClientMock.ExpectsCall("GetObject").Returns(mockObject).NumberOfCalls(1).WithArgs(key, Bucket) – und das ist, wenn Sie sich an die richtige Beschwörung für diese spezielle DSL erinnern können.

Dies wäre eine Menge zusätzlicher Abstraktion, die direkt mit der Implementierungsentscheidung für die Verwendung des Minio-Clients verbunden wäre. Dies führt zu spröden Tests, wenn wir herausfinden, dass wir unsere Annahmen über den Kunden ändern müssen oder einen völlig anderen Kunden benötigen.

Dies erhöht die End-to-End-Codeentwicklungszeit jetzt und in Zukunft, erhöht die Codekomplexität und verringert die Lesbarkeit, erhöht möglicherweise die Abhängigkeiten von Mock-Generatoren und gibt uns den zweifelhaften zusätzlichen Wert zu wissen, ob wir den Bucket und die Schlüsselparameter verwechselt haben die wir sowieso beim Integrationstest entdeckt hätten.

Je mehr Objekte eingeführt werden, desto enger wird die Kopplung. Wir haben vielleicht einen Logger-Mock gemacht und später beginnen wir mit einem Metrik-Mock. Bevor Sie es wissen, fügen Sie einen Protokolleintrag oder eine neue Metrik hinzu und Sie haben gerade zig Tests abgebrochen, die nicht erwartet haben, dass eine zusätzliche Metrik durchkommt.

Das letzte Mal, als ich davon in Go gebissen wurde, sagte mir das spöttische Framework nicht einmal, welcher Test oder welche Datei fehlgeschlagen war, da es in Panik geriet und einen schrecklichen Tod starb, weil es auf eine neue Metrik stieß (dies erforderte eine binäre Suche der Tests durch Kommentieren). um herauszufinden, wo wir das Scheinverhalten ändern mussten). Können Mocks Mehrwert schaffen? Sicher. Ist es die Kosten wert? In den meisten Fällen bin ich nicht überzeugt.

Schnittstellen: Einfachheit und Komponententests für den Sieg

Wir haben gezeigt, dass wir durch die einfache Verwendung von Schnittstellen in Go das Design leiten und sicherstellen können, dass der richtige Code und die richtigen Fehlerpfade befolgt werden. Indem wir einfache Fälschungen schreiben, die sich an die Schnittstellen halten, können wir sehen, dass wir keine Mocks, Mock-Frameworks oder Mock-Generatoren benötigen, um Code zu erstellen, der zum Testen entwickelt wurde. Wir haben auch festgestellt, dass Komponententests nicht alles sind und Sie Integrationstests schreiben müssen, um sicherzustellen, dass die Systeme ordnungsgemäß miteinander integriert sind.

Ich hoffe, dass ich in Zukunft einen Beitrag über einige nette Methoden zum Ausführen von Integrationstests erhalten werde. Bleib dran!

Verweise

1: Endo-Testing: Unit Testing with Mock Objects (2000): Siehe Einführung für die Definition von Mock-Objekten
2: Der kleine Spötter: Siehe den Teil über Fälschungen, insbesondere „Eine Fälschung hat Geschäftsgebaren. Sie können eine Fälschung dazu bringen, sich anders zu verhalten, indem Sie ihr unterschiedliche Daten geben.“
3: Mocks sind keine Stubs: Siehe den Abschnitt „Soll ich also ein Klassiker oder ein Mockist sein?“ Martin Fowler erklärt: „Ich sehe keine überzeugenden Vorteile für Schein-TDD und mache mir Sorgen über die Folgen der Kopplung von Tests an die Implementierung.“
4: Naiver Ansatz: eine vereinfachte Version des Codes. Siehe [7].
5: https://go-proverbs.github.io/: Die Liste der Go-Sprichwörter mit Links zu Vorträgen.
6: https://www.youtube.com/watch?v=PAAkCSZUG1c&t=5m17s: Direkter Link zum Vortrag von Rob Pike in Bezug auf Schnittstellengröße und Abstraktion.
7: Vollversion des Democodes: Sie können das Repo klonen und „go test“ ausführen.
8: Tabellengesteuerte Tests: Eine Teststrategie zum Organisieren von Testcode zum Reduzieren von Duplizierung.
9: Tests für die Vollversion des Democodes. Sie können sie mit `go test` ausführen.
10: Fragen, die Sie sich beim Schreiben von Tests stellen sollten von Michal Charemza: Verspotten führt zu Annahmen, und Annahmen führen zu Risiken.