Jak budujesz testy jednostkowe dla wielu obiektów, które wykazują to samo zachowanie?

9

W wielu przypadkach mogę mieć istniejącą klasę z pewnym zachowaniem:

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

... i mam test jednostkowy ...

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

Teraz muszę stworzyć klasę Tygrys o identycznym zachowaniu jak Lew:

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

... a ponieważ chcę tego samego zachowania, muszę przeprowadzić ten sam test, robię coś takiego:

interface IHerbivoreEater
{
    void Eat(Herbivore herbivore);
}

... i refaktoryzuję mój 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);
}

... a następnie dodaję kolejny test dla mojej nowej Tigerklasy:

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

... a następnie refaktoryzuję swoje Lioni Tigerzajęcia (zwykle przez dziedziczenie, ale czasami przez kompozycję):

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

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

... i wszystko jest dobrze. Ponieważ jednak funkcjonalność jest teraz w HerbivoreEaterklasie, wydaje się, że jest coś nie tak z testowaniem każdego z tych zachowań w każdej podklasie. Jednak to właśnie podklasy są faktycznie konsumowane, a tylko szczegół implementacji pozwala im dzielić się zachowaniami (Lions i Tigersmoże mieć zupełnie różne zastosowania końcowe, na przykład).

Testowanie tego samego kodu wiele razy wydaje się zbędne, ale zdarzają się przypadki, w których podklasa może i zastępuje funkcjonalność klasy podstawowej (tak, może naruszać LSP, ale spójrzmy prawdzie w oczy, IHerbivoreEater to tylko wygodny interfejs testowy - to może nie mieć znaczenia dla użytkownika końcowego). Sądzę, że te testy mają pewną wartość.

Co robią inni ludzie w tej sytuacji? Czy po prostu przenosisz test do klasy podstawowej, czy testujesz wszystkie podklasy pod kątem oczekiwanego zachowania?

EDYTOWAĆ :

W oparciu o odpowiedź z @pdr uważam, że powinniśmy rozważyć: IHerbivoreEaterjest to tylko podpisanie umowy; nie określa zachowania. Na przykład:

[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
źródło
Dla argumentu, czy nie powinieneś mieć Animalklasy, która zawiera Eat? Wszystkie zwierzęta jedzą, a zatem Tigeri Lionklasa mogą dziedziczyć po zwierzętach.
The Muffin Man,
1
@Nick - to dobra uwaga, ale myślę, że to inna sytuacja. Jak wskazał @pdr, jeśli umieścisz Eatzachowanie w klasie bazowej, wszystkie podklasy powinny wykazywać to samo Eatzachowanie. Mówię jednak o 2 relatywnie niezwiązanych ze sobą klasach, które mają wspólne zachowanie. Weźmy na przykład, Flyzachowanie Bricki Personktóre możemy założyć, wykazują podobne zachowanie latanie, ale nie musi to mieć sens, aby je czerpać ze wspólnej klasy bazowej.
Scott Whitlock,

Odpowiedzi:

6

Jest to świetne, ponieważ pokazuje, w jaki sposób testy naprawdę wpływają na Twój sposób myślenia o projektowaniu. Wyczuwasz problemy w projekcie i zadajesz właściwe pytania.

Są na to dwa sposoby.

IHerbivoreEater to umowa. Wszyscy IHerbivoreEaters muszą mieć metodę Eat, która akceptuje Herbivore. Teraz wasze testy nie dbają o to, jak je się; twój Lew może zacząć od zadnich, a Tygrys zacząć od gardła. Wszystko, na czym Ci zależy, polega na tym, że po wywołaniu Eat, Herbivore zostaje zjedzony.

Z drugiej strony, część tego, co mówisz, jest taka, że ​​wszyscy IHerbivoreEaters jedzą Herbivore w dokładnie ten sam sposób (stąd klasa podstawowa). W związku z tym nie ma sensu mieć umowy IHerbivoreEater. Nic nie oferuje. Równie dobrze możesz odziedziczyć po HerbivoreEater.

A może całkowicie pozbyć się Lwa i Tygrysa.

Ale jeśli Lew i Tygrys różnią się pod każdym względem oprócz nawyków żywieniowych, musisz zacząć zastanawiać się, czy nie napotkasz problemów ze złożonym drzewem spadkowym. Co jeśli chcesz również wyprowadzić obie klasy od Feline, lub tylko klasę Lion od KingOfItsDomain (być może wraz z Shark). W tym miejscu naprawdę przychodzi LSP.

Sugerowałbym, że wspólny kod jest lepiej zamknięty.

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

To samo dotyczy Tygrysa.

Teraz rozwija się piękna rzecz (piękna, ponieważ nie zamierzałem tego). Jeśli udostępnisz do testowania tego prywatnego konstruktora, możesz przekazać fałszywą IHerbivoreEatingStrategy i po prostu przetestować, czy komunikat jest poprawnie przekazywany do enkapsulowanego obiektu.

A twój złożony test, ten, o który martwiłeś się przede wszystkim, musi tylko przetestować StandardHerbivoreEatingStrategy. Jedna klasa, jeden zestaw testów, nie trzeba się martwić o powielony kod.

A jeśli później chcesz powiedzieć Tygrysom, że powinni jeść swoje zwierzęta roślinożerne w inny sposób, żaden z tych testów nie musi się zmienić. Po prostu tworzysz nową HerbivoreEatingStrategy i testujesz ją. Okablowanie jest testowane na poziomie testu integracyjnego.

pdr
źródło
+1 Wzorzec strategii był pierwszą rzeczą, która przyszła mi do głowy po przeczytaniu pytania.
StuperUser,
Bardzo dobrze, ale teraz w moim pytaniu zastąp „test jednostkowy” na „test integracyjny”. Czy nie mamy tego samego problemu? IHerbivoreEaterjest kontraktem, ale tylko w takim stopniu, w jakim potrzebuję go do testowania. Wydaje mi się, że jest to jeden przypadek, w którym pisanie kaczek naprawdę by pomogło. Chcę tylko teraz wysłać ich obu do tej samej logiki testowej. Nie sądzę, że interfejs powinien obiecać takie zachowanie. Testy powinny to zrobić.
Scott Whitlock,
świetne pytanie, świetna odpowiedź. Nie musisz mieć prywatnego konstruktora; możesz połączyć IHerbivoreEatingStrategy ze StandardHerbivoreEatingStrategy za pomocą kontenera IoC.
azheglov
@ScottWhitlock: „Nie sądzę, że interfejs powinien obiecać zachowanie. Testy powinny to zrobić.” Właśnie to mówię. Jeśli to obiecuje zachowanie, powinieneś się go pozbyć i po prostu użyć klasy (podstawowej). W ogóle nie potrzebujesz go do testowania.
pdr
@azheglov: Zgadzam się, ale moja odpowiedź była już wystarczająco długa :)
pdr
1

Testowanie tego samego kodu wiele razy wydaje się zbędne, ale zdarzają się przypadki, w których podklasa może i zastępuje funkcjonalność klasy podstawowej

Na okrągło pytasz, czy należy wykorzystać wiedzę z białej skrzynki, aby pominąć niektóre testy. Z perspektywy czarnej skrzynki Lioni Tigersą to różne klasy. Więc ktoś bez znajomości kodu przetestowałby je, ale ty z głęboką wiedzą na temat implementacji wiesz, że możesz uniknąć testowania jednego zwierzęcia.

Jednym z powodów opracowania testów jednostkowych jest umożliwienie późniejszego refaktoryzacji, ale utrzymanie tego samego interfejsu czarnej skrzynki . Testy jednostkowe pomagają upewnić się, że twoje klasy nadal spełniają umowę z klientami, lub przynajmniej zmuszają cię do uświadomienia sobie i przemyślenia, jak umowa może się zmienić. Sam zdajesz sobie z tego sprawę Lionlub Tigermoże Eatw pewnym momencie zastąpić . Jeśli jest to możliwe zdalnie, prosty test jednostkowy, w którym każde zwierzę, które wspierasz, może jeść, w sposób:

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

powinno być bardzo proste do zrobienia i wystarczające oraz zapewni wykrycie, kiedy obiekty nie spełniają swojego zamówienia.

Doug T.
źródło
Zastanawiam się, czy to pytanie naprawdę sprowadza się do preferencji testowania czarnej skrzynki zamiast białej. Pochylam się w stronę obozu czarnych skrzynek i być może dlatego testuję taki, jaki jestem. Dzięki za zwrócenie na to uwagi.
Scott Whitlock,
1

Robisz to dobrze. Pomyśl o teście jednostkowym jako teście zachowania pojedynczego użycia nowego kodu. Jest to to samo wywołanie, które można wykonać z kodu produkcyjnego.

W tej sytuacji masz całkowitą rację; użytkownik Lwa lub Tygrysa nie będzie (przynajmniej musiał) dbać o to, że oboje są HerbivoreEaters i że kod, który faktycznie działa dla tej metody, jest wspólny dla obu klas podstawowych. Podobnie użytkownik abstraktu HerbivoreEater (dostarczonego przez konkretnego Lwa lub Tygrysa) nie będzie się tym przejmował. Chodzi o to, że ich Lew, Tygrys lub nieznana konkretna implementacja HerbivoreEater prawidłowo zje () Herbivore.

Więc w zasadzie testujesz, czy Lew będzie jadł zgodnie z przeznaczeniem, a Tygrys będzie jadł zgodnie z przeznaczeniem. Ważne jest przetestowanie obu, ponieważ nie zawsze może być prawdą, że oboje jedzą dokładnie tak samo; testując oba, upewniasz się, że ten, którego nie chciałeś zmienić, nie zmienił. Ponieważ są to oba zdefiniowane HerbivoreEaters, przynajmniej do czasu dodania Geparda przetestowałeś również, że wszyscy HerbivoreEaters będą jeść zgodnie z przeznaczeniem. Twoje testy całkowicie obejmują i odpowiednio ćwiczą kod (pod warunkiem, że wykonasz również wszystkie oczekiwane twierdzenia o tym, co powinien wynikać z zjadacza HerbivoreEater).

KeithS
źródło