web-dev-qa-db-fra.com

Quelle est la bonne approche pour tester des classes avec héritage?

En supposant que j'ai la structure de classe (trop simplifiée) suivante:

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

Comme vous pouvez le voir, la fonction doThings dans la classe de base renvoie des résultats différents dans chaque classe dérivée en raison des différentes valeurs de foo , tandis que la fonction doOtherThings se comporte de manière identique dans toutes les classes.

Lorsque je souhaite implémenter des tests unitaires pour ces classes, la gestion de doThings, doBarThings/doBazThings est claire pour moi - ils doivent être couverts pour chaque classe dérivée. Mais comment gérer doOtherThings? Est-ce une bonne pratique de dupliquer essentiellement le cas de test dans les deux classes dérivées? Le problème s'aggrave s'il existe une demi-douzaine de fonctions comme doOtherThings, et plus classes dérivées.

8
CharonX

Dans vos tests pour BarDerived, vous voulez prouver que toutes les méthodes (publiques) de BarDerived fonctionnent correctement (pour les situations que vous avez testées). De même pour BazDerived.

Le fait que certaines des méthodes soient implémentées dans une classe de base ne change pas cet objectif de test pour BarDerived et BazDerived. Cela conduit à la conclusion que Base::doOtherThings doit être testé à la fois dans le contexte de BarDerived et BazDerived et que vous obtenez des tests très similaires pour cette fonction.

L'avantage de tester doOtherThings pour chaque classe dérivée est que si les exigences pour BarDerived changent de telle sorte que BarDerived::doOtherThings doit renvoyer 24, puis l'échec du test dans le cas de test BazDerived vous indique que vous ne respectez peut-être pas les exigences d'une autre classe.

3

Mais comment les autres devraient-ils être traités? Est-ce une bonne pratique de dupliquer essentiellement le cas de test dans les deux classes dérivées?

Je m'attendrais normalement à ce que Base ait sa propre spécification, que vous pouvez vérifier pour toute implémentation conforme, y compris les classes dérivées.

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()); }
3
VoiceOfUnreason

Vous avez un conflit ici.

Vous voulez tester la valeur de retour de doThings(), qui repose sur un littéral (valeur const).

Tout test que vous écrivez pour cela va par nature se résumer tester une valeur const, ce qui est absurde.


Pour vous montrer un exemple plus sensé (je suis plus rapide avec C #, mais le principe est le même)

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

Cette classe peut être testée de manière significative:

var inputValue = 123;

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

Assert.AreEqual(receivedOutputValue, expectedOutputValue);

Cela a plus de sens à tester. Sa sortie est basée sur l'entrée que vous avez choisie pour lui donner. Dans un tel cas, vous pouvez donner à une classe une entrée choisie arbitrairement, observer sa sortie et tester si elle correspond à vos attentes.

Quelques exemples de ce principe de test. Notez que mes exemples ont toujours un contrôle direct sur l'entrée de la méthode testable.

  • Testez si GetFirstLetterOfString() retourne "F" lorsque j'entre "Flater".
  • Testez si CountLettersInString() renvoie 6 lorsque j'entre "Flater".
  • Testez si ParseStringThatBeginsWithAnA() retourne une exception lorsque j'entre "Flater".

Tous ces tests peuvent entrer quelle que soit la valeur qu'ils souhaitent , tant que leurs attentes sont conformes à ce qu'ils saisissent.

Mais si votre sortie est décidée par une valeur constante, vous devrez créer une attente constante, puis tester si la première correspond à la seconde. Ce qui est idiot, c'est soit toujours ou jamais va passer; ni l'un ni l'autre n'est un résultat significatif.

Quelques exemples de ce principe de test. Notez que ces exemples n'ont aucun contrôle sur au moins une des valeurs qui sont comparées.

  • Testez si Math.Pi == 3.1415...
  • Testez si MyApplication.ThisConstValue == 123

Ces tests pour un valeur particulière. Si vous modifiez cette valeur, vos tests échoueront. En substance, vous ne testez pas si votre logique fonctionne pour une entrée valide, vous testez simplement si quelqu'un est capable de - prédire avec précision un résultat sur lequel il n'a aucun contrôle.

Il s'agit essentiellement de tester les connaissances de l'auteur de tests sur la logique métier. Il ne s'agit pas de tester le code, mais l'auteur lui-même.


Revenant à votre exemple:

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

Pourquoi BarDerived a-t-il toujours un foo égal à 12? Qu'est-ce que cela veut dire?

Et étant donné que vous l'avez déjà décidé, qu'essayez-vous de gagner en écrivant un test qui confirme que BarDerived a toujours un foo égal à 12?

Cela devient encore pire si vous commencez à prendre en compte le fait que doThings() peut être remplacé dans une classe dérivée. Imaginez si AnotherDerived devait remplacer doThings() pour qu'il renvoie toujours foo * 2. Maintenant, vous allez avoir une classe qui est codée en dur comme Base(12), dont la valeur doThings() est 24. Bien que techniquement testable, elle est dépourvue de toute signification contextuelle. Le test n'est pas compréhensible.

Je ne peux vraiment pas penser à une raison d'utiliser cette approche de valeur codée en dur. Même s'il existe un cas d'utilisation valide, je ne comprends pas pourquoi vous essayez d'écrire un test pour confirmer cette valeur codée en dur. Il n'y a rien à gagner à tester si une valeur constante est égale à la même valeur constante.

Tout échec de test prouve intrinsèquement que le test est faux. Il n'y a aucun résultat où un échec de test prouve que la logique métier est erronée. Vous n'êtes effectivement pas en mesure de confirmer ce que les tests sont créés pour confirmer en premier lieu.

La question n'a rien à voir avec l'héritage, au cas où vous vous poseriez la question. Il vous arrive d'avoir utilisé une valeur const dans le constructeur de la classe de base, mais vous auriez pu utiliser cette valeur const ailleurs et cela ne être lié à une classe héritée.


Modifier

Il y a des cas où les valeurs codées en dur ne sont pas un problème. (encore une fois, désolé pour la syntaxe C # mais le principe est toujours le même)

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

Alors que la valeur constante (2/3) Influence toujours la valeur de sortie de GetMultipliedValue(), le consommateur de votre classe a toujours contrôle aussi!
Dans cet exemple, des tests significatifs peuvent encore être écrits:

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);
  • Techniquement, nous écrivons toujours un test qui vérifie si la constante dans base(value, 2) correspond à la constante dans inputValue * 2.
  • Cependant, nous testons également en même temps que cette classe est correctement multipliant toute valeur donnée par ce facteur prédéterminé.

Le premier point n'est pas pertinent à tester. Le deuxième est!

1
Flater