Come si strutturano i test unitari per più oggetti che presentano lo stesso comportamento?

9

In molti casi potrei avere una classe esistente con qualche comportamento:

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

... e ho un test unitario ...

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

Ora, quello che succede è che ho bisogno di creare una classe Tiger con un comportamento identico a quello del Lion:

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

... e dal momento che voglio lo stesso comportamento, ho bisogno di eseguire lo stesso test, faccio qualcosa del genere:

interface IHerbivoreEater
{
    void Eat(Herbivore herbivore);
}

... e refripto il mio 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);
}

... e poi aggiungo un altro test per la mia nuova Tiger class:

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

... e poi refacifico le mie classi Lion e Tiger (di solito per ereditarietà, ma a volte per composizione):

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

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

... e tutto va bene. Tuttavia, poiché la funzionalità è ora nella classe HerbivoreEater , ora sembra che ci sia qualcosa di sbagliato nell'avere test per ciascuno di questi comportamenti su ciascuna sottoclasse. Eppure sono le sottoclassi che vengono effettivamente consumate, ed è solo un dettaglio di implementazione che condividono comportamenti sovrapposti ( Lions e Tigers potrebbero avere usi finali completamente diversi, per esempio).

Sembra ridondante testare lo stesso codice più volte, ma ci sono casi in cui la sottoclasse può e sovrascrive la funzionalità della classe base (sì, potrebbe violare l'LSP, ma lasciamolo affrontare, IHerbivoreEater è solo una comoda interfaccia di test - potrebbe non essere importante per l'utente finale). Quindi questi test hanno un certo valore, penso.

Cosa fanno gli altri in questa situazione? Spostate semplicemente il test sulla classe base o testate tutte le sottoclassi per il comportamento previsto?

Modifica :

Sulla base della risposta di @pdr, dovremmo considerare questo: il IHerbivoreEater è solo un contratto di firma del metodo; non specifica il comportamento. Ad esempio:

[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);
}
    
posta Scott Whitlock 01.11.2011 - 18:49
fonte

3 risposte

6

Questo è fantastico perché mostra come i test guidino davvero il modo in cui pensi al design. Stai riscontrando problemi nel design e fai le domande giuste.

Ci sono due modi per osservarlo.

IHerbivoreEater è un contratto. Tutti gli utenti di IHerbivore devono avere un metodo Eat che accetti un erbivoro. Ora, ai tuoi test non importa come viene mangiato; il tuo Leone potrebbe iniziare con le cosce e Tiger potrebbe iniziare alla gola. Tutto ciò di cui ti preoccupi è che dopo aver chiamato Eat, l'erbivoro viene mangiato.

D'altro canto, parte di quello che stai dicendo è che tutti gli abitanti di IHerbivore mangiano l'erbivoro esattamente allo stesso modo (da qui la classe base). Stando così le cose, non ha senso avere un contratto di IHerbivoreEater. Non offre nulla. Puoi anche ereditare da HerbivoreEater.

O forse elimina completamente Lion e Tiger.

Tuttavia, se Lion e Tiger sono diversi in tutti i sensi, a parte le loro abitudini alimentari, è necessario iniziare a chiedersi se si incontreranno problemi con un albero di eredità complesso. Che cosa succede se vuoi anche ricavare entrambe le classi da Feline, o solo la classe Lion da KingOfItsDomain (insieme a Shark, forse). È qui che arriva veramente LSP.

Suggerirei che il codice comune sia meglio incapsulato.

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

Lo stesso vale per Tiger.

Ora, ecco una bella cosa in via di sviluppo (bellissima perché non l'ho intesa). Se rendi disponibile quel costruttore privato per il test, puoi inoltrare un falso IHerbivoreEatingStrategy e semplicemente verificare che il messaggio sia passato correttamente all'oggetto incapsulato.

E il tuo test complesso, quello di cui ti stavi preoccupando in primo luogo, deve solo testare StandardHerbivoreEatingStrategy. Una classe, un set di test, nessun codice duplicato di cui preoccuparsi.

E se, più tardi, vuoi dire a Tigers che dovrebbero mangiare i loro erbivori in un modo diverso, nessuno di questi test deve cambiare. Stai semplicemente creando una nuova HerbivoreEatingStrategy e testandola. Il cablaggio è testato a livello di test di integrazione.

    
risposta data 01.11.2011 - 20:00
fonte
1

It seems redundant to test the same code multiple times, but there are cases where the subclass can and does override the functionality of the base class

In un round round stai chiedendo se è appropriato usare la conoscenza della white-box per omettere alcuni test. Da una prospettiva black-box, Lion e Tiger sono classi diverse. Quindi qualcuno senza familiarità con il codice li testerebbe, ma tu con una profonda conoscenza dell'implementazione sai che puoi cavartela semplicemente testando un animale.

Parte del motivo per lo sviluppo dei test unitari è quello di permettere a te stesso di effettuare il refactoring in seguito, ma mantenere la stessa interfaccia black-box . I test unitari ti aiutano a garantire che le lezioni continuino a rispettare il loro contratto con i clienti o, per lo meno, a costringerti a realizzare e riflettere attentamente su come il contratto potrebbe cambiare. Ti rendi conto che Lion o Tiger potrebbero sovrascrivere Eat in un momento successivo. Se ciò è possibile da remoto, un semplice test unitario che verifica che ogni animale che sostieni possa mangiare, nel modo di:

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

dovrebbe essere molto semplice da fare e sufficiente e farà in modo che tu possa rilevare quando gli oggetti non riescono a rispettare il loro contratto.

    
risposta data 01.11.2011 - 20:01
fonte
1

Lo stai facendo bene. Pensa a un test unitario come test del comportamento di un singolo utilizzo del tuo nuovo codice. Questa è la stessa chiamata che faresti dal codice di produzione.

In questa situazione, sei completamente corretto; un utente di un Lion o Tiger non lo farà (non dovrà, almeno) preoccuparsi che siano entrambi HerbivoreEaters e che il codice che viene effettivamente eseguito per il metodo sia comune a entrambi nella classe base. Allo stesso modo, un utente di un abstract di HerbivoreEater (fornito da Lion o Tiger concreto) non si preoccuperà di ciò che ha. Ciò che a loro interessa è che il loro Lion, Tiger o implementazione concreta sconosciuta di HerbivoreEater mangi () correttamente un erbivoro.

Quindi, quello che stai fondamentalmente testando è che un leone mangerà come previsto, e che una tigre mangia come previsto. È importante testarli entrambi, perché potrebbe non essere sempre vero che entrambi mangiano esattamente allo stesso modo; testando entrambi, ti assicuri che quello che non volevi cambiare, no. Poiché questi sono entrambi gli HerbivoreEaters definiti, almeno fino a quando non aggiungi Cheetah hai anche provato che tutti gli HerbivoreEaters mangiano come previsto. Il tuo test copre completamente ed esercita adeguatamente il codice (a patto che tu faccia anche tutte le asserzioni attese su cosa dovrebbe derivare da un HerbivoreEater che mangia un erbivoro).

    
risposta data 01.11.2011 - 20:03
fonte

Leggi altre domande sui tag