Was ist der richtige Ansatz, um Klassen mit Vererbung zu testen?


8

Angenommen, ich habe die folgende (stark vereinfachte) Klassenstruktur:

class Base
{
  public:
    Base(int valueForFoo) : foo(valueForFoo) { };
    virtual ~Base() = 0;
    int doThings() { return foo; };
    int doOtherThings() { return 42; };

  protected:
    int foo;
}

class BarDerived : public Base
{
  public:
    BarDerived() : Base(12) { };
    ~BarDerived() { };
    int doBarThings() { return foo + 1; };
}

class BazDerived : public Base
{
  public:
    BazDerived() : Base(25) { };
    ~BazDerived() { };
    int doBazThings() { return 2 * foo; };
}

Wie Sie sehen können, die doThingsFunktion in der Basisklasse zu unterschiedlichen Ergebnissen in jeder gibt abgeleiteten Klasse aufgrund der unterschiedlichen Werte foo, während die doOtherThingsFunktion verhält sich gleich über alle Klassen .

Wenn ich Unit-Tests für diese Klassen implementieren möchte doThings, ist mir die Handhabung von doBarThings/ doBazThingsklar - sie müssen für jede abgeleitete Klasse abgedeckt werden. Aber wie soll doOtherThingsdamit umgegangen werden? Ist es empfehlenswert, den Testfall in beiden abgeleiteten Klassen im Wesentlichen zu duplizieren? Das Problem wird schlimmer, wenn es ein halbes Dutzend Funktionen wie doOtherThingsund mehr abgeleitete Klassen gibt .


Sind Sie sicher, dass nur der dtor sein sollte virtual?
Deduplikator

@Deduplicator Zur Vereinfachung des Beispiels ja. Die Basisklasse ist / sollte abstrakt sein, wobei die abgeleiteten Klassen zusätzliche Funktionen oder spezielle Implementierungen bereitstellen. BarDerivedund Basekann einmal die gleiche Klasse gewesen sein. Wenn eine ähnliche Funktionalität hinzugefügt werden sollte, wurde der gemeinsame Teil in die Basisklasse verschoben, wobei in jeder abgeleiteten Klasse unterschiedliche Spezialisierungen implementiert wurden.
CharonX

Stellen Sie sich in einem weniger abstrakten Beispiel eine Klasse vor, die standardkonformes HTML schreibt, aber dann wurde entschieden, dass es neben der "Vanilla" -Implementierung (die einige Dinge anders macht und tut) auch möglich sein sollte, für <Browser> optimiertes HTML zu schreiben unterstützt nicht alle vom Standard bereitgestellten Funktionen). (Hinweis: Ich bin erleichtert zu sagen, dass die realen Klassen, die zu diesen Fragen führen, nichts mit dem Schreiben von "
browseroptimiertem

Antworten:


3

In Ihren Tests BarDerivedmöchten Sie nachweisen, dass alle (öffentlichen) Methoden BarDerivedkorrekt funktionieren (für die von Ihnen getesteten Situationen). Ähnliches gilt für BazDerived.

Die Tatsache, dass einige der Methoden in einer Basisklasse implementiert sind, ändert nichts an diesem Testziel für BarDerivedund BazDerived. Das führt zu dem Schluss , dass Base::doOtherThingssowohl im Rahmen getestet werden soll BarDerivedund , BazDerivedund dass Sie bekommen sehr Ähnliche Tests für diese Funktion.

Der Vorteil des Testens doOtherThingsfür jede abgeleitete Klasse besteht darin, dass der Testfehler im Testfall darauf hinweist, dass Sie möglicherweise gegen die Anforderungen einer anderen Klasse verstoßen , wenn die Anforderungen für BarDerivedÄnderungen so sind, dass BarDerived::doOtherThings24 zurückgegeben werden müssen BazDerived.


2
Und nichts hindert Sie daran, Duplikate zu reduzieren, indem Sie den gemeinsamen Testcode in eine einzige Funktion zerlegen, die von beiden separaten Testfällen aufgerufen wird.
Sean Burton

1
IMHO wird diese ganze Antwort nur dann gut, wenn Sie den Kommentar von @ SeanBurton klar hinzufügen, sonst sieht es für mich nach einer offensichtlichen Verletzung des DRY-Prinzips aus.
Doc Brown

3

Aber wie soll man mit anderen Dingen umgehen? Ist es empfehlenswert, den Testfall in beiden abgeleiteten Klassen im Wesentlichen zu duplizieren?

Normalerweise würde ich erwarten, dass Base eine eigene Spezifikation hat, die Sie für jede konforme Implementierung überprüfen können, einschließlich der abgeleiteten Klassen.

void verifyBaseCompliance(const Base & systemUnderTest) {
    // checks that systemUnderTest conforms to the Base API
    // specification
}

void testBase () { verifyBaseCompliance(new Base()); }
void testBar () { verifyBaseCompliance(new BarDerived()); }
void testBaz () { verifyBaseCompliance(new BazDerived()); }

1

Sie haben hier einen Konflikt.

Sie möchten den Rückgabewert von testen doThings(), der auf einem Literal (const-Wert) basiert.

Jeder Test, den Sie dafür schreiben, führt dazu, dass ein konstanter Wert getestet wird , was unsinnig ist.


Um Ihnen ein sinnlicheres Beispiel zu zeigen (ich bin schneller mit C #, aber das Prinzip ist das gleiche)

public class TriplesYourInput : Base
{
    public TriplesYourInput(int input)
    {
        this.foo = 3 * input;
    }
}

Diese Klasse kann sinnvoll getestet werden:

var inputValue = 123;

var expectedOutputValue = inputValue * 3;
var receivedOutputValue = new TriplesYourInput(inputValue).doThings();

Assert.AreEqual(receivedOutputValue, expectedOutputValue);

Das Testen ist sinnvoller. Sein Ausgang ist auf den Eingang aus , dass Sie gewählt haben , es zu geben. In einem solchen Fall können Sie einer Klasse eine willkürlich ausgewählte Eingabe geben, ihre Ausgabe beobachten und testen, ob sie Ihren Erwartungen entspricht.

Einige Beispiele für dieses Testprinzip. Beachten Sie, dass meine Beispiele immer die direkte Kontrolle über die Eingabe der testbaren Methode haben.

  • Testen Sie, ob GetFirstLetterOfString()"F" zurückgegeben wird, wenn ich "Flater" eingebe.
  • Testen Sie, ob CountLettersInString()bei Eingabe von "Flater" 6 zurückgegeben wird.
  • Testen Sie, ob ParseStringThatBeginsWithAnA()bei der Eingabe von "Flater" eine Ausnahme zurückgegeben wird.

Alle diese Tests können jeden gewünschten Wert eingeben , solange ihre Erwartungen mit den eingegebenen übereinstimmen .

Wenn Ihre Ausgabe jedoch durch einen konstanten Wert bestimmt wird, müssen Sie eine konstante Erwartung erstellen und dann testen, ob die erste mit der zweiten übereinstimmt. Was albern ist, das wird entweder immer oder nie passieren; Beides ist kein aussagekräftiges Ergebnis.

Einige Beispiele für dieses Testprinzip. Beachten Sie, dass diese Beispiele keine Kontrolle über mindestens einen der zu vergleichenden Werte haben.

  • Testen Sie, ob Math.Pi == 3.1415...
  • Testen Sie, ob MyApplication.ThisConstValue == 123

Diese Tests für einen bestimmten Wert. Wenn Sie diesen Wert ändern, schlagen Ihre Tests fehl. Im Wesentlichen testen Sie nicht, ob Ihre Logik für eine gültige Eingabe funktioniert, sondern nur, ob jemand in der Lage ist , ein Ergebnis genau vorherzusagen , über das er keine Kontrolle hat.

Das testet im Wesentlichen das Wissen des Testautors über die Geschäftslogik. Es wird nicht der Code getestet, sondern der Autor selbst.


Zurück zu Ihrem Beispiel:

class BarDerived : public Base
{
  public:
    BarDerived() : Base(12) { };
    ~BarDerived() { };
    int doBarThings() { return foo + 1; };
}

Warum hat BarDerivedimmer ein foogleich 12? Was ist die Bedeutung davon?

Und da Sie dies bereits entschieden haben, was versuchen Sie zu erreichen, indem Sie einen Test schreiben, der bestätigt, dass er BarDerivedimmer foogleich ist 12?

Dies wird noch schlimmer, wenn Sie anfangen zu berücksichtigen, dass doThings()dies in einer abgeleiteten Klasse überschrieben werden kann. Stellen Sie AnotherDerivedsich vor, Sie würden überschreiben, doThings()damit es immer zurückkehrt foo * 2. Jetzt haben Sie eine Klasse, die fest codiert ist Base(12)und deren doThings()Wert 24 ist. Obwohl sie technisch testbar ist, hat sie keine kontextbezogene Bedeutung. Der Test ist nicht nachvollziehbar.

Ich kann mir wirklich keinen Grund vorstellen, diesen fest codierten Wertansatz zu verwenden. Selbst wenn es einen gültigen Anwendungsfall gibt, verstehe ich nicht, warum Sie versuchen, einen Test zu schreiben, um diesen fest codierten Wert zu bestätigen . Es gibt nichts zu gewinnen, wenn getestet wird, ob ein konstanter Wert dem gleichen konstanten Wert entspricht.

Jeder Testfehler beweist von Natur aus, dass der Test falsch ist . Es gibt kein Ergebnis, bei dem ein Testfehler beweist, dass die Geschäftslogik falsch ist. Sie können effektiv nicht bestätigen, welche Tests erstellt wurden, um sie überhaupt zu bestätigen.

Das Problem hat nichts mit Vererbung zu tun, falls Sie sich fragen. Sie haben zufällig einen const-Wert im Basisklassenkonstruktor verwendet, aber Sie hätten diesen const-Wert an einer anderen Stelle verwenden können, und dann wäre er nicht mit einer geerbten Klasse verbunden.


Bearbeiten

Es gibt Fälle, in denen fest codierte Werte kein Problem darstellen. (Nochmals, entschuldigen Sie die C # -Syntax, aber das Prinzip ist immer noch dasselbe)

public class Base
{
    public int MultiplyFactor;
    protected int InitialValue;

    public Base(int value, int factor)
    {
        this.InitialValue = value;
        this.MultiplyFactor= factor;
    }

    public int GetMultipliedValue()
    {
         return this.InitialValue * this.MultiplyFactor;
    }
}

public class DoublesYourNumber : Base
{
    public DoublesYourNumber(int value) :  base(value, 2) {}
}

public class TriplesYourNumber : Base
{
    public TriplesYourNumber(int value) : base(value, 3) {}
}

Während der konstante Wert ( 2/ 3) noch den Ausgabewert von beeinflusst GetMultipliedValue(), hat der Verbraucher Ihrer Klasse auch noch die Kontrolle darüber!
In diesem Beispiel können noch aussagekräftige Tests geschrieben werden:

var inputValue = 123;

var expectedDoubledOutputValue = inputValue * 2;
var receivedDoubledOutputValue = new DoublesYourNumber(inputValue).GetMultipliedValue();

Assert.AreEqual(expectedDoubledOutputValue , receivedDoubledOutputValue);

var expectedTripledOutputValue = inputValue * 3;
var receivedTripledOutputValue = new TriplesYourNumber(inputValue).GetMultipliedValue();

Assert.AreEqual(expectedTripledOutputValue , receivedTripledOutputValue);
  • Technisch gesehen schreiben wir immer noch einen Test, der prüft, ob die const in base(value, 2)mit der const in übereinstimmt inputValue * 2.
  • Gleichzeitig testen wir jedoch auch, ob diese Klasse einen bestimmten Wert korrekt mit diesem vorgegebenen Faktor multipliziert .

Der erste Aufzählungspunkt ist für den Test nicht relevant. Der zweite ist!


Wie bereits erwähnt, handelt es sich hierbei um eine stark vereinfachte Klassenstruktur. Lassen Sie mich dennoch auf das vielleicht weniger abstrakte Beispiel verweisen: Stellen Sie sich eine HTML-Schreibklasse vor. Wir alle kennen diesen Standard <und die >Klammern, die die HTML-Tags kapseln. Leider (aufgrund von Wahnsinn) verwendet ein "spezialisierter" Browser ![{und }]!stattdessen und Intitech entscheidet, dass Sie diesen Browser in Ihrem HTML-Writer unterstützen müssen. Zum Beispiel haben Sie die getHeaderStart()und getHeaderEnd()Funktion, die - bis jetzt - zurückgegeben hat <HEADER>und <\HEADER>.
CharonX

@CharonX Sie müssten den Tag-Typ (ob durch eine Aufzählung oder zwei Zeichenfolgeneigenschaften für die verwendeten Tags oder etwas Äquivalentes) öffentlich konfigurierbar machen, um sinnvoll zu testen, ob die Klasse die Tags korrekt verwendet. Wenn Sie dies nicht tun, werden Ihre Tests mit undokumentierten konstanten Werten übersät sein, die erforderlich sind, damit der Test funktioniert.
Flater

Sie können die Klasse und Funktionen ändern, indem Sie einfach alles kopieren und einfügen - einmal mit <, das andere mit ![{. Das wäre aber ziemlich schlimm. So haben Sie jede Funktion legen Sie die Stelle (n) Satz in einer Variablen in den Orten <und >gehen würden, und abgeleitete Klassen erstellen , die - je nachdem , ob sie standardkonforme oder verrückt sind, bietet die sachgemäßen <HEADER>oder ![{HEADER}]!Weder getHeaderStart()Funktion ist bei der Eingabe abhängig und setzt auf die Konstante gesetzt während der Konstruktion der abgeleiteten Klasse. Trotzdem würde ich mich
unwohl

@CharonX Das ist nicht der Punkt. Der Punkt ist, dass Ihre Ausgabe (die offensichtlich entscheidet, ob der Test erfolgreich ist) auf einem Wert basiert, über den der Test selbst keine Kontrolle hat. Andernfalls sollten Sie einen Ausgabewert testen, der nicht auf dieser versteckten Konstante basiert. Ihr Beispielcode testet diesen const-Wert, nichts anderes. Dies ist entweder falsch oder zu stark vereinfacht, bis der tatsächliche Zweck der Ausgabe nicht mehr angezeigt wird.
Flater

Ist Testen getHeaderStart()sinnvoll oder nicht?
CharonX
Durch die Nutzung unserer Website bestätigen Sie, dass Sie unsere Cookie-Richtlinie und Datenschutzrichtlinie gelesen und verstanden haben.
Licensed under cc by-sa 3.0 with attribution required.