Wie strukturieren Sie Komponententests für mehrere Objekte, die dasselbe Verhalten aufweisen?

9

In vielen Fällen habe ich möglicherweise eine vorhandene Klasse mit einem gewissen Verhalten:

class Lion
{
    public void Eat(Herbivore herbivore) { ... }
}

... und ich habe einen Unit Test ...

[TestMethod]
public void Lion_can_eat_herbivore()
{
    var herbivore = buildHerbivoreForEating();
    var test = BuildLionForTest();
    test.Eat(herbivore);
    Assert.IsEaten(herbivore);
}

Was jetzt passiert, ist, dass ich eine Tiger-Klasse mit einem identischen Verhalten wie das des Löwen erstellen muss:

class Tiger
{
    public void Eat(Herbivore herbivore) { ... }
}

... und da ich das gleiche Verhalten möchte, muss ich den gleichen Test ausführen, mache ich so etwas:

interface IHerbivoreEater
{
    void Eat(Herbivore herbivore);
}

... und ich überarbeite meinen Test:

[TestMethod]
public void Lion_can_eat_herbivore()
{
    IHerbivoreEater_can_eat_herbivore(BuildLionForTest);
}


public void IHerbivoreEater_can_eat_herbivore(Func<IHerbivoreEater> builder)
{
    var herbivore = buildHerbivoreForEating();
    var test = builder();
    test.Eat(herbivore);
    Assert.IsEaten(herbivore);
}

... und dann füge ich einen weiteren Test für meine neue TigerKlasse hinzu:

[TestMethod]
public void Tiger_can_eat_herbivore()
{
    IHerbivoreEater_can_eat_herbivore(BuildTigerForTest);
}

... und dann überarbeite ich meine Lionund TigerKlassen (normalerweise durch Vererbung, manchmal aber auch durch Komposition):

class Lion : HerbivoreEater { }
class Tiger : HerbivoreEater { }

abstract class HerbivoreEater : IHerbivoreEater
{
    public void Eat(Herbivore herbivore) { ... }
}

... und alles ist gut. Da sich die Funktionalität jetzt in der HerbivoreEaterKlasse befindet, scheint es nun etwas falsch zu sein, Tests für jedes dieser Verhaltensweisen in jeder Unterklasse durchzuführen. Es sind jedoch die Unterklassen, die tatsächlich verwendet werden, und es ist nur ein Implementierungsdetail, dass sie zufällig überlappende Verhaltensweisen aufweisen ( Lionsund Tigersbeispielsweise völlig unterschiedliche Endverwendungen haben können).

Es scheint überflüssig, denselben Code mehrmals zu testen, aber es gibt Fälle, in denen die Unterklasse die Funktionalität der Basisklasse überschreiben kann und tut (ja, es könnte den LSP verletzen, aber seien wir ehrlich, es IHerbivoreEaterist nur eine praktische Testschnittstelle - es ist ist für den Endbenutzer möglicherweise nicht wichtig). Ich denke, diese Tests haben einen gewissen Wert.

Was machen andere Leute in dieser Situation? Verschieben Sie Ihren Test einfach in die Basisklasse oder testen Sie alle Unterklassen auf das erwartete Verhalten?

EDIT :

Basierend auf der Antwort von @pdr denke ich, dass wir dies berücksichtigen sollten: Das IHerbivoreEaterist nur ein Methodensignaturvertrag; Es gibt kein Verhalten an. Zum Beispiel:

[TestMethod]
public void Tiger_eats_herbivore_haunches_first()
{
    IHerbivoreEater_eats_herbivore_haunches_first(BuildTigerForTest);
}

[TestMethod]
public void Cheetah_eats_herbivore_haunches_first()
{
    IHerbivoreEater_eats_herbivore_haunches_first(BuildCheetahForTest);
}

[TestMethod]
public void Lion_eats_herbivore_head_first()
{
    IHerbivoreEater_eats_herbivore_head_first(BuildLionForTest);
}
Scott Whitlock
quelle
Sollten Sie aus Gründen der Argumentation keine AnimalKlasse haben, die enthält Eat? Alle Tiere fressen, und daher könnte die Klasse Tigerund Lionvom Tier erben.
Der Muffin-Mann
1
@ Nick - das ist ein guter Punkt, aber ich denke, das ist eine andere Situation. Wie @pdr hervorhob, Eatsollten alle Unterklassen dasselbe EatVerhalten aufweisen , wenn Sie das Verhalten in die Basisklasse einfügen . Ich spreche jedoch von zwei relativ unabhängigen Klassen, die zufällig ein Verhalten teilen. Betrachten Sie zum Beispiel das FlyVerhalten von Brickund Persondas, wie wir annehmen können, ein ähnliches Flugverhalten aufweist, aber es ist nicht unbedingt sinnvoll, sie von einer gemeinsamen Basisklasse abzuleiten.
Scott Whitlock

Antworten:

6

Das ist großartig, weil es zeigt, wie Tests wirklich die Art und Weise beeinflussen, wie Sie über Design denken. Sie spüren Probleme im Design und stellen die richtigen Fragen.

Es gibt zwei Möglichkeiten, dies zu betrachten.

IHerbivoreEater ist ein Vertrag. Alle IHerbivoreEater müssen über eine Eat-Methode verfügen, die einen Pflanzenfresser akzeptiert. Nun ist es Ihren Tests egal, wie es gegessen wird; Ihr Löwe könnte mit den Hüften beginnen und Tiger könnte an der Kehle beginnen. Alles, was Ihren Test interessiert, ist, dass der Pflanzenfresser gegessen wird, nachdem er Eat genannt hat.

Auf der anderen Seite sagen Sie unter anderem, dass alle IHerbivoreEater den Pflanzenfresser genauso essen (daher die Basisklasse). In diesem Fall macht es keinen Sinn, überhaupt einen IHerbivoreEater-Vertrag zu haben. Es bietet nichts. Sie können auch einfach von HerbivoreEater erben.

Oder vielleicht Löwe und Tiger komplett abschaffen.

Wenn sich Löwe und Tiger jedoch in jeder Hinsicht von ihren Essgewohnheiten unterscheiden, müssen Sie sich fragen, ob Sie auf Probleme mit einem komplexen Erbbaum stoßen werden. Was ist, wenn Sie auch beide Klassen von Feline oder nur die Lion-Klasse von KingOfItsDomain (vielleicht zusammen mit Shark) ableiten möchten? Hier kommt LSP wirklich ins Spiel.

Ich würde vorschlagen, dass allgemeiner Code besser gekapselt ist.

public class Lion : IHerbivoreEater
{
    private IHerbivoreEatingStrategy _herbivoreEatingStrategy;
    private Lion (IHerbivoreEatingStrategy herbivoreEatingStrategy)
    {
        _herbivoreEatingStrategy = herbivoreEatingStrategy;
    }

    public Lion() : this(new StandardHerbivoreEatingStrategy())
    {
    }

    public void Eat(Herbivore herbivore)
    {
        _herbivoreEatingStrategy.Eat(herbivore);
    }
}

Gleiches gilt für Tiger.

Nun, hier ist eine schöne Sache, die sich entwickelt (schön, weil ich es nicht beabsichtigt habe). Wenn Sie nur diesen privaten Konstruktor für den Test verfügbar machen, können Sie eine gefälschte IHerbivoreEatingStrategy übergeben und einfach testen, ob die Nachricht ordnungsgemäß an das gekapselte Objekt weitergeleitet wird.

Und Ihr komplexer Test, über den Sie sich zuerst Sorgen gemacht haben, muss nur die StandardHerbivoreEatingStrategy testen. Eine Klasse, eine Reihe von Tests, kein doppelter Code, über den man sich Sorgen machen muss.

Und wenn Sie Tigers später mitteilen möchten, dass sie ihre Pflanzenfresser anders essen sollen, muss sich keiner dieser Tests ändern. Sie erstellen einfach eine neue HerbivoreEatingStrategy und testen diese. Die Verkabelung wird auf Integrationstest-Ebene getestet.

pdr
quelle
+1 Das Strategiemuster war das erste, was mir beim Lesen der Frage in den Sinn kam.
StuperUser
Sehr gut, aber jetzt "Unit Test" in meiner Frage durch "Integrationstest" ersetzen. Haben wir nicht das gleiche Problem? IHerbivoreEaterist ein Vertrag, aber nur insoweit, als ich ihn zum Testen brauche. Es scheint mir, dass dies ein Fall ist, in dem das Tippen von Enten wirklich helfen würde. Ich möchte sie jetzt nur noch an dieselbe Testlogik senden. Ich denke nicht, dass die Schnittstelle das Verhalten versprechen sollte. Die Tests sollten das tun.
Scott Whitlock
tolle Frage, tolle Antwort. Sie müssen keinen privaten Konstruktor haben. Sie können IHerbivoreEatingStrategy mithilfe eines IoC-Containers mit StandardHerbivoreEatingStrategy verbinden.
Azheglov
@ScottWhitlock: "Ich denke nicht, dass die Schnittstelle das Verhalten versprechen sollte. Die Tests sollten das tun." Genau das sage ich. Wenn es das Verhalten verspricht, sollten Sie es loswerden und einfach die (Basis-) Klasse verwenden. Sie brauchen es überhaupt nicht zum Testen.
pdr
@azheglov: Stimme zu, aber meine Antwort war schon lang genug :)
pdr
1

Es erscheint überflüssig, denselben Code mehrmals zu testen, aber es gibt Fälle, in denen die Unterklasse die Funktionalität der Basisklasse überschreiben kann und tut

Umgekehrt fragen Sie, ob es angemessen ist, White-Box-Wissen zu verwenden, um einige Tests wegzulassen. Aus einer Black-Box-Perspektive Lionund Tigersind verschiedene Klassen. Jemand, der mit dem Code nicht vertraut ist, würde ihn testen, aber Sie mit tiefem Implementierungswissen wissen, dass Sie mit dem einfachen Testen eines Tieres davonkommen können.

Ein Grund für die Entwicklung von Komponententests besteht darin, dass Sie später eine Umgestaltung vornehmen können, aber dieselbe Black-Box-Schnittstelle beibehalten . Die Unit-Tests helfen Ihnen dabei, sicherzustellen, dass Ihre Klassen ihren Vertrag mit Kunden weiterhin erfüllen, oder zwingen Sie zumindest dazu, zu erkennen und sorgfältig zu überlegen, wie sich der Vertrag ändern könnte. Sie selbst erkennen dies Lionoder können Tigeres Eatzu einem späteren Zeitpunkt überschreiben . Wenn dies aus der Ferne möglich ist, kann ein einfacher Unit-Test durchgeführt werden, den jedes von Ihnen unterstützte Tier auf folgende Weise fressen kann:

[TestMethod]
public void Tiger_can_eat_herbivore()
{
    IHerbivoreEater_can_eat_herbivore(BuildTigerForTest);
}

sollte sehr einfach zu machen sein und ausreichen und wird sicherstellen, dass Sie erkennen können, wenn Objekte ihren Vertrag nicht erfüllen.

Doug T.
quelle
Ich frage mich, ob diese Frage wirklich nur auf eine Präferenz von Black-Box- gegenüber White-Box-Tests hinausläuft. Ich beuge mich zum Black-Box-Camp, weshalb ich vielleicht teste, wie ich bin. Vielen Dank für den Hinweis.
Scott Whitlock
1

Du machst es richtig. Stellen Sie sich einen Komponententest als Test für das Verhalten einer einzelnen Verwendung Ihres neuen Codes vor. Dies ist der gleiche Aufruf, den Sie vom Produktionscode aus ausführen würden.

In dieser Situation haben Sie völlig Recht. Ein Benutzer eines Löwen oder Tigers wird sich (zumindest nicht) nicht darum kümmern müssen, dass sie beide HerbivoreEaters sind und dass der Code, der tatsächlich für die Methode ausgeführt wird, beiden in der Basisklasse gemeinsam ist. Ebenso ist es einem Benutzer eines HerbivoreEater-Abstracts (bereitgestellt vom konkreten Löwen oder Tiger) egal, welches es hat. Was sie interessiert ist, dass ihr Löwe, Tiger oder eine unbekannte konkrete Implementierung von HerbivoreEater einen Pflanzenfresser korrekt frisst ().

Was Sie also im Grunde testen, ist, dass ein Löwe wie beabsichtigt frisst und dass ein Tiger wie beabsichtigt frisst. Es ist wichtig, beide zu testen, da es möglicherweise nicht immer wahr ist, dass beide genau gleich essen. Indem Sie beide testen, stellen Sie sicher, dass derjenige, den Sie nicht ändern wollten, dies nicht tat. Da dies beide definierten HerbivoreEaters sind, haben Sie zumindest bis zum Hinzufügen von Cheetah auch getestet, dass alle HerbivoreEaters wie beabsichtigt essen. Ihre Tests decken den Code vollständig ab und üben ihn angemessen aus (vorausgesetzt, Sie machen auch alle erwarteten Aussagen darüber, was daraus resultieren sollte, wenn ein Pflanzenfresser einen Pflanzenfresser isst).

KeithS
quelle