Comment structurez-vous les tests unitaires pour plusieurs objets qui présentent le même comportement?

9

Dans de nombreux cas, je pourrais avoir une classe existante avec un certain comportement:

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

... et j'ai un test unitaire ...

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

Maintenant, ce qui se passe, c'est que je dois créer une classe Tiger avec un comportement identique à celui du Lion:

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

... et comme je veux le même comportement, je dois faire le même test, je fais quelque chose comme ça:

interface IHerbivoreEater
{
    void Eat(Herbivore herbivore);
}

... et je refactorise mon 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);
}

... puis j'ajoute un autre test pour ma nouvelle Tigerclasse:

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

... puis je refactorise mes classes Lionet Tiger(généralement par héritage, mais parfois par composition):

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

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

... et tout va bien. Cependant, puisque la fonctionnalité est maintenant dans la HerbivoreEaterclasse, il semble maintenant qu'il y ait quelque chose de mal à avoir des tests pour chacun de ces comportements dans chaque sous-classe. Pourtant, ce sont les sous-classes qui sont réellement consommées, et ce n'est qu'un détail de mise en œuvre qui partage des comportements qui se chevauchent ( Lionset Tigerspeuvent avoir des utilisations finales totalement différentes, par exemple).

Il semble redondant de tester le même code plusieurs fois, mais il y a des cas où la sous-classe peut et remplace la fonctionnalité de la classe de base (oui, cela pourrait violer le LSP, mais avouons-le, IHerbivoreEatern'est qu'une interface de test pratique - elle peut ne pas avoir d'importance pour l'utilisateur final). Ces tests ont donc une certaine valeur, je pense.

Que font les autres dans cette situation? Déplacez-vous simplement votre test vers la classe de base ou testez-vous toutes les sous-classes pour le comportement attendu?

MODIFIER :

Sur la base de la réponse de @pdr, je pense que nous devrions considérer ceci: IHerbivoreEaterc'est juste un contrat de signature de méthode; il ne spécifie pas de comportement. Par exemple:

[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
la source
Par souci d'argument, ne devriez-vous pas avoir une Animalclasse qui contient Eat? Tous les animaux mangent, et donc la classe Tigeret Lionpourrait hériter de l'animal.
The Muffin Man
1
@ Nick - c'est un bon point, mais je pense que la situation est différente. Comme l'a souligné @pdr, si vous placez le Eatcomportement dans la classe de base, toutes les sous-classes doivent présenter le même Eatcomportement. Cependant, je parle de 2 classes relativement indépendantes qui partagent un comportement. Considérons, par exemple, le Flycomportement de Bricket Personqui, nous pouvons supposer, présentent un comportement de vol similaire, mais cela n'a pas nécessairement de sens de les faire dériver d'une classe de base commune.
Scott Whitlock

Réponses:

6

C'est formidable car cela montre comment les tests déterminent vraiment votre façon de penser la conception. Vous ressentez des problèmes dans la conception et posez les bonnes questions.

Il y a deux façons d'envisager cela.

IHerbivoreEater est un contrat. Tous les IHerbivoreEaters doivent avoir une méthode Eat qui accepte un Herbivore. Maintenant, vos tests ne se soucient pas de la façon dont il est mangé; votre Lion pourrait commencer par les hanches et Tiger pourrait commencer par la gorge. Tout ce qui compte pour vous, c'est qu'après avoir appelé Eat, l'herbivore est mangé.

D'un autre côté, une partie de ce que vous dites est que tous les IHerbivoreEaters mangent l'herbivore exactement de la même manière (d'où la classe de base). Cela étant, il est inutile d'avoir un contrat IHerbivoreEater. Il n'offre rien. Vous pouvez tout aussi bien hériter de HerbivoreEater.

Ou peut-être éliminer complètement Lion et Tiger.

Mais, si Lion et Tiger sont différents dans tous les sens en dehors de leurs habitudes alimentaires, alors vous devez commencer à vous demander si vous allez rencontrer des problèmes avec un arbre d'héritage complexe. Que se passe-t-il si vous souhaitez également dériver les deux classes de Félin, ou uniquement la classe Lion de KingOfItsDomain (avec Shark, peut-être). C'est là que LSP entre vraiment en jeu.

Je dirais que le code commun est mieux encapsulé.

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);
    }
}

Il en va de même pour Tiger.

Maintenant, voici une belle chose qui se développe (belle parce que je ne le voulais pas). Si vous mettez simplement ce constructeur privé à la disposition du test, vous pouvez passer une fausse IHerbivoreEatingStrategy et simplement tester que le message est correctement transmis à l'objet encapsulé.

Et votre test complexe, celui dont vous vous inquiétiez en premier lieu, n'a qu'à tester la stratégie StandardHerbivoreEatingStrategy. Une classe, un ensemble de tests, pas de code dupliqué à craindre.

Et si, plus tard, vous voulez dire aux tigres qu'ils devraient manger leurs herbivores d'une manière différente, aucun de ces tests ne doit changer. Vous créez simplement une nouvelle stratégie HerbivoreEatingStrategy et vous la testez. Le câblage est testé au niveau du test d'intégration.

pdr
la source
+1 Le modèle de stratégie a été la première chose qui m'est venue à l'esprit en lisant la question.
StuperUser
Très bien, mais remplacez maintenant "test unitaire" dans ma question par "test d'intégration". Ne nous retrouvons-nous pas avec le même problème? IHerbivoreEaterest un contrat, mais uniquement dans la mesure où j'en ai besoin pour les tests. Il me semble que c'est un cas où la frappe de canard serait vraiment utile. Je veux juste les envoyer tous les deux à la même logique de test maintenant. Je ne pense pas que l'interface devrait faire une promesse de comportement. Les tests devraient le faire.
Scott Whitlock
grande question, grande réponse. Vous n'avez pas besoin d'avoir un constructeur privé; vous pouvez câbler IHerbivoreEatingStrategy à StandardHerbivoreEatingStrategy à l'aide d'un conteneur IoC.
azheglov
@ScottWhitlock: "Je ne pense pas que l'interface devrait faire une promesse de comportement. Les tests devraient le faire." C'est exactement ce que je dis. S'il fait une promesse du comportement, vous devez vous en débarrasser et utiliser simplement la classe (de base). Vous n'en avez pas du tout besoin pour les tests.
pdr
@azheglov: D'accord, mais ma réponse était déjà assez longue :)
pdr
1

Il semble redondant de tester le même code plusieurs fois, mais il y a des cas où la sous-classe peut et remplace la fonctionnalité de la classe de base

Dans un sens détourné, vous demandez s'il est approprié d'utiliser les connaissances en boîte blanche pour omettre certains tests. Du point de vue de la boîte noire, Lionet Tigersont des classes différentes. Donc, quelqu'un qui ne connaît pas le code les testerait, mais vous qui avez une connaissance approfondie de l'implémentation, vous savez que vous pouvez vous en tirer avec simplement un animal.

Une partie de la raison du développement de tests unitaires est de vous permettre de refactoriser plus tard, mais de conserver la même interface de boîte noire . Les tests unitaires vous aident à vous assurer que vos classes continuent de respecter leur contrat avec les clients, ou à tout le moins vous obligent à réaliser et à réfléchir soigneusement à la façon dont le contrat pourrait changer. Vous le réalisez vous-même Lionou vous Tigerpourriez l'emporter Eatultérieurement. Si cela est possible à distance, un simple test unitaire testant que chaque animal que vous soutenez peut manger, à la manière de:

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

devrait être très simple à faire et suffisant et vous permettra de détecter quand les objets ne respectent pas leur contrat.

Doug T.
la source
Je me demande si cette question se résume vraiment à une préférence entre les tests en boîte noire et les tests en boîte blanche. Je me penche vers le camp de la boîte noire, c'est peut-être pourquoi je teste ma façon d'être. Merci d'avoir fait remarquer cela.
Scott Whitlock
1

Tu le fais bien. Considérez un test unitaire comme le test du comportement d'une utilisation unique de votre nouveau code. C'est le même appel que vous feriez à partir du code de production.

Dans cette situation, vous avez tout à fait raison; un utilisateur d'un Lion ou d'un Tigre ne se souciera pas (au moins) du fait qu'ils sont tous deux HerbivoreEaters et que le code qui s'exécute réellement pour la méthode est commun aux deux dans la classe de base. De même, un utilisateur d'un résumé HerbivoreEater (fourni par le Lion ou le Tigre concret) ne s'en souciera pas. Ce qui leur importe, c'est que leur implémentation concrète Lion, Tiger ou inconnue de HerbivoreEater mange () correctement un herbivore.

Donc, ce que vous testez essentiellement, c'est qu'un Lion mangera comme prévu, et qu'un Tigre mangera comme prévu. Il est important de tester les deux, car il n'est pas toujours vrai qu'ils mangent tous les deux de la même manière; en testant les deux, vous vous assurez que celui que vous ne vouliez pas changer n'a pas changé. Parce que ce sont les deux HerbivoreEaters définis, au moins jusqu'à ce que vous ajoutiez Cheetah, vous avez également testé que tous les HerbivoreEaters mangeront comme prévu. Vos tests couvrent complètement et exercent correctement le code (à condition que vous fassiez également toutes les affirmations attendues de ce qui devrait résulter du fait qu'un HerbivoreEater mange un Herbivore).

KeithS
la source