Wo liegt die Grenze zwischen Unit-Testing-Anwendungslogik und misstrauischen Sprachkonstrukten?

87

Betrachten Sie eine Funktion wie diese:

function savePeople(dataStore, people) {
    people.forEach(person => dataStore.savePerson(person));
}

Es könnte so verwendet werden:

myDataStore = new Store('some connection string', 'password');
myPeople = ['Joe', 'Maggie', 'John'];
savePeople(myDataStore, myPeople);

Nehmen wir an, dass dies Storeeigene Komponententests hat oder vom Hersteller bereitgestellt wird. Auf jeden Fall vertrauen wir Store. Und nehmen wir weiter an, dass die Fehlerbehandlung - z. B. von Fehlern bei der Trennung der Datenbank - nicht in der Verantwortung von liegt savePeople. Nehmen wir in der Tat an, dass der Speicher selbst eine magische Datenbank ist, die in keiner Weise fehlerfrei sein kann. Angesichts dieser Annahmen lautet die Frage:

Sollte savePeople()ein Unit-Test durchgeführt werden, oder würden solche Tests dem Testen des eingebauten forEachSprachkonstrukts gleichkommen?

Wir könnten natürlich einen Schein abgeben dataStoreund behaupten, der dataStore.savePerson()für jede Person einmal aufgerufen wird. Sie könnten durchaus das Argument vorbringen, dass ein solcher Test Sicherheit gegen Implementierungsänderungen bietet: Wenn wir uns beispielsweise dafür entscheiden, ihn durch forEacheine herkömmliche forSchleife oder eine andere Methode der Iteration zu ersetzen . Der Test ist also nicht ganz trivial. Und doch scheint es furchtbar nahe ...


Hier ist ein weiteres Beispiel, das möglicherweise fruchtbarer ist. Stellen Sie sich eine Funktion vor, die nur andere Objekte oder Funktionen koordiniert. Zum Beispiel:

function bakeCookies(dough, pan, oven) {
    panWithRawCookies = pan.add(dough);
    oven.addPan(panWithRawCookies);
    oven.bakeCookies();
    oven.removePan();
}

Wie sollte eine Funktion wie diese Unit getestet werden, vorausgesetzt, Sie glauben, dass dies der Fall ist? Es ist schwer für mich , jede Art von Gerät zu testen , sich vorzustellen , die einfach nicht spotten dough, panund oven, und dann behaupten , dass Methoden auf sie genannt werden. Ein solcher Test ist jedoch nichts anderes, als die exakte Implementierung der Funktion zu duplizieren.

Zeigt diese Unfähigkeit, die Funktion in einer aussagekräftigen Black-Box-Weise zu testen, einen Konstruktionsfehler bei der Funktion selbst an? Wenn ja, wie könnte es verbessert werden?


Um die Frage, die das bakeCookiesBeispiel motiviert , noch klarer zu gestalten, füge ich ein realistischeres Szenario hinzu, auf das ich beim Versuch gestoßen bin, dem Legacy-Code Tests hinzuzufügen und ihn umzugestalten.

Wenn ein Benutzer ein neues Konto erstellt, müssen einige Dinge geschehen: 1) Ein neuer Benutzerdatensatz muss in der Datenbank erstellt werden. 2) Eine Begrüßungs-E-Mail muss gesendet werden. 3) Die IP-Adresse des Benutzers muss für Betrugsfälle aufgezeichnet werden Zwecke.

Deshalb möchten wir eine Methode erstellen, die alle Schritte "Neuer Benutzer" miteinander verbindet:

function createNewUser(validatedUserData, emailService, dataStore) {
  userId = dataStore.insertUserRecord(validateduserData);
  emailService.sendWelcomeEmail(validatedUserData);
  dataStore.recordIpAddress(userId, validatedUserData.ip);
}

Beachten Sie, dass, wenn eine dieser Methoden einen Fehler auslöst, der Fehler in den aufrufenden Code aufsteigen soll, damit er den Fehler nach Belieben behandeln kann. Wenn es vom API-Code aufgerufen wird, übersetzt es den Fehler möglicherweise in einen entsprechenden http-Antwortcode. Wenn es von einer Webschnittstelle aufgerufen wird, übersetzt es den Fehler möglicherweise in eine entsprechende Meldung, die dem Benutzer angezeigt wird, und so weiter. Der Punkt ist, dass diese Funktion nicht weiß, wie sie mit den Fehlern umgehen soll, die möglicherweise ausgelöst werden.

Das Wesen meiner Verwirrung ist, dass es zum Testen einer solchen Funktion in einer Einheit notwendig erscheint, die genaue Implementierung im Test selbst zu wiederholen (indem angegeben wird, dass Methoden in einer bestimmten Reihenfolge auf Mocks angewendet werden), und dass dies falsch erscheint.

Jona
quelle
44
Nachdem es läuft. Haben Sie Cookies
Ewan
6
zu deinem Update: Warum willst du jemals eine Pfanne verspotten? oder teig? Diese klingen wie einfache speicherinterne Objekte, deren Erstellung trivial sein sollte. Es gibt also keinen Grund, sie nicht als eine Einheit zu testen. Denken Sie daran, dass "Unit" in "Unit Testing" nicht "eine einzelne Klasse" bedeutet. es bedeutet "die kleinstmögliche Codeeinheit, die verwendet wird, um etwas zu erledigen". Eine Pfanne ist wahrscheinlich nichts anderes als ein Behälter für Teiggegenstände. Es wäre daher zweckmäßig, sie einzeln zu testen, anstatt nur die bakeCookies-Methode von außen zu testen.
sara
11
Letztendlich gilt hier das Grundprinzip, dass Sie genügend Tests schreiben, um sich davon zu überzeugen, dass der Code funktioniert, und dass es ein angemessener "Kanarienvogel in der Kohlenmine" ist, wenn jemand etwas ändert. Das ist es. Es gibt keine magischen Beschwörungsformeln, formelhaften Annahmen oder dogmatischen Behauptungen, weshalb eine Codeabdeckung von 85% bis 90% (nicht 100%) allgemein als ausgezeichnet angesehen wird.
Robert Harvey
5
@RobertHarvey Unglücklicherweise helfen formelhafte Plattitüden und TDD-Soundbits nicht dabei, echte Probleme zu lösen, auch wenn sie Ihnen sicherlich begeisterte Zustimmungspunkte einbringen. Dafür müssen Sie sich die Hände schmutzig machen und riskieren, eine tatsächliche Frage zu beantworten
Jonah
4
Einheitentest in der Reihenfolge abnehmender zyklomatischer Komplexität. Vertrauen Sie mir, Sie werden keine Zeit mehr haben, bevor Sie diese Funktion nutzen können
Neil McGuigan,

Antworten:

118

Sollte savePeople()das Gerät getestet werden? Ja. Sie testen nicht, ob das dataStore.savePersonfunktioniert oder ob die Datenbankverbindung funktioniert oder ob es überhaupt foreachfunktioniert. Sie testen, dass savePeopledas Versprechen, das es durch seinen Vertrag macht , erfüllt wird.

Stellen Sie sich dieses Szenario vor: Jemand überarbeitet die Codebasis grundlegend und entfernt versehentlich den forEachTeil der Implementierung, sodass immer nur das erste Element gespeichert wird. Möchten Sie nicht, dass ein Komponententest dies erfasst?

Bryan Oakley
quelle
20
@RobertHarvey: Es gibt viele Grauzonen, und die Unterscheidung, IMO, ist nicht wichtig. Sie haben jedoch Recht - es ist nicht wirklich wichtig zu testen, ob "es die richtigen Funktionen aufruft", sondern "es tut das Richtige", unabhängig davon, wie es funktioniert. Was wichtig ist, ist zu testen, dass Sie bei einem bestimmten Satz von Eingaben für die Funktion einen bestimmten Satz von Ausgaben erhalten. Ich kann jedoch erkennen, wie verwirrend dieser letzte Satz sein kann, und habe ihn daher entfernt.
Bryan Oakley
64
"Sie testen, dass savePeople das Versprechen erfüllt, das es durch seinen Vertrag gibt." Diese. Soviel dazu.
Lovis
2
Es sei denn, Sie haben einen "End-to-End" -Systemtest, der dies abdeckt.
Ian
6
@Ian End-to-End-Tests ersetzen keine Komponententests, sondern sind kostenlos. Nur weil Sie möglicherweise einen End-to-End-Test haben, der sicherstellt, dass Sie eine Liste von Personen speichern, bedeutet dies nicht, dass Sie keinen Komponententest haben sollten, um dies auch zu behandeln.
Vincent Savard
4
@VincentSavard, aber die Kosten / Nutzen eines Unit-Tests verringern sich, wenn das Risiko auf andere Weise kontrolliert wird.
Ian
36

Normalerweise taucht diese Art von Frage auf, wenn Leute "Test-After" -Entwicklungen durchführen. Gehen Sie dieses Problem aus Sicht von TDD an, bei dem Tests vor der Implementierung durchgeführt werden, und stellen Sie sich diese Frage erneut als Übung.

Zumindest in meiner Anwendung von TDD, die normalerweise von außen nach innen ausgeführt wird, würde ich keine Funktion wie savePeoplenach der Implementierung implementieren savePerson. Die Funktionen savePeopleund savePersonwürden als eine Funktion beginnen und aus denselben Einheitentests heraus getestet werden. Die Trennung zwischen den beiden würde sich nach einigen Tests im Refactoring-Schritt ergeben. Diese Arbeitsweise würde auch die Frage aufwerfen, wo sich die Funktion befinden savePeoplesollte - ob es sich um eine freie Funktion oder einen Teil der Funktion handelt dataStore.

Am Ende würden die Tests nicht nur überprüfen , ob Sie richtig eine speichern kann Personin dem Store, aber auch viele Personen. Dies würde mich auch zu der Frage führen, ob andere Überprüfungen erforderlich sind, zum Beispiel: "Muss ich sicherstellen, dass die savePeopleFunktion atomar ist und entweder alle oder keine speichert?" Wie würden diese Fehler aussehen? "und so weiter. All dies ist viel mehr als nur die Überprüfung auf die Verwendung einer forEachoder anderer Formen der Iteration.

Wenn die Anforderung, mehr als eine Person auf einmal zu speichern, erst erfüllt wurde, nachdem sie erfüllt savePersonwurde, aktualisiere ich die vorhandenen Tests von savePerson, um die neue Funktion auszuführen savePeople, und stelle sicher, dass weiterhin eine Person gespeichert werden kann, indem zunächst einfach delegiert wird. Testen Sie dann das Verhalten für mehr als eine Person durch neue Tests und überlegen Sie, ob es notwendig wäre, das Verhalten atomar zu machen oder nicht.

MichelHenrich
quelle
4
Testen Sie grundsätzlich die Schnittstelle, nicht die Implementierung.
Snoop
8
Faire und aufschlussreiche Punkte. Trotzdem habe ich das Gefühl, dass meine eigentliche Frage ausgeblendet ist :) Ihre Antwort lautet: "In der realen Welt, in einem gut gestalteten System, glaube ich nicht, dass es diese vereinfachte Version Ihres Problems geben würde." Wieder fair, aber ich habe diese vereinfachte Version speziell erstellt, um die Essenz eines allgemeineren Problems hervorzuheben. Wenn Sie die künstliche Natur des Beispiels nicht überwinden können, können Sie sich vielleicht ein anderes Beispiel vorstellen, in dem Sie einen guten Grund für eine ähnliche Funktion hatten, die nur Iteration und Delegation ausführte. Oder denkst du vielleicht, dass es einfach unmöglich ist?
Jonah
@Jonah aktualisiert. Ich hoffe es beantwortet deine Frage ein bisschen besser. Dies ist alles sehr meinungsbasiert und kann gegen das Ziel dieser Website verstoßen, aber es ist sicherlich eine sehr interessante Diskussion. Übrigens habe ich versucht, aus Sicht der professionellen Arbeit zu antworten, wo wir uns bemühen müssen, Unit-Tests für das gesamte Anwendungsverhalten zu hinterlassen, unabhängig davon, wie trivial die Implementierung sein mag, weil wir die Pflicht haben, ein gut getestetes und zu erstellen dokumentiertes System für neue Betreuer, wenn wir gehen. Für persönliche oder zum Beispiel unkritische (Geld ist auch kritisch) Projekte bin ich einer ganz anderen Meinung.
MichelHenrich
Danke für das Update. Wie genau würdest du testen savePeople? Wie ich im letzten Absatz von OP oder auf andere Weise beschrieben habe?
Jonah
1
Tut mir leid, ich habe mich mit dem Teil "keine Mocks beteiligt" nicht klar gemacht. Ich meinte, ich würde kein Mock für die von savePersonIhnen vorgeschlagene Funktion verwenden, sondern sie durch allgemeinere testen savePeople. Die Unit-Tests für Storewerden so geändert, dass sie savePeoplenicht direkt aufgerufen savePerson, sondern durchlaufen werden. Daher werden keine Mocks verwendet. Die Datenbank sollte natürlich nicht vorhanden sein, da wir Codierungsprobleme von den verschiedenen Integrationsproblemen, die bei tatsächlichen Datenbanken auftreten, isolieren möchten.
MichelHenrich
21

Soll savePeople () Unit-getestet werden?

Ja, das sollte es. Versuchen Sie jedoch, Ihre Testbedingungen unabhängig von der Implementierung zu schreiben. Verwandeln Sie beispielsweise Ihr Verwendungsbeispiel in einen Komponententest:

function testSavePeople() {
    myDataStore = new Store('some connection string', 'password');
    myPeople = ['Joe', 'Maggie', 'John'];
    savePeople(myDataStore, myPeople);
    assert(myDataStore.containsPerson('Joe'));
    assert(myDataStore.containsPerson('Maggie'));
    assert(myDataStore.containsPerson('John'));
}

Dieser Test macht mehrere Dinge:

  • es überprüft den Vertrag der Funktion savePeople()
  • Es kümmert sich nicht um die Implementierung von savePeople()
  • es dokumentiert die beispielhafte Verwendung von savePeople()

Beachten Sie, dass Sie den Datenspeicher weiterhin verspotten / stubben / fälschen können. In diesem Fall würde ich nicht nach expliziten Funktionsaufrufen suchen, sondern nach dem Ergebnis der Operation. Auf diese Weise ist mein Test auf zukünftige Änderungen / Refaktoren vorbereitet.

Beispielsweise könnte Ihre Datenspeicherimplementierung saveBulkPerson()in Zukunft eine Methode bereitstellen - jetzt würde eine Änderung der zu verwendenden Implementierung savePeople()den saveBulkPerson()Komponententest nicht abbrechen, solange dies saveBulkPerson()wie erwartet funktioniert. Und wenn saveBulkPerson()irgendwie nicht funktioniert wie erwartet, Ihr Gerät zu testen wird , dass fangen.

oder würde ein solcher Test das eingebaute forEach-Sprachkonstrukt testen?

Versuchen Sie, wie gesagt, die erwarteten Ergebnisse und die Funktionsschnittstelle zu testen, nicht die Implementierung (es sei denn, Sie führen Integrationstests durch - dann kann das Abfangen bestimmter Funktionsaufrufe hilfreich sein). Wenn es mehrere Möglichkeiten gibt, eine Funktion zu implementieren, sollten alle mit Ihrem Komponententest funktionieren.

In Bezug auf Ihre Aktualisierung der Frage:

Test auf Zustandsänderungen! ZB wird ein Teil des Teigs verwendet. Stellen Sie gemäß Ihrer Implementierung sicher, dass die verwendete Menge doughin die Menge passt, panoder dass die Menge doughaufgebraucht ist. Stellen Sie pannach dem Funktionsaufruf sicher, dass das Cookies enthält. Stellen Sie sicher, dass das ovenleer ist / sich im selben Zustand wie zuvor befindet.

Überprüfen Sie für zusätzliche Tests die Flankenfälle: Was passiert, wenn die ovenvor dem Anruf nicht leer ist? Was passiert, wenn nicht genug da ist dough? Ist der panschon voll?

Sie sollten in der Lage sein, alle erforderlichen Daten für diese Tests aus den Objekten Teig, Pfanne und Ofen selbst abzuleiten. Die Funktionsaufrufe müssen nicht erfasst werden. Behandeln Sie die Funktion so, als stünde Ihnen ihre Implementierung nicht zur Verfügung!

Tatsächlich schreiben die meisten TDD-Benutzer ihre Tests, bevor sie die Funktion schreiben, sodass sie nicht von der tatsächlichen Implementierung abhängig sind.


Für Ihre neueste Ergänzung:

Wenn ein Benutzer ein neues Konto erstellt, müssen einige Dinge geschehen: 1) Ein neuer Benutzerdatensatz muss in der Datenbank erstellt werden. 2) Eine Begrüßungs-E-Mail muss gesendet werden. 3) Die IP-Adresse des Benutzers muss für Betrugsfälle aufgezeichnet werden Zwecke.

Deshalb möchten wir eine Methode erstellen, die alle Schritte "Neuer Benutzer" miteinander verbindet:

function createNewUser(validatedUserData, emailService, dataStore) {
    userId = dataStore.insertUserRecord(validateduserData);
    emailService.sendWelcomeEmail(validatedUserData);
    dataStore.recordIpAddress(userId, validatedUserData.ip);
}

Für eine Funktion wie diese würde ich die Parameter dataStoreund verspotten / stub / fake (was auch immer allgemeiner erscheint) emailService. Diese Funktion führt keine Zustandsübergänge für einen Parameter aus, sondern delegiert sie an Methoden einiger von ihnen. Ich würde versuchen, zu überprüfen, dass der Aufruf der Funktion 4 Dinge tat:

  • Es wurde ein Benutzer in den Datenspeicher eingefügt
  • Es hat eine Begrüßungs-E-Mail gesendet (oder zumindest die entsprechende Methode aufgerufen)
  • Es zeichnete die IP des Benutzers im Datenspeicher auf
  • Es hat alle aufgetretenen Ausnahmen / Fehler delegiert (falls vorhanden).

Die ersten drei Prüfungen können mit Schein, Stich oder Fälschung von dataStoreund durchgeführt werden emailService(Sie möchten beim Testen wirklich keine E-Mails senden). Da ich dies für einige der Kommentare nachschlagen musste, sind dies die Unterschiede:

  • Eine Fälschung ist ein Objekt, das sich wie das Original verhält und bis zu einem gewissen Grad nicht zu unterscheiden ist. Sein Code kann normalerweise über Tests hinweg wiederverwendet werden. Dies kann beispielsweise eine einfache speicherinterne Datenbank für einen Datenbank-Wrapper sein.
  • Ein Stub implementiert nur so viel wie nötig, um die für diesen Test erforderlichen Operationen auszuführen. In den meisten Fällen ist ein Stub spezifisch für einen Test oder eine Testgruppe, für die nur ein kleiner Satz der Methoden des Originals erforderlich ist. In diesem Beispiel könnte es sich um eine handeln dataStore, die nur eine geeignete Version von insertUserRecord()und implementiert recordIpAddress().
  • Ein Mock ist ein Objekt, mit dem Sie überprüfen können, wie es verwendet wird (in den meisten Fällen können Sie Aufrufe seiner Methoden auswerten). Ich würde versuchen, sie in Komponententests sparsam zu verwenden, da Sie mit ihnen tatsächlich versuchen, die Funktionsimplementierung und nicht die Einhaltung der Schnittstelle zu testen, aber sie haben immer noch ihre Verwendung. Es gibt viele Modell-Frameworks, mit denen Sie genau das Modell erstellen können, das Sie benötigen.

Beachten Sie, dass, wenn eine dieser Methoden einen Fehler auslöst, der Fehler in den aufrufenden Code aufsteigen soll, damit er den Fehler nach Belieben behandeln kann. Wenn er vom API-Code aufgerufen wird, übersetzt er den Fehler möglicherweise in einen geeigneten HTTP-Antwortcode. Wenn es von einer Webschnittstelle aufgerufen wird, übersetzt es den Fehler möglicherweise in eine entsprechende Meldung, die dem Benutzer angezeigt wird, und so weiter. Der Punkt ist, dass diese Funktion nicht weiß, wie sie mit den Fehlern umgeht, die möglicherweise ausgelöst werden.

Erwartete Ausnahmen / Fehler sind gültige Testfälle: Sie bestätigen, dass sich die Funktion in einem solchen Fall wie erwartet verhält. Dies kann erreicht werden, indem das entsprechende Schein- / Fälschungs- / Stichobjekt geworfen wird, wenn dies gewünscht wird.

Das Wesen meiner Verwirrung ist, dass es zum Testen einer solchen Funktion in einer Einheit notwendig erscheint, die genaue Implementierung im Test selbst zu wiederholen (indem angegeben wird, dass Methoden in einer bestimmten Reihenfolge auf Mocks angewendet werden), und dass dies falsch erscheint.

Manchmal muss das gemacht werden (obwohl es Ihnen bei Integrationstests am wichtigsten ist). Häufiger gibt es andere Möglichkeiten, um die erwarteten Nebenwirkungen / Zustandsänderungen zu überprüfen.

Das Überprüfen der genauen Funktionsaufrufe führt zu eher spröden Komponententests: Nur kleine Änderungen an der ursprünglichen Funktion führen zum Fehlschlagen. Dies kann erwünscht sein oder nicht, erfordert jedoch eine Änderung der entsprechenden Komponententests, wenn Sie eine Funktion ändern (Refactoring, Optimierung, Fehlerbehebung, ...).

Leider verliert der Komponententest in diesem Fall etwas an Glaubwürdigkeit: Da er geändert wurde, bestätigt er die Funktion nicht, nachdem sich die Änderung wie zuvor verhalten hat.

Angenommen, jemand fügt oven.preheat()in Ihrem Beispiel für das Backen von Cookies einen Aufruf für (Optimierung!) Hinzu:

  • Wenn Sie das Ofenobjekt verspottet haben, wird es diesen Aufruf nicht erwarten und den Test nicht bestehen, obwohl sich das beobachtbare Verhalten der Methode nicht geändert hat (Sie haben hoffentlich immer noch eine Pfanne mit Cookies).
  • Ein Stub kann fehlschlagen oder nicht, je nachdem, ob Sie nur die zu testenden Methoden oder die gesamte Schnittstelle mit einigen Dummy-Methoden hinzugefügt haben.
  • Eine Fälschung sollte nicht scheitern, da sie die Methode (entsprechend der Schnittstelle) implementieren sollte

Bei meinen Unit-Tests versuche ich, so allgemein wie möglich zu sein: Wenn sich die Implementierung ändert, aber das sichtbare Verhalten (aus Sicht des Aufrufers) immer noch dasselbe ist, sollten meine Tests bestanden werden. Im Idealfall sollte der einzige Fall, in dem ich einen vorhandenen Komponententest ändern muss, eine Fehlerbehebung sein (des Tests, nicht der getesteten Funktion).

hoffmale
quelle
1
Das Problem ist, dass Sie, sobald Sie schreiben myDataStore.containsPerson('Joe'), davon ausgehen, dass eine funktionale Testdatenbank vorhanden ist. Sobald Sie das tun, schreiben Sie einen Integrationstest und keinen Komponententest.
Jonah
Ich gehe davon aus, dass ich mich auf einen Testdatenspeicher verlassen kann (es ist mir egal, ob es sich um einen echten oder einen nachgebildeten handelt) und dass alles so funktioniert, wie es eingerichtet ist (da ich für diese Fälle bereits Unit-Tests haben sollte). Das einzige, was der Test testen möchte, ist, dass savePeople()diese Personen tatsächlich zu dem von Ihnen bereitgestellten Datenspeicher hinzugefügt werden, solange dieser Datenspeicher die erwartete Schnittstelle implementiert. Ein Integrationstest besteht beispielsweise darin, zu überprüfen, ob mein Datenbank-Wrapper die richtigen Datenbankaufrufe für einen Methodenaufruf ausführt.
Hoffmale
Um zu verdeutlichen, ob Sie einen Mock verwenden, können Sie lediglich behaupten, dass eine Methode für diesen Mock aufgerufen wurde , möglicherweise mit einem bestimmten Parameter. Sie können sich danach nicht mehr auf den Status des Mocks festlegen. Wenn Sie also nach dem Aufruf der getesteten Methode wie in Aussagen zum Status der Datenbank treffen möchten myDataStore.containsPerson('Joe'), müssen Sie eine funktionale Datenbank verwenden. Sobald Sie diesen Schritt gemacht haben, ist es kein Unit-Test mehr.
Jonah
1
Es muss sich nicht um eine echte Datenbank handeln, sondern nur um ein Objekt, das dieselbe Schnittstelle wie der echte Datenspeicher implementiert (lesen Sie: Es besteht die relevanten Komponententests für die Datenspeicherschnittstelle). Ich würde das immer noch für einen Schein halten. Lassen Sie den Mock alles speichern, was durch eine Methode hinzugefügt wird, um dies in einem Array zu tun, und überprüfen Sie, ob die Testdaten (Elemente von myPeople) im Array sind. IMHO sollte ein Mock immer noch dasselbe beobachtbare Verhalten aufweisen, das von einem realen Objekt erwartet wird, andernfalls prüfen Sie, ob die Mock-Schnittstelle und nicht die reale Schnittstelle kompatibel ist.
Hoffmale
"Lassen Sie den Mock alles, was durch eine Methode hinzugefügt wird, in einem Array speichern und prüfen Sie, ob die Testdaten (Elemente von myPeople) im Array sind" - das ist immer noch eine "echte" Datenbank, nur eine Ad-hoc-Datenbank. In-Memory eine, die Sie gebaut haben. "IMHO sollte ein Mock immer noch das gleiche beobachtbare Verhalten haben, das von einem realen Objekt erwartet wird" - ich nehme an, Sie können sich dafür aussprechen, aber das bedeutet "Mock" in der Testliteratur oder in keiner der populären Bibliotheken, die ich ' habe gesehen. Ein Mock überprüft lediglich, ob die erwarteten Methoden mit den erwarteten Parametern aufgerufen wurden.
Jonah
13

Der primäre Wert, den ein solcher Test bietet, besteht darin, dass Ihre Implementierung überarbeitbar ist.

Ich habe in meiner Karriere viele Leistungsoptimierungen durchgeführt und häufig Probleme mit dem von Ihnen gezeigten Muster festgestellt: Um N Entitäten in der Datenbank zu speichern, führen Sie N Einfügungen durch. Normalerweise ist es effizienter, eine Masseneinfügung mit einer einzelnen Anweisung durchzuführen.

Andererseits wollen wir auch nicht vorzeitig optimieren. Wenn Sie in der Regel nur 1 bis 3 Personen gleichzeitig speichern, ist das Schreiben eines optimierten Stapels möglicherweise zu viel des Guten.

Mit einem ordnungsgemäßen Komponententest können Sie ihn so schreiben, wie Sie ihn oben implementiert haben. Wenn Sie feststellen, dass Sie ihn optimieren müssen, können Sie dies mit dem Sicherheitsnetz eines automatisierten Tests tun, um etwaige Fehler aufzufangen. Dies hängt natürlich von der Qualität der Tests ab. Testen Sie also großzügig und gut.

Der sekundäre Vorteil des Unit-Testens dieses Verhaltens besteht darin, dass es als Dokumentation für dessen Zweck dient. Dieses triviale Beispiel mag offensichtlich sein, aber im Hinblick auf den nächsten Punkt könnte es sehr wichtig sein.

Der dritte Vorteil, auf den andere hingewiesen haben, besteht darin, dass Sie verdeckte Details testen können, die mit Integrations- oder Abnahmetests nur sehr schwer zu testen sind. Wenn zum Beispiel die Anforderung besteht, dass alle Benutzer atomar gespeichert werden, können Sie einen Testfall dafür schreiben, der Ihnen Aufschluss über das erwartete Verhalten gibt und auch als Dokumentation für eine möglicherweise nicht offensichtliche Anforderung dient an neue Entwickler.

Ich werde einen Gedanken hinzufügen, den ich von einem TDD-Lehrer bekommen habe. Testen Sie die Methode nicht. Testen Sie das Verhalten. Mit anderen Worten, Sie testen nicht, dass dies savePeoplefunktioniert. Sie testen, dass mehrere Benutzer in einem einzigen Anruf gespeichert werden können.

Ich fand meine Fähigkeit Qualität Unit - Tests zu tun und TDD zu verbessern , wenn ich darüber nachzudenken , Unit - Tests gestoppt, Überprüfung, ob ein Programm funktioniert, sondern sie überprüfen, ob eine Einheit des Code tut , was ich erwarte . Das sind andere. Sie verifizieren nicht, dass es funktioniert, aber sie verifizieren, dass es das tut, was ich denke. Als ich anfing, so zu denken, änderte sich meine Perspektive.

Brandon
quelle
Das Refactoring-Beispiel für Masseneinsätze ist gut. Der mögliche Komponententest, den ich im OP vorgeschlagen habe - ein Schein von dataStore hat savePersonihn für jede Person in der Liste aufgerufen - würde jedoch mit einem Bulk Insert Refactoring brechen. Was für mich bedeutet, dass es ein schlechter Komponententest ist. Ich sehe jedoch keine Alternative, die sowohl die Massenimplementierung als auch die Einzel-Sicherungsimplementierung durchläuft, ohne eine tatsächliche Testdatenbank zu verwenden und dagegen zu behaupten, was falsch zu sein scheint. Können Sie einen Test bereitstellen, der für beide Implementierungen funktioniert?
Jonah
1
@ jpmc26 Was ist mit einem Test, der prüft, ob die Menschen gerettet wurden ...?
immibis
1
@immibis Ich verstehe nicht, was das bedeutet. Vermutlich wird der reale Laden durch eine Datenbank gesichert, sodass Sie ihn für einen Komponententest verspotten oder stummschalten müssen. An diesem Punkt würden Sie testen, ob Ihr Mock oder Stub Objekte speichern kann. Das ist völlig nutzlos. Das Beste, was Sie tun können, ist zu behaupten, dass die savePersonMethode für jede Eingabe aufgerufen wurde. Wenn Sie die Schleife durch eine Masseneinfügung ersetzen, würden Sie diese Methode nicht mehr aufrufen. Ihr Test würde also brechen. Wenn Sie etwas anderes im Sinn haben, bin ich offen dafür, aber ich sehe es noch nicht. (Und es nicht zu sehen, war mein Punkt.)
jpmc26
1
@immibis Ich halte das nicht für einen nützlichen Test. Die Verwendung des gefälschten Datenspeichers gibt mir kein Vertrauen, dass er mit der realen Sache funktioniert. Woher weiß ich, dass meine Fälschung wie die echte funktioniert? Ich würde mich lieber von einer Reihe von Integrationstests abdecken lassen. (Ich soll wohl klarstellen, dass ich „jede bedeuten Einheit Test“ in meinem ersten Kommentar hier.)
jpmc26
1
@immibis Ich überdenke gerade meine Position. Ich war wegen dieser Probleme skeptisch gegenüber dem Wert von Komponententests, aber vielleicht unterschätze ich den Wert, auch wenn Sie eine Eingabe vortäuschen. Ich weiß , dass Input / Output-Tests viel nützlicher sind als scheinbar schwere Tests, aber vielleicht ist die Weigerung, einen Input durch einen Fake zu ersetzen, tatsächlich ein Teil des Problems.
jpmc26
6

Sollte bakeCookies()getestet werden? Ja.

Wie sollte eine Funktion wie diese Unit getestet werden, vorausgesetzt, Sie glauben, dass es sollte? Es fällt mir schwer, mir irgendeine Art von Unit-Test vorzustellen, bei dem Teig, Pfanne und Ofen nicht einfach nur verspottet werden und dann behauptet wird, dass Methoden für sie gelten.

Nicht wirklich. Schauen Sie sich genau an, WAS die Funktion tun soll - sie soll das ovenObjekt in einen bestimmten Zustand versetzen. Betrachtet man den Code, so scheint es, dass die Zustände der panund dough-Objekte nicht wirklich wichtig sind. Übergeben Sie also ein ovenObjekt (oder verspotten Sie es) und stellen Sie am Ende des Funktionsaufrufs fest, dass es sich in einem bestimmten Zustand befindet.

Mit anderen Worten, Sie sollten behaupten, dass bakeCookies()die Kekse gebacken haben .

Bei sehr kurzen Funktionen scheinen Komponententests nicht viel mehr zu sein als Tautologie. Aber vergessen Sie nicht, Ihr Programm wird viel länger dauern als die Zeit, in der Sie es schreiben. Diese Funktion kann sich in Zukunft ändern oder auch nicht.

Unit-Tests haben zwei Funktionen:

  1. Es testet, dass alles funktioniert. Dies ist die am wenigsten nützliche Funktion, für die Unit-Tests durchgeführt werden, und es scheint, dass Sie diese Funktionalität nur bei der Frage berücksichtigen.

  2. Es wird überprüft, ob zukünftige Änderungen des Programms die zuvor implementierte Funktionalität beeinträchtigen. Dies ist die nützlichste Funktion von Komponententests und verhindert die Einführung von Fehlern in große Programme. Es ist nützlich beim normalen Codieren, wenn Features zum Programm hinzugefügt werden, aber es ist nützlicher beim Refactoring und bei Optimierungen, bei denen die Kernalgorithmen, die das Programm implementieren, dramatisch geändert werden, ohne das beobachtbare Verhalten des Programms zu ändern.

Testen Sie nicht den Code innerhalb der Funktion. Testen Sie stattdessen, ob die Funktion das tut, was sie sagt. Wenn Sie Unit-Tests auf diese Weise betrachten (Funktionen testen, nicht Code), werden Sie feststellen, dass Sie niemals Sprachkonstrukte oder gar Anwendungslogik testen. Sie testen eine API.

Slebetman
quelle
Hallo, danke für deine Antwort. Würde es Ihnen etwas ausmachen, mein zweites Update anzuschauen und darüber nachzudenken, wie Sie die Funktion in diesem Beispiel testen können?
Jonah
Ich finde, dass dies effektiv sein kann, wenn Sie entweder einen echten Ofen oder einen gefälschten Ofen verwenden können, aber mit einem Scheinofen bedeutend weniger effektiv ist. Mocks (nach den Meszaros-Definitionen) haben keinen anderen Status als eine Aufzeichnung der Methoden, die für sie aufgerufen wurden. Ich habe die Erfahrung gemacht, dass Funktionen, bakeCookiesdie auf diese Weise getestet werden, bei Refaktoren, die sich nicht auf das beobachtbare Verhalten der Anwendung auswirken, zum Absturz neigen.
James_pic
@ James_pic, genau. Und ja, das ist die Scheindefinition, die ich verwende. Was machen Sie in einem solchen Fall angesichts Ihres Kommentars? Auf den Test verzichten? Schreiben Sie den spröden, implementierungswiederholenden Test trotzdem? Etwas anderes?
Jonah
@Jonah Im Allgemeinen werde ich entweder versuchen, diese Komponente mit Integrationstests zu testen (ich habe die Warnungen gefunden, dass es schwieriger ist, Fehler zu beheben, die möglicherweise auf die Qualität moderner Tools zurückzuführen sind), oder mich mit der Erstellung eines halb überzeugende Fälschung.
James_pic
3

Sollte savePeople () Unit-getestet werden, oder würden solche Tests dem Testen des integrierten forEach-Sprachkonstrukts gleichkommen?

Ja. Aber Sie könnten es auf eine Weise tun, die das Konstrukt erneut testet.

Hier ist zu beachten, wie sich diese Funktion verhält, wenn ein savePersonFehler auf halbem Weg auftritt. Wie soll es funktionieren?

Das ist die Art subtilen Verhaltens, die die Funktion bietet, die Sie mit Unit-Tests erzwingen sollten.

Telastyn
quelle
Ja, ich stimme zu, dass subtile Fehlerbedingungen getestet werden sollten , aber imo ist das keine interessante Frage - die Antwort ist klar. Aus diesem Grund habe ich ausdrücklich angegeben, dass ich im Rahmen meiner Frage savePeoplenicht für die Fehlerbehandlung verantwortlich sein sollte. Um wieder zu klären, unter der Annahme , dass savePeopleverantwortlich ist nur für Sie durch die Liste iterieren und die Speicherung der einzelnen Elemente an einem anderen Methode zu delegieren, sollte es noch getestet werden?
Jonah
@Jonah: Wenn Sie darauf bestehen wollen, Ihren Komponententest nur auf das foreachKonstrukt zu beschränken und keine Bedingungen, Nebenwirkungen oder Verhaltensweisen außerhalb des Konstrukts, dann haben Sie Recht. Der neue Unit-Test ist eigentlich gar nicht so interessant.
Robert Harvey
1
@jonah - sollte es durchlaufen und so viele wie möglich speichern oder auf Fehler stoppen? Das einzelne Speichern kann das nicht entscheiden, da es nicht wissen kann, wie es verwendet wird.
Telastyn
1
@jonah - Willkommen auf der Seite. Eine der Schlüsselkomponenten unseres Q & A-Formats ist, dass wir nicht hier sind, um Ihnen zu helfen . Ihre Frage hilft Ihnen natürlich, aber es hilft auch vielen anderen Menschen, die auf die Website kommen und nach Antworten auf ihre Fragen suchen. Ich habe die von Ihnen gestellte Frage beantwortet. Es ist nicht meine Schuld, wenn Sie die Antwort nicht mögen oder lieber die Torpfosten verschieben möchten. Und ehrlich gesagt scheinen die anderen Antworten dasselbe zu sagen, wenn auch beredter.
Telastyn
1
@Telastyn, ich versuche, einen Einblick in Unit-Tests zu bekommen. Meine ursprüngliche Frage war nicht klar genug, daher füge ich Erläuterungen hinzu, um das Gespräch auf meine eigentliche Frage auszurichten. Du entscheidest dich dafür, das so zu interpretieren, als würde ich dich irgendwie im Spiel "Recht haben" betrügen. Ich habe Hunderte von Stunden damit verbracht, Fragen zur Codeüberprüfung und zu SO zu beantworten. Mein Ziel ist es immer, den Leuten zu helfen, denen ich antworte. Wenn nicht, haben Sie die Wahl. Sie müssen meine Fragen nicht beantworten.
Jonah
3

Der Schlüssel hier ist Ihre Sicht auf eine bestimmte Funktion als Trivialität. Der größte Teil der Programmierung ist trivial: einen Wert zuweisen, etwas rechnen, eine Entscheidung treffen: Wenn dies dann der Fall ist, eine Schleife fortsetzen, bis ... Alle trivial. Sie sind gerade durch die ersten 5 Kapitel eines Buches gekommen, das eine Programmiersprache unterrichtet.

Die Tatsache, dass das Schreiben eines Tests so einfach ist, sollte ein Zeichen dafür sein, dass Ihr Design nicht so schlecht ist. Wünschen Sie ein Design, das nicht einfach zu testen ist?

"Das wird sich nie ändern." So beginnen die meisten gescheiterten Projekte. Ein Einheitentest bestimmt nur, ob die Einheit unter bestimmten Umständen wie erwartet funktioniert. Lass es passieren und dann kannst du die Implementierungsdetails vergessen und es einfach benutzen. Nutzen Sie diesen Gehirnraum für die nächste Aufgabe.

Zu wissen, dass die Dinge wie erwartet funktionieren, ist sehr wichtig und bei großen Projekten und insbesondere bei großen Teams nicht trivial. Wenn Programmierer eines gemeinsam haben, ist die Tatsache, dass wir uns alle mit dem schrecklichen Code eines anderen auseinandersetzen mussten. Das Mindeste, was wir tun können, sind einige Tests. Wenn Sie Zweifel haben, schreiben Sie einen Test und fahren Sie fort.

JeffO
quelle
Vielen Dank für Ihr Feedback. Gute Argumente. Die Frage, die ich wirklich beantworten möchte (ich habe gerade ein weiteres Update hinzugefügt, um dies zu verdeutlichen), ist die geeignete Methode zum Testen von Funktionen, die nichts anderes tun, als eine Abfolge anderer Dienste durch Delegation aufzurufen. In solchen Fällen scheinen die für die "Dokumentation des Vertrags" geeigneten Komponententests nur eine Wiederholung der Funktionsimplementierung zu sein und zu behaupten, dass Methoden für verschiedene Mocks aufgerufen werden. Dennoch fühlt sich der Test, der mit der Implementierung in diesen Fällen identisch ist, falsch an ...
Jonah,
1

Sollte savePeople () Unit-getestet werden, oder würden solche Tests dem Testen des integrierten forEach-Sprachkonstrukts gleichkommen?

Dies wurde bereits von @BryanOakley beantwortet, aber ich habe einige zusätzliche Argumente (denke ich):

Zunächst dient ein Unit-Test zum Testen der Vertragserfüllung und nicht der Implementierung einer API. Der Test sollte Vorbedingungen setzen, dann aufrufen und auf Auswirkungen, Nebenwirkungen, etwaige Invarianten und Post-Bedingungen prüfen. Wenn Sie entscheiden, was getestet werden soll, spielt die Implementierung der API keine Rolle (und sollte es auch nicht sein) .

Zweitens wird Ihr Test dort sein, um die Invarianten zu überprüfen, wenn sich die Funktion ändert . Die Tatsache, dass es sich jetzt nicht ändert, bedeutet nicht, dass Sie den Test nicht haben sollten.

Drittens ist es wertvoll, einen trivialen Test sowohl in einem TDD-Ansatz (der ihn vorschreibt) als auch außerhalb davon implementiert zu haben.

Beim Schreiben von C ++ neige ich dazu, für meine Klassen einen einfachen Test zu schreiben, der ein Objekt instanziiert und Invarianten überprüft (zuweisbar, regulär usw.). Ich fand es überraschend, wie oft dieser Test während der Entwicklung unterbrochen wurde (zum Beispiel durch versehentliches Hinzufügen eines nicht beweglichen Elements in einer Klasse).

utnapistim
quelle
1

Ich denke, Ihre Frage lautet:

Wie teste ich eine Void-Funktion, ohne dass es sich um einen Integrationstest handelt?

Wenn wir zum Beispiel Ihre Cookie-Backfunktion so ändern, dass Cookies zurückgegeben werden, wird sofort klar, wie der Test aussehen soll.

Wenn wir pan.GetCookies aufrufen müssen, nachdem wir die Funktion aufgerufen haben, können wir uns fragen, ob es sich wirklich um einen Integrationstest handelt oder ob wir nicht nur das pan-Objekt testen.

Ich denke, Sie haben Recht damit, dass Unit-Tests mit allem, was verspottet wurde, und nur die Überprüfung der Funktionen xy und z als Mangelwert bezeichnet wurden.

Aber! Ich würde argumentieren, dass Sie in diesem Fall Ihre Void-Funktionen überarbeiten sollten, um ein testbares Ergebnis zurückzugeben, ODER reale Objekte verwenden und einen Integrationstest durchführen sollten

--- Update für das createNewUser-Beispiel

  • In der Datenbank muss ein neuer Benutzerdatensatz erstellt werden
  • Es muss eine Begrüßungs-E-Mail gesendet werden
  • Die IP-Adresse des Benutzers muss zu Betrugszwecken aufgezeichnet werden.

OK, diesmal wird das Ergebnis der Funktion nicht einfach zurückgegeben. Wir wollen den Zustand der Parameter ändern.

Hier werde ich etwas kontrovers. Ich erstelle konkrete Mock-Implementierungen für die Stateful-Parameter

Bitte, liebe Leser, versuchen Sie, Ihre Wut unter Kontrolle zu halten!

damit...

var validatedUserData = new UserData(); //we can use the real object for this
var emailService = new MockEmailService(); //a simple mock which saves sentEmails to a List<string>
var dataStore = new MockDataStore(); //a simple mock which saves ips to a List<string>

//run the test
target.createNewUser(validatedUserData, emailService, dataStore);

//check the results
Assert.AreEqual(1, emailService.EmailsSent.Count());
Assert.AreEqual(1, dataStore.IpsRecorded.Count());
Assert.AreEqual(1, dataStore.UsersSaved.Count());

Dies trennt das Implementierungsdetail der zu testenden Methode vom gewünschten Verhalten. Eine alternative Implementierung:

function createNewUser(validatedUserData, emailService, dataStore) {
  userId = dataStore.bulkInsedrtUserRecords(new [] {validateduserData});
  emailService.addEmailToQueue(validatedUserData);
  emailService.ProcessQueue();
  dataStore.recordIpAddress(userId, validatedUserData.ip);
}

Wird immer noch den Unit-Test bestehen. Außerdem haben Sie den Vorteil, dass Sie die Scheinobjekte testübergreifend wiederverwenden und sie für UI- oder Integrationstests in Ihre Anwendung einfügen können.

Ewan
quelle
Es ist kein Integrationstest, nur weil Sie die Namen von zwei konkreten Klassen nennen. Bei Integrationstests geht es darum, Integrationen mit externen Systemen wie Disk IO, der DB, externen Webdiensten usw. zu testen -Speicher, schnell, sucht nach dem, woran wir interessiert sind usw. Ich bin damit einverstanden, dass die Methode, die Cookies direkt zurücksendet, sich jedoch wie ein besseres Design anfühlt.
Sara
3
Warten. Nach allem, was wir wissen, sendet pan.getcookies eine E-Mail an einen Koch und bittet ihn, die Kekse aus dem Ofen zu nehmen, wenn er die Gelegenheit dazu hat
Ewan,
Ich denke, es ist theoretisch möglich, aber es wäre ein ziemlich irreführender Name. Wer hat jemals von einem Ofengerät gehört, das E-Mails versendet? aber ich sehe dich zeigen, es kommt darauf an. Ich gehe davon aus, dass es sich bei diesen zusammenarbeitenden Klassen um Blattobjekte oder einfach nur im Speicher befindliche Objekte handelt. Wenn sie jedoch hinterhältige Aufgaben ausführen, ist Vorsicht geboten. Ich denke, das Versenden von E-Mails sollte auf jeden Fall auf einer höheren Ebene erfolgen. Dies scheint die Down- und Dirty-Business-Logik in den Entitäten zu sein.
Sara
2
Es war eine rhetorische Frage, aber: "Wer hat jemals von Ofengeräten gehört, die E-Mails verschicken?" venturebeat.com/2016/03/08/…
clacke
Hallo Ewan, ich denke, diese Antwort kommt dem, was ich wirklich verlange, so weit wie möglich. Ich denke, Ihr bakeCookiesArgument, die gebackenen Kekse zurückzugeben, ist genau richtig, und ich hatte einige Gedanken, nachdem ich sie veröffentlicht habe. Ich denke, es ist wieder kein gutes Beispiel. Ich habe ein weiteres Update hinzugefügt, das hoffentlich ein realistischeres Beispiel dafür liefert, was meine Frage motiviert. Ich würde mich über Ihre Beiträge freuen.
Jonah
0

Sie sollten auch testen bakeCookies- was würde / sollte e..g bakeCookies(egg, pan, oven)ergeben? Spiegelei oder eine Ausnahme? Weder für sich pannoch für sich ovenselbst interessieren die eigentlichen Zutaten, da keiner von ihnen eigentlich bakeCookiesKekse liefern sollte, sondern soll. Im Allgemeinen kann es davon abhängen, wie doughes beschafft wird und ob die Chance besteht, dass es einfach wird eggoder z water. B. stattdessen.

Tobias Kienzler
quelle