TDD und vollständige Testabdeckung, wenn exponentielle Testfälle benötigt werden

17

Ich arbeite an einem Listenkomparator, um die Sortierung einer ungeordneten Liste von Suchergebnissen nach ganz bestimmten Anforderungen unseres Kunden zu unterstützen. Die Anforderungen verlangen nach einem eingestuften Relevanzalgorithmus mit den folgenden Regeln in der Reihenfolge der Wichtigkeit:

  1. Genaue Übereinstimmung mit dem Namen
  2. Alle Wörter der Suche werden im Namen oder einem Synonym des Ergebnisses abgefragt
  3. Einige Wörter der Suchanfrage im Namen oder Synonym des Ergebnisses (% absteigend)
  4. Alle Wörter der Suchanfrage in der Beschreibung
  5. Einige Wörter der Suchanfrage in der Beschreibung (% absteigend)
  6. Datum der letzten Änderung absteigend

Die natürliche Designwahl für diesen Komparator schien eine auf Potenzen von 2 basierende Wertung zu sein. Die Summe von weniger wichtigen Regeln kann niemals mehr als eine positive Übereinstimmung mit einer Regel mit höherer Wichtigkeit sein. Dies wird durch die folgende Punktzahl erreicht:

  1. 32
  2. 16
  3. 8 (Sekundäre Tie-Breaker-Punktzahl basierend auf% absteigend)
  4. 4
  5. 2 (Sekundäre Tie-Breaker-Punktzahl basierend auf% absteigend)
  6. 1

Im Sinne des TDD habe ich mich dazu entschlossen, zuerst mit meinen Unit-Tests zu beginnen. Einen Testfall für jedes einzelne Szenario zu haben, würde mindestens 63 einzelnen Testfällen entsprechen, wobei zusätzliche Testfälle für die Logik der sekundären Verbindungsunterbrecher in den Regeln 3 und 5 nicht berücksichtigt werden. Dies scheint überheblich.

Die tatsächlichen Tests werden jedoch tatsächlich weniger sein. Auf der Grundlage der eigentlichen Regeln stellen bestimmte Regeln sicher, dass niedrigere Regeln immer zutreffen (z. B. Wenn "Alle Suchbegriffe in der Beschreibung enthalten", ist die Regel "Einige Suchbegriffe in der Beschreibung enthalten" immer zutreffend). Lohnt sich der Aufwand, jeden dieser Testfälle zu schreiben? Ist dies die Teststufe, die normalerweise erforderlich ist, wenn es um eine Testabdeckung von 100% bei TDD geht? Wenn nicht, was wäre dann eine akzeptable alternative Teststrategie?

maple_shaft
quelle
1
In diesem und ähnlichen Szenarien habe ich einen "TMatrixTestCase" und einen Enumerator entwickelt, für die Sie den Testcode einmal schreiben und ihm zwei oder mehr Arrays mit den Eingaben und dem erwarteten Ergebnis zuführen können.
Marjan Venema

Antworten:

16

Ihre Frage impliziert, dass TDD etwas mit "Schreiben aller Testfälle zuerst" zu tun hat. IMHO ist das nicht "im Geiste von TDD", eigentlich ist es dagegen . Denken Sie daran, dass TDD für "Test Driven Development" steht. Sie benötigen also nur die Testfälle, die Ihre Implementierung wirklich "vorantreiben", nicht mehr. Und solange Ihre Implementierung nicht so konzipiert ist, dass die Anzahl der Codeblöcke mit jeder neuen Anforderung exponentiell wächst, benötigen Sie auch keine exponentielle Anzahl von Testfällen. In Ihrem Beispiel sieht der TDD-Zyklus wahrscheinlich so aus:

  • Beginnen Sie mit der ersten Anforderung aus Ihrer Liste: Wörter mit "Exakte Übereinstimmung mit dem Namen" müssen eine höhere Punktzahl als alles andere erhalten
  • Jetzt schreiben Sie einen ersten Testfall dafür (zum Beispiel ein Wort, das zu einer bestimmten Abfrage passt) und implementieren die minimale Menge an Arbeitscode, die diesen Test besteht
  • Fügen Sie einen zweiten Testfall für die erste Anforderung hinzu (z. B. ein Wort, das nicht mit der Abfrage übereinstimmt), und ändern Sie vor dem Hinzufügen eines neuen Testfalls Ihren vorhandenen Code, bis der zweite Test bestanden ist
  • Abhängig von den Details Ihrer Implementierung können Sie weitere Testfälle hinzufügen, z. B. eine leere Abfrage, ein leeres Wort usw. (Denken Sie daran: TDD ist ein White-Box- Ansatz. Sie können die Tatsache nutzen, dass Sie Ihre Implementierung kennen, wenn Sie dies tun entwerfen Sie Ihre Testfälle).

Beginnen Sie dann mit der 2. Anforderung:

  • "Alle Wörter der Suchanfrage im Namen oder ein Synonym des Ergebnisses" müssen eine niedrigere Punktzahl als "Genaue Übereinstimmung mit dem Namen", aber eine höhere Punktzahl als alles andere erhalten.
  • Erstellen Sie nun wie oben beschrieben nacheinander Testfälle für diese neue Anforderung und implementieren Sie nach jedem neuen Test den nächsten Teil Ihres Codes. Vergessen Sie nicht, zwischendurch sowohl Ihren Code als auch Ihre Testfälle umzugestalten.

Hier ist der Haken : Wenn Sie Testfälle für die Anforderungs- / Kategorienummer "n" hinzufügen, müssen Sie nur Tests hinzufügen, um sicherzustellen, dass die Punktzahl der Kategorie "n-1" höher ist als die Punktzahl für die Kategorie "n". . Sie müssen keine Testfälle für jede andere Kombination der Kategorien 1, ..., n-1 hinzufügen, da die von Ihnen zuvor geschriebenen Tests sicherstellen, dass die Ergebnisse dieser Kategorien weiterhin in der richtigen Reihenfolge vorliegen.

So erhalten Sie eine Reihe von Testfällen, die nicht exponentiell, sondern ungefähr linear mit der Anzahl der Anforderungen wachsen.

Doc Brown
quelle
Ich mag diese Antwort wirklich. Es gibt eine klare und prägnante Unit-Test-Strategie, um dieses Problem unter Berücksichtigung von TDD anzugehen. Sie brechen es ganz schön auf.
maple_shaft
@maple_shaft: Danke, und ich mag deine Frage wirklich. Ich möchte hinzufügen, dass die klassische Technik des Erstellens von Äquivalenzklassen für Tests trotz Ihres Ansatzes, zuerst alle Testfälle zu entwerfen, möglicherweise ausreicht, um das exponentielle Wachstum zu verringern (aber ich habe das bisher nicht herausgearbeitet).
Doc Brown
13

Schreiben Sie eine Klasse, die eine vordefinierte Liste von Bedingungen durchläuft und für jede erfolgreiche Prüfung eine aktuelle Punktzahl mit 2 multipliziert.

Dies kann sehr einfach mit nur ein paar verspotteten Tests getestet werden.

Dann können Sie für jede Bedingung eine Klasse schreiben und es gibt nur 2 Tests für jeden Fall.

Ich verstehe Ihren Anwendungsfall nicht wirklich, aber hoffentlich hilft dieses Beispiel.

public class ScoreBuilder
{
    private ISingleScorableCondition[] _conditions;
    public ScoreBuilder (ISingleScorableCondition[] conditions)
    {
        _conditions = conditions;
    }

    public int GetScore(string toBeScored)
    {
        foreach (var condition in _conditions)
        {
            if (_conditions.Test(toBeScored))
            {
                // score this somehow
            }
        }
    }
}

public class ExactMatchOnNameCondition : ISingleScorableCondition
{
    private IDataSource _dataSource;
    public ExactMatchOnNameCondition(IDataSource dataSource)
    {
        _dataSource = dataSource;
    }

    public bool Test(string toBeTested)
    {
        return _dataSource.Contains(toBeTested);
    }
}

// etc

Sie werden feststellen, dass Ihre 2 ^ -Bedingungenstests schnell 4+ (2 * -Bedingungen) ergeben. 20 ist viel weniger anstrengend als 64. Und wenn Sie später eine weitere hinzufügen, müssen Sie KEINE der vorhandenen Klassen ändern (Open-Closed-Prinzip), sodass Sie keine 64 neuen Tests schreiben müssen, sondern nur um eine weitere Klasse mit 2 neuen Tests hinzuzufügen und diese in Ihre ScoreBuilder-Klasse einzufügen.

pdr
quelle
Interessanter Ansatz. Die ganze Zeit über dachte ich nicht über einen OOP-Ansatz nach, da ich im Kopf einer einzelnen Vergleichskomponente steckte. Ich habe wirklich keinen Algorithmus-Rat gesucht, aber das ist trotzdem sehr hilfreich.
maple_shaft
4
@maple_shaft: Nein, aber Sie haben nach TDD-Rat gesucht, und diese Art von Algorithmen sind perfekt, um die Frage zu beseitigen, ob sich der Aufwand lohnt, indem Sie den Aufwand erheblich reduzieren. Die Reduzierung der Komplexität ist der Schlüssel zu TDD.
pdr
+1, tolle Antwort. Obwohl ich glaube, dass auch ohne solch eine ausgefeilte Lösung die Anzahl der Testfälle nicht exponentiell ansteigen muss (siehe meine Antwort unten).
Doc Brown
Ich habe Ihre Antwort nicht akzeptiert, weil ich der Meinung war, dass eine andere Antwort die eigentliche Frage besser anspricht, aber Ihr Designansatz hat mir so gut gefallen, dass ich ihn so umsetze, wie Sie es vorgeschlagen haben. Dies reduziert die Komplexität und macht sie langfristig erweiterbar.
maple_shaft
4

Lohnt sich der Aufwand, jeden dieser Testfälle zu schreiben?

Sie müssen "wert" definieren. Das Problem bei dieser Art von Szenario ist, dass die Tests eine abnehmende Rendite für die Nützlichkeit haben. Der erste Test, den Sie schreiben, wird sich auf jeden Fall lohnen. Es kann offensichtliche Fehler in der Priorität und sogar Dinge wie Parsing-Fehler finden, wenn versucht wird, die Wörter aufzubrechen.

Der zweite Test lohnt sich, da er einen anderen Pfad durch den Code abdeckt und möglicherweise eine andere Prioritätsbeziehung überprüft.

Der 63. Test wird sich wahrscheinlich nicht lohnen, da Sie zu 99,99% sicher sind, dass er von der Logik Ihres Codes oder eines anderen Tests abgedeckt wird.

Ist dies die Teststufe, die normalerweise erforderlich ist, wenn es um eine Testabdeckung von 100% bei TDD geht?

Mein Verständnis ist 100% Abdeckung bedeutet, dass alle Codepfade ausgeübt werden. Dies bedeutet nicht, dass Sie alle Kombinationen Ihrer Regeln ausführen, aber alle unterschiedlichen Pfade, auf denen Ihr Code ausgeführt werden kann (wie Sie bereits betont haben, können einige Kombinationen im Code nicht vorhanden sein). Da Sie jedoch TDD ausführen, gibt es noch keinen "Code", nach dem Sie suchen müssen. Der Buchstabe des Prozesses würde sagen, alle 63+ zu machen.

Ich persönlich empfinde 100% Deckung als Wunschtraum. Darüber hinaus ist es unpragmatisch. Unit-Tests dienen Ihnen und nicht umgekehrt. Wenn Sie mehr Tests durchführen, erhalten Sie eine geringere Rendite für den Vorteil (die Wahrscheinlichkeit, dass der Test einen Fehler verhindert, + die Gewissheit, dass der Code korrekt ist). Abhängig davon, was Ihr Code tut, legen Sie fest, wo Sie auf dieser Skala keine Tests mehr durchführen. Wenn Ihr Code einen Kernreaktor betreibt, lohnen sich vielleicht alle 63+ Tests. Wenn Ihr Code Ihr Musikarchiv organisiert, könnten Sie wahrscheinlich mit viel weniger davonkommen.

Telastyn
quelle
"Abdeckung" bezieht sich typischerweise auf die Codeabdeckung (jede Codezeile wird ausgeführt) oder die Zweigabdeckung (jeder Zweig wird mindestens einmal in eine mögliche Richtung ausgeführt). Für beide Arten der Abdeckung sind keine 64 verschiedenen Testfälle erforderlich. Zumindest nicht bei einer seriösen Implementierung, die keine einzelnen Codeteile für jeden der 64 Fälle enthält. Somit ist eine 100% ige Abdeckung voll möglich.
Doc Brown
@DocBrown - in diesem Fall sind andere Dinge schwieriger / unmöglich zu testen; Ausnahmepfade zu wenig Arbeitsspeicher berücksichtigen. Wären nicht alle 64 in TDD "nach dem Buchstaben" erforderlich, um das Verhalten zu erzwingen, ohne die Implementierung zu kennen?
Telastyn
Nun, mein Kommentar bezog sich auf die Frage, und Ihre Antwort erweckt den Eindruck, dass es im Falle des OP schwierig sein könnte, eine 100% ige Abdeckung zu erreichen . Ich bezweifle das. Und ich stimme Ihnen zu, dass man Fälle konstruieren kann, in denen eine 100% ige Abdeckung schwieriger zu erreichen ist, aber das wurde nicht gefragt.
Doc Brown
4

Ich würde argumentieren, dass dies ein perfekter Fall für TDD ist.

Sie müssen eine Reihe bekannter Kriterien testen, mit einer logischen Aufschlüsselung dieser Fälle. Angenommen, Sie werden sie entweder jetzt oder später in einem Komponententest testen, erscheint es sinnvoll, das bekannte Ergebnis zu verwenden und darauf aufzubauen, um sicherzustellen, dass Sie tatsächlich jede der Regeln unabhängig abdecken.

Außerdem erfahren Sie, wenn Sie eine neue Suchregel hinzufügen, die gegen eine vorhandene Regel verstößt. Wenn Sie dies alles am Ende der Codierung tun, besteht vermutlich ein höheres Risiko, dass Sie eines ändern müssen, um eines zu reparieren, was ein anderes zerstört, was ein anderes zerstört ... Und Sie lernen, wie Sie die Regeln implementieren, ob Ihr Entwurf gültig ist oder muss optimiert werden.

Wonko der Vernünftige
quelle
1

Ich bin kein Fan davon, eine 100% ige Testabdeckung streng zu interpretieren, indem ich Spezifikationen für jede einzelne Methode schreibe oder jede Permutation des Codes teste. Wenn Sie dies fanatisch tun, führt dies in der Regel zu einem testgetriebenen Design Ihrer Klassen, das die Geschäftslogik nicht richtig einschließt und Tests / Spezifikationen liefert, die im Allgemeinen für die Beschreibung der unterstützten Geschäftslogik bedeutungslos sind. Stattdessen konzentriere ich mich auf die Strukturierung der Tests, ähnlich wie die Geschäftsregeln selbst, und bemühe mich, jeden bedingten Zweig des Codes mit Tests auszuüben, mit der ausdrücklichen Erwartung, dass die Tests für den Tester leicht zu verstehen sind, wie es die allgemeinen Anwendungsfälle wären und tatsächlich beschreiben Geschäftsregeln, die implementiert wurden.

Unter Berücksichtigung dieser Idee würde ich die 6 Ranking-Faktoren, die Sie für sich aufgelistet haben, ausführlich testen und anschließend zwei oder drei Integrationstests durchführen, um sicherzustellen, dass Sie Ihre Ergebnisse auf die erwarteten Gesamtranking-Werte bringen. Beispiel: In Fall 1, Exakte Übereinstimmung mit dem Namen, müssten mindestens zwei Komponententests durchgeführt werden, um zu testen, wann genau und wann nicht und ob die beiden Szenarien die erwartete Punktzahl zurückgeben. Wenn die Groß- und Kleinschreibung beachtet wird, gibt auch ein Testfall für "Exakte Übereinstimmung" im Vergleich zu "Exakte Übereinstimmung" und möglicherweise für andere Eingabevariationen wie Interpunktion, zusätzliche Leerzeichen usw. die erwarteten Ergebnisse zurück.

Nachdem ich alle einzelnen Faktoren durchgearbeitet habe, die zur Bewertung des Rankings beitragen, gehe ich im Wesentlichen davon aus, dass diese auf der Integrationsebene korrekt funktionieren, und konzentriere mich darauf, sicherzustellen, dass ihre kombinierten Faktoren korrekt zur endgültigen erwarteten Bewertung des Rankings beitragen.

Angenommen, die Fälle Nr. 2 / Nr. 3 und Nr. 4 / Nr. 5 werden auf die gleichen zugrunde liegenden Methoden verallgemeinert, aber Sie müssen nur einen Satz von Komponententests für die zugrunde liegenden Methoden schreiben und einfache zusätzliche Komponententests schreiben, um die spezifischen zu testen Felder (Titel, Name, Beschreibung usw.) und Bewertung beim Designated Factoring, sodass die Redundanz Ihres gesamten Testaufwands weiter reduziert wird.

Mit diesem Ansatz würde der oben beschriebene Ansatz wahrscheinlich 3 oder 4 Einheitentests für Fall Nr. 1 ergeben, möglicherweise 10 Spezifikationen für einige / alle mit Synonymen, plus 4 Spezifikationen für die korrekte Bewertung der Fälle Nr. 2 - Nr. 5 und 2 bis zu 3 Spezifikationen am Enddatum bestellt Rangfolge, dann 3 bis 4 Integrationstests, die alle 6 Fälle in wahrscheinlicher Weise kombiniert messen (vergessen Sie vorerst obskure Randfälle, es sei denn, Sie sehen eindeutig ein Problem in Ihrem Code, das ausgeübt werden muss, um dies sicherzustellen Diese Bedingung wird behandelt) oder stellen Sie sicher, dass durch spätere Überarbeitungen keine Verletzungen oder Brüche verursacht werden. Das ergibt ungefähr 25 Specs, um 100% des geschriebenen Codes auszuüben (obwohl Sie nicht direkt 100% der geschriebenen Methoden aufgerufen haben).

Michael Lang
quelle
1

Ich war noch nie ein Fan von 100% Testabdeckung. Wenn etwas so einfach ist, dass es nur mit ein oder zwei Testfällen getestet werden kann, ist es meiner Erfahrung nach so einfach, dass es selten fehlschlägt. Wenn dies fehlschlägt, liegt dies normalerweise an Änderungen der Architektur, die ohnehin Teständerungen erfordern würden.

Abgesehen davon, für Anforderungen wie Ihre, teste ich meine Geräte immer gründlich, auch bei persönlichen Projekten, bei denen mich niemand dazu bringt, denn das erspart Ihnen Zeit und Ärger. Je mehr Unit-Tests erforderlich sind, um etwas zu testen, desto mehr Zeit sparen Unit-Tests.

Das liegt daran, dass Sie nur so viele Dinge gleichzeitig in Ihrem Kopf halten können. Wenn Sie versuchen, Code zu schreiben, der für 63 verschiedene Kombinationen funktioniert, ist es oft schwierig, eine Kombination zu reparieren, ohne eine andere zu beschädigen. Am Ende testen Sie manuell immer wieder andere Kombinationen. Das manuelle Testen ist viel langsamer, sodass Sie nicht bei jeder Änderung jede mögliche Kombination wiederholen möchten. Dies führt dazu, dass Sie mit größerer Wahrscheinlichkeit etwas verpassen und Zeit damit verschwenden, Pfade zu verfolgen, die nicht in allen Fällen funktionieren.

Abgesehen von der Zeitersparnis im Vergleich zu manuellen Tests ist die mentale Belastung erheblich geringer, sodass Sie sich leichter auf das jeweilige Problem konzentrieren können, ohne sich Gedanken über versehentliche Regressionen machen zu müssen. So können Sie schneller und länger ohne Burnout arbeiten. Meiner Meinung nach sind die Vorteile für die psychische Gesundheit allein die Kosten für das Testen komplexer Codes wert, auch wenn Sie dadurch keine Zeit gespart haben.

Karl Bielefeldt
quelle