sobota, 15 października 2022

Mockowanie Repozytoriów

Testowanie jednostkowe klas posiadających jako zależności Repozytoria np. Seriwsów/Command Handler'ów może być kłopotliwe. Jeżeli zdycydujemy się na sztuczne implementacje Repozytoriów tzw. *InMemoryRepository – powinniśmy być świadomi kwestii związanych z ich późniejszym utrzymaniem – jakie problemy rozwiązują, a jakie stwarzają. Kod metody poddawanej testom nie jest tym nad czym chaciałbym się skupić w tym wpisie, istotne jest jedynie wywołanie w niej metody Query (CQS) Repozytorium.

 

Podejścia do Mock’owania Repozytoriów

Na potrzeby testu jednostkowego musimy odtworzyć wierną kopię klasy produkcyjnej. Np. posiadamy interfejs repozytorium OrderRepository:

interface OrderRepository 
{
	/** @return Order[] */
	public function getUnpaidOrders(): iterable;
}

 

Abstrahując od tego jak wyglądałaby implementacja klasy produkcyjnej OrderMySQLRepository, skupmy się na jej odpowiedniku utworzonym na potrzeby testu:

final class OrderInMemoryRepository implements OrderRepository
{
	/** @param Order[] $orders */
	public function __construct(private array $orders) {}
	
	public function getUnpaidOrders(): iterable
	{
		return array_filter(
			$this->orders,
			static fn ($order) => $order->isUnpaid() 
		);
	}
}


Implementacja metody OrderInMemoryRepository::getUnpaidOrders została tak napisana by zawsze zwracała odpowiednią kolekcję zamówień - logicznie zgodną z nazwą metody. Dzięki takiej implementacji dysponujemy całkiem poręcznym narzędziem do pisania testów jednostkowych. W przypadku gdy interfejs posiadałby by inne metody – też w takim stopniu odtwarzające rzeczywistą implementację – moglibyśmy używać tego samego obiektu OrderInMemoryRepository w wielu przypadkach testowych. Jest to dość złożony TestDouble typu Fake, który niejako posiada więdzę na temat tego jak działa produkcyjna implementacja.

Moglibyśmy przyjąć inną taktykę w której OrderInMemoryRepository jest maksymalnie okrojony z implementacji:

final class OrderInMemoryRepository implements OrderRepository
{
	/** @param Order[] $orders */
	public function __construct(private array $orders) {}
	
	public function getUnpaidOrders(): iterable
	{
		return $this->orders;
	}
}

 

Jako, że OrderInMemoryRepository::getUnpaidOrders zawsze zwraca taką samą kolekcję jak ta dostarczona do konstruktora – można powiedzieć, że jest to pewna forma Stub’a.Ciężar doboru odpowiednich danych wejściowych spoczywa na metodzie w której sztuczne Repozytorium zostało utworzone – np. Fixtura, metoda testowa/setUp.

Korzystanie z wariantu Stub jest niemal identyczne jakbyśmy używali biblioteki mokującej np. MockObject czy Prophecy. W dwóch przypadkach musimy staranie dobrać zwrotkę metody do aktualnego przypadku testowego. Pomiędzy przygotowaną kolekcją obiektów wrzucaną w metodę obiektu z biblioteki mokującej/czy konstruktorem Stub *InMemoryRepository, a zwróceniem danych z wywołanej metody Repozytorium, nic więcej się nie dzieje - nie następuje żadne filtrowanie.

Fake Repository

❌ jest silnie sprzężony z Produkcyjnym Repozytorium (po każdej zmianie repozytorium MySQL, musimy sprawdzić czy *InMemoryRepository nie wymaga modernizacji, czy w dalszym ciągu wiernie odzwierciedla prawdziwą implemetację),

✅ koncept stojący za nazwą metod jest jawnie zapisany w kodzie,

✅ odpowiednio przygotowany może być wykorzystany w wielu przypadkach testowych.

 

Stub Repository

❌ musi być indywidualnie przygotowany pod każdy przypadek testowy. Dobór zwrotek jego metod stoi po stronie developera – jest więc nie do końca jawny,

❌ w przypadku zmiany metody Produkcyjnego Repozytorium, musimy zweryfikować wszystkie przypadki testowe kodu, który bezpośrednio polega na tej metodzie. Czy w dalszym ciągu przygotowane przez developera obiekty zwracane przez metodę sztucznego Repozytorium spełniają logikę która stoi za ich nazwą? 

✅ zaletą jest mniej kodu do utrzymania, ale to tylko dlatego, że koncepty te nie są zawarte w kodzie z czego wynikają opisane wyżej problemy. W przypadku gdy istnieje tylko jeden klient metody Repozytorium możemy zastosować Stub’a – wraz z rozrostem repozytorium o nowe metody, zwiększeniem się liczby klientów należy rozwarzyć refaktoryzację w stronę Fake’a.

Koniec końców, czy stosujemy jedną czy drugą metodę – cały czas jesteśmy w tej samej sytuacji – musimy inscenizować dostarczanie danych do testowanej metody. W przypadku Fake’ów z bardziej skomplikowaną implementacją, dysponujemy po prostu bardziej wszechstronnym narzędziem kosztem jego późniejszego utrzymania. 

 

Utrzymanie klasy Fake InMemory Repository


Nowe wymagania biznesowe w czasie kolejnych iteracji wymuszają zmianę repozytoriów - prawdziwych i sztucznych.

Przedstawione na diagramie zmiany interfejsu Repozytorium, są oczywiście napędzane decyzjami biznesu co do nowych feature’ów. Jak widać testując jednostkowo metodę wywołującą metody Repozytorium, musimy wprowadzić do systemu testowego nowy byt. Każda zmiana implementacji repozytoriów będzie wymuszała na nas prace związane z jego utrzymaniem. Można wywnioskować, że klasy *InMemoryRepository to dodatkowy koszt jaki musimy ponieść za cenę bezpieczeństwa czyli łatwiejszego wprowadzania zmian w projekcie.

Błędne będzie jednak założenie, że Mock’owanie Repozytoriów zawsze daje nam wspomniane bezpieczeństwo. W przypadku gdy pomiędzy Klasą Produkcyjną, a Test Double pojawią się rozbieżności w działaniu – wprowadzone nieświadomie/omyłkowo przez programistę – testy jednostkowe mogą przechodzić na zielono, podczas gdy tzw. Produkcja będzie rzucał błędami lub działała niezgodnie z oczekiwaniami. Jest to ryzyko z którego powinniśmy zdawać sobie sprawę podczas synchronizacji Fake InMemory Repository z Repozytorium Produkcyjnym.

Warto też zauważyć, że im bardziej uniwersalna jest metoda repozytorium tzn. posiada filtry, tym trudniej utworzyć jej imitację potrzebną do testów jednostkowych. Odtworzenie poprawnego zachowania wszystkich filtrów może być skomplikowane, a wprowadzona złożoność z tym zwiazana - podatna na błędy w przyszłości. Przy tego typu pracach należy zachować szczególną ostrożność, gdyż jak na ironię, nie posiadamy testu jednostkowego klasy InMemory.

Jeżeli zaś chodzi o sam design, na tak uniwersalnej metodzie Repozytorium polegać będzie zapewne dużo klientów, co będzie skutkowało pojawieniem się zależności między nimi czego wspólnym mianownikiem jest wspomniana „uniwersalna” metoda Repozytorium.

 

Podejście funkcyjne

Możemy przyjąć zupełnie inną taktykę. Zamiast zastanawiać się nad najlepszym sposobem testowania metod które pozyskują dane z repozytoriów - nie testować ich w ogóle. Wiąże się to z destylacją logiki biznesowej zawartej w testowanej metodzie poprzez wydzielenie wszystkich wyowłań metod Query (CQS) do warstwy wyżej.

Metoda Serwisu samy pozyskuje dane z Repozytorium. Taka implementacja wymaga ich mock'owania.

 

W wyniku takiej zmiany design’u: wydestylowany byt staje się Serwisem Domenowym, a warstwa która pozyskuje Encję/Agregaty/VO z repozytoriów – Serwisem Aplikacyjnym, którego domenowy odpowiednik otrzymuje wszystkie potrzebne do przeprowadzenia operacji obiekty, jako parametry metody.

 

Testowany Serwis Domenowy poddawany nie wie nic o klasach Repozytoriów.

Testowaniu jednostkowem podlegać będzie wtedy tylko Serwis Domenowy, dla którego nie będziemy musieli już szykować żadnego Test Double Repozytorium. Serwisy Aplikacyjne będą testowane tylko Integracyjne.

Niektóre przypadki mogą okazać się problematyczne do zaimplementowania – bo jak mamy rozwiązać problem jakiegoś bytu który jest pobierany z repozytorium na podstawie jakiejś decyzji biznesowe?

Podsumowanie

Jak widać mockowanie repozytoriów wiąże się z pewnego rodzaju problemami z których należy sobie zdawać sprawę – jak zwyklę w programowaniu nic nie jest czarno białe i rozwiązując pewien problem godzimy się na wprowadzenie mniejszego. Testy Integracyjne niejako rozwiązują sprawę tworzenia Test Double Repozytoriów w ogóle, niemniej jednak nie zawsze takowe z łatwością można wprowadzić w projekcie.


 

 

Brak komentarzy:

Prześlij komentarz