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


9

In molti casi potrei avere una classe esistente con un certo 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, ciò che succede è che devo creare una classe Tiger con un comportamento identico a quello del Lion:

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

... e poiché voglio lo stesso comportamento, devo eseguire lo stesso test, faccio qualcosa del genere:

interface IHerbivoreEater
{
    void Eat(Herbivore herbivore);
}

... e rifatto 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 Tigerclasse:

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

... e poi refactoring my Lione Tigerclass (di solito per eredità, 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 HerbivoreEaterclasse, ora sembra che ci sia qualcosa di sbagliato nell'avere test per ciascuno di questi comportamenti su ogni sottoclasse. Eppure sono le sottoclassi che vengono effettivamente consumate ed è solo un dettaglio dell'implementazione che condividono comportamenti sovrapposti ( Lionse Tigerspossono avere usi finali totalmente diversi, per esempio).

Sembra ridondante testare lo stesso codice più volte, ma ci sono casi in cui la sottoclasse può e sostituisce la funzionalità della classe base (sì, potrebbe violare l'LSP, ma ammettiamolo, 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? Sposta semplicemente il test nella classe base o verifichi tutte le sottoclassi per il comportamento previsto?

MODIFICA :

Sulla base della risposta di @pdr penso che dovremmo considerare questo: si IHerbivoreEatertratta solo di un contratto di firma del metodo; non specifica il comportamento. Per 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);
}

Per ragioni di argomento, non dovresti avere una Animalclasse che contiene Eat? Tutti gli animali mangiano, e quindi la classe Tigere Lionpotrebbe ereditare dall'animale.
The Muffin Man,

1
@ Nick - questo è un buon punto, ma penso che sia una situazione diversa. Come sottolineato da @pdr, se si inserisce il Eatcomportamento nella classe base, tutte le sottoclassi dovrebbero mostrare lo stesso Eatcomportamento. Tuttavia, sto parlando di 2 classi relativamente non correlate che condividono un comportamento. Si consideri, ad esempio, il Flycomportamento Bricke Personche, possiamo supporre, esibiscono un comportamento di volo simile, ma non ha necessariamente senso che derivino da una classe base comune.
Scott Whitlock,

Risposte:


6

Questo è fantastico perché mostra come i test guidano veramente il modo in cui pensi al design. Stai avvertendo problemi nella progettazione e ponendo le domande giuste.

Ci sono due modi di vedere questo.

IHerbivoreEater è un contratto. Tutti i IHerbivoreEaters devono avere un metodo Eat che accetta un Herbivore. Ora, i tuoi test non si preoccupano di come viene mangiato; il tuo leone potrebbe iniziare con le cosce e la tigre potrebbe iniziare alla gola. Tutto il tuo test si preoccupa è che dopo aver chiamato Eat, l'erbivoro viene mangiato.

D'altra parte, parte di ciò che stai dicendo è che tutti i IHerbivoreEaters mangiano l'Erbivoro esattamente allo stesso modo (da qui la classe base). Stando così le cose, non ha senso avere un contratto IHerbivoreEater. Non offre nulla. Puoi anche ereditare da HerbivoreEater.

O forse eliminare completamente Lion e Tiger.

Ma se Lion e Tiger sono diversi in tutti i sensi, a parte le loro abitudini alimentari, allora devi iniziare a chiederti se incontrerai problemi con un albero ereditario complesso. Che cosa succede se vuoi anche derivare entrambe le classi da Feline o solo la classe Lion da KingOfItsDomain (insieme a Shark, forse). È qui che entra davvero in gioco 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 cosa bella in via di sviluppo (bella perché non lo intendevo). Se rendi disponibile quel costruttore privato per il test, puoi passare un falso IHerbivoreEatingStrategy e testare semplicemente che il messaggio viene passato correttamente all'oggetto incapsulato.

E il tuo test complesso, quello di cui ti preoccupavi in ​​primo luogo, deve solo testare la StandardHerbivoreEatingStrategy. Una classe, una serie di test, nessun codice duplicato di cui preoccuparsi.

E se, in seguito, vuoi dire alle Tigri che dovrebbero mangiare i loro erbivori in modo diverso, nessuno di questi test deve cambiare. Stai semplicemente creando un nuovo HerbivoreEatingStrategy e testandolo. Il cablaggio è testato a livello di test di integrazione.


+1 Il modello di strategia è stata la prima cosa che mi è venuta in mente leggendo la domanda.
StuperUser

Molto buono, ma ora sostituisco "unit test" nella mia domanda con "test di integrazione". Non finiamo con lo stesso problema? IHerbivoreEaterè un contratto, ma solo in quanto ne ho bisogno per i test. Mi sembra che questo sia un caso in cui la digitazione di anatre sarebbe davvero di aiuto. Voglio solo inviarli entrambi alla stessa logica di test ora. Non credo che l'interfaccia dovrebbe promettere il comportamento. I test dovrebbero farlo.
Scott Whitlock,

ottima domanda, ottima risposta. Non devi avere un costruttore privato; puoi collegare IHerbivoreEatingStrategy a StandardHerbivoreEatingStrategy utilizzando un contenitore IoC.
Azheglov,

@ScottWhitlock: "Non credo che l'interfaccia dovrebbe promettere il comportamento. I test dovrebbero farlo." Questo è esattamente quello che sto dicendo. Se promette il comportamento, dovresti liberartene e usare solo la classe (base-). Non è necessario per il test.
pdr

@azheglov: d'accordo, ma la mia risposta era già abbastanza lunga :)
pdr

1

Sembra ridondante testare lo stesso codice più volte, ma ci sono casi in cui la sottoclasse può e sostituisce la funzionalità della classe base

In un certo senso ti stai chiedendo se è appropriato usare la conoscenza del white box per omettere alcuni test. Dal punto di vista della scatola nera Lione Tigersono 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 di unit test è consentire a te stesso di effettuare il refactoring in un secondo momento ma mantenere la stessa interfaccia black-box . I test unitari ti stanno aiutando a garantire che le tue classi continuino a rispettare il loro contratto con i clienti, o per lo meno ti costringono a realizzare e riflettere attentamente su come il contratto potrebbe cambiare. Tu stesso lo capisci Liono Tigerpotresti scavalcare Eatin un secondo momento. Se ciò è possibile in remoto, un semplice test unitario che ogni animale che supporti può 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 assicurerà che sia possibile rilevare quando gli oggetti non riescono a soddisfare il loro contratto.


Mi chiedo se questa domanda si riduce davvero a una preferenza tra i test black-box e white-box. Mi sposto verso il campo della scatola nera, forse è per questo che sto testando come sono. Grazie per la segnalazione.
Scott Whitlock,

1

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

In quella situazione, hai completamente ragione; un utente di un Lion o Tiger non si preoccuperà (almeno non) di essere entrambi HerbivoreEaters e che il codice che viene effettivamente eseguito per il metodo è comune a entrambi nella classe base. Allo stesso modo, un utente di un estratto HerbivoreEater (fornito dal Lion o Tiger concreto) non si preoccuperà di ciò che ha. Quello a cui tengono è che il loro Leone, Tigre o l'implementazione concreta sconosciuta di HerbivoreEater mangeranno correttamente () un Herbivore.

Quindi, ciò che stai sostanzialmente testando è che un leone mangerà come previsto e che una tigre mangerà come previsto. È importante testare 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é entrambi sono gli HerbivoreEaters definiti, almeno fino a quando non si aggiunge il ghepardo, è stato anche verificato che tutti gli HerbivoreEaters mangeranno come previsto. I tuoi test coprono completamente ed esercitano adeguatamente il codice (a condizione che tu faccia anche tutte le asserzioni attese di ciò che dovrebbe derivare da un mangiatore di erbe che mangia un erbivoro).

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.