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 Tiger
klasy:
[TestMethod]
public void Tiger_can_eat_herbivore()
{
IHerbivoreEater_can_eat_herbivore(BuildTigerForTest);
}
... a następnie refaktoryzuję swoje Lion
i Tiger
zaję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 HerbivoreEater
klasie, 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 Tigers
moż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ć: IHerbivoreEater
jest 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);
}
źródło
Animal
klasy, która zawieraEat
? Wszystkie zwierzęta jedzą, a zatemTiger
iLion
klasa mogą dziedziczyć po zwierzętach.Eat
zachowanie w klasie bazowej, wszystkie podklasy powinny wykazywać to samoEat
zachowanie. Mówię jednak o 2 relatywnie niezwiązanych ze sobą klasach, które mają wspólne zachowanie. Weźmy na przykład,Fly
zachowanieBrick
iPerson
które możemy założyć, wykazują podobne zachowanie latanie, ale nie musi to mieć sens, aby je czerpać ze wspólnej klasy bazowej.Odpowiedzi:
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.
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.
źródło
IHerbivoreEater
jest 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ć.Na okrągło pytasz, czy należy wykorzystać wiedzę z białej skrzynki, aby pominąć niektóre testy. Z perspektywy czarnej skrzynki
Lion
iTiger
są 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ę
Lion
lubTiger
możeEat
w 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:powinno być bardzo proste do zrobienia i wystarczające oraz zapewni wykrycie, kiedy obiekty nie spełniają swojego zamówienia.
źródło
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).
źródło