Testowanie agregatów, czyli architektura wspierająca testowanie

Pojawił się jednak problem…

Jakiś czas temu, pracując nad kolejnym zadaniem w projekcie doszedłem do etapu gdzie musiałem napisać testy. Zdaję sobie sprawę, że mogą teraz podnieść się głosy mówiące, że przecież od nich powinienem zacząć. Na swoje usprawiedliwienie jednak dodam, że część kodu już istniała, była nie przetestowana, a ja chciałem mieć pewność, że mimo wszystko moje zmiany nie popsują tego co zastałem. Decyzja ta, więc finalnie miała na celu ogólne podniesienie jakości i testowalności tego fragmentu aplikacji. Pojawił się jednak problem.

Aplikacja opierała się i nie chciała ze mną współpracować mimo faktu, że w moim subiektywnym odczuciu jakość zastanego kodu nie była zła. Oczywiście znalazłbym kilka rzeczy, które bym z chęcią z refaktoryzował, jednak mimo braku testów, kod wydawał się testowalny. I nie pomyliłem się. Po jakiś 40 minutach miałem napisaną bardzo przyzwoitą suitę testową, pokrywającą kilka zastanych jak i nowych przypadków. Jako inżynier byłem zadowolony, jednak coś nie dawało mi spokoju. Zrobiłem więc dwa kroki do tyłu i przyjrzałem się swojemu dziełu. Nurtowały mnie pytania. Dlaczego kod tak bardzo nie chciał być przetestowany, mimo jego pozornej testowalności? Czy czas jaki poświęciłem na te testy to dużo czy mało? Co więcej zacząłem się zastanawiać czy faktycznie to co przetestowałem daje mi wystarczającą informację zwrotną w przypadku kolejnych zmian w systemie? Zdecydowanie był to temat który warto było zgłębić trochę bardziej!

Architektura aplikacji w służbie Jej Testowalności

Dla prostoty przyjmijmy, że architektura kodu to przede wszystkim sposób jego organizacji. Nie dotyczy to oczywiście tylko struktury katalogowej, ale przede wszystkim jakie odpowiedzialności pełnią poszczególne jego części. W przypadku mojego projektu będziemy mówić o dążeniu do spełnienia postulatów Czystej Architektury, znanej również jako Heksagonalna, Portów i Adapterów lub nawet jako Cebulowa (ang. Onion), chociaż ta ostatnia nazwa niespecjalnie się przyjęła. W ramach tej architektury wyróżniamy trzy warstwy:

  • poziom infrastruktury – w tym miejscu komunikujemy się ze światem zewnętrznym oraz korzystamy z zewnętrznych zależności (np. w postaci dołączonych bibliotek i pakietów)
  • poziom aplikacji – tutaj powinna dziać się część operacyjna naszej aplikacji
  • poziom domeny – na tym poziomie z kolei powinny być podejmowane kluczowe decyzje, np. na temat zmiany stanu aplikacji

Ciekawą własnością do której powinniśmy dążyć w takiej architekturze jest zakres widoczności każdej z tych warstw. Domena widzi tylko siebie i nie ma dostępu do pozostałych warstw. Poziom aplikacji zna domenę i siebie. Infrastruktura z kolei widzi wszystko – i z góry i z dołu. Taki sposób myślenia wymusza na programiście, aby zależności zawsze były wstrzykiwane z warstwy infrastruktury do warstwy aplikacji. Możemy powiedzieć, że zależności wstrzykujemy od dołu lub jak to w przypadku charakterystycznej dla tej architektury wizualizacji – od zewnątrz do środka.

Rys 1. Wizualizacja Czystej Architektury, diagram własny

Do takiej architektury również dążymy, kiedy staramy się, aby nasza aplikacja była projektowana w duchu DDD (ang. Domain Driven Design). Tak też było w moim przypadku. System w którym pracowałem zdecydowanie zmierzał w tym kierunku, chociaż nadal dużo drogi pozostawało przed nim. Z tego też powodu w kodzie pojawiały się idee zaczerpnięte z DDD, takie jak encje, ValueObject’y, czy repozytoria. Wpis ten nie ma na celu tłumaczyć czym jest DDD oraz z jakich konceptów się składa, ale dla dalszego zrozumienia kontekstu przyjmijmy takie uproszczone definicje dla kilku z nich:

  • encja (ang. entity) – jest to obiekt posiadający tożsamość, który reprezentuje jakiś model. Przykładowo użytkownika, którego możemy jednoznacznie z identyfikować. Model ten jednak w odróżnieniu od typowego podejścia zawierającego głównie encje anemiczne, poza danymi, posiada również metody, które umożliwiają wykonywanie na tym obiekcie jakiś akcji.
  • ValueObject – jest to obiekt, który nie posiada tożsamości. Idealnym przykładem jest reprezentacja pieniądza. W odróżnieniu jednak od prostej struktury danych, może on posiadać również jakąś logikę, np. związaną z walidacją, różną reprezentacją lub porównywaniem.
  • repozytorium – jest to obiekt, który umożliwia nam wykonywanie operacji na bazie danych. Powinien on udostępniać jednak na tyle wysokopoziomowe API, aby nie zdradzać nam szczegółów implementacyjnych.

Wydaje się, że Wujek Bob by to zaakceptował. Jednak w jaki sposób taka architektura miałaby nam w jakikolwiek sposób pomóc w testowaniu? W jakiej warstwie powinno być najwięcej testów? Jak powinny wyglądać takie testy? Niestety wydaje się, że sama koncepcja tej architektury nam na to nie odpowiada. Spróbujmy zadać więc trochę inne pytanie. Co sprawia, że test jest łatwy w napisaniu, a co go utrudnia lub wręcz uniemożliwia?

Białe czy czarne?

Jednym popularnych wzorców do pisania testów jednostkowych jest reguła trzy razy A:

  • Arrange – przygotuj aplikację, aby można było ją przetestować
  • Act – wykonaj akcję
  • Assert – sprawdź, przetestuj, czy wynik akcji jest zgodny z twoimi przewidywaniami.

Po przeanalizowaniu testów, które pisałem wtedy w tamtym projekcie, zwróciłem uwagę, że najwięcej czasu zajmował mi etap Arrange, czyli przygotowania testu. Samo wywołanie akcji i testu najczęściej sprowadzało się do jednej lub dwóch linijek kodu, a przygotowanie go zajmowało nawet kilkanaście. Poza tym miałem silne uczucie, że zbyt głęboko muszę wchodzić w implementację, aby wiedzieć jak dany fragment kodu przetestować.

Spójrzmy na poniższy fragment kodu:

interface CalculatorInterface
{
    public function add(float $x, float $y): float;
    public function divide(float $x, float $y): float;
}

final class Calculator implements CalculatorInterface
{
    public function add(float $x, float $y): float
    {
        return $x + $y;
    }

    public function divide(float $x, float $y): float
    {
        if ($y == 0) {
            throw new \DivisionByZeroError();
        }

        return $x / $y;
    }
}

Spróbujmy ten kod przetestować:

class CalculatorTest extends TestCase
{
    public function testDivideTwoIntegers()
    {
        // Arrange
        $calculator = new Calculator();
        // Act
        $result = $calculator->divide(6, 3);
        // Assert
        $this->assertEquals(2, $result);
    }

    public function testDivideByZero()
    {
        // Assert
        $this->expectException(\DivisionByZeroError::class);
        // Arrange
        $calculator = new Calculator();
        // Act
        $calculator->divide(6, 0);
    }

    public function testAddTwoIntegers()
    {
        // Arrange
        $calculator = new Calculator();
        // Act
        $result = $calculator->add(7, 3);
        // Assert
        $this->assertEquals(10, $result);
    }
}

Kod przedstawia bardzo prostą implementację kalkulatora z dwoma metodami do dzielenie i dodawania. Bardzo często właśnie takie przykłady służą do pokazania przykładowego testowania aplikacji.

W każdym przypadku testowym zaznaczyłem za pomocą komentarza część sekcji Arrange, Act i Assert. Można zauważyć, że sekcje te są bardzo krótkie i zwięzłe. Każdy test składa się z 3 linii, po jednej na każdą z sekcji. Chciałbym jednak zwrócić uwagę na jedną istotną rzecz, która wydaje się być pomijana lub nie wystarczająco wybrzmiewa w tego typu przykładach. Testy te są bardzo proste i szybkie w napisaniu. Można by powiedzieć, że testujemy bardzo prostą przykładową klasę i takie przypadki rzadko kiedy występują w rzeczywistości. Wręcz można by uznać, że testy te, od tej rzeczywistości są oderwane. Spróbujmy zastanowić się jednak co, poza oczywiście nieskomplikowanym przykładem, sprawia, że testy te są takie proste:

  • jawnie zdefiniowany kontrakt w postaci interfejsu CalculatorInterface,
  • klasa Calculator nie posiada żadnych zależności,
  • metody posiadają wszystkie informacje, aby móc wykonać żądaną akcję,
  • zachodzi tutaj stała zależność pomiędzy parametrami wejściowymi, a wartością zwracaną.

Jednoznacznie można stwierdzić, że patrząc na metody tej klasy, jesteśmy w stanie powiedzieć jakie jest ich zachowanie i jakich wyników możemy się spodziewać. Dzięki temu nie jest wymagana znajomość wewnętrznej implementacji i tak naprawdę moglibyśmy napisać testy bazując tylko na metodach wyszczególnionych w interfejsie CalculatorInterface. Testy o takich właściwościach nazywamy testami czarnej skrzynki (ang. blackbox tests). Test czarnej skrzynki jest najprostszym rodzajem testów, który zdecydowanie ułatwia testowanie aplikacji. Jednak w życiu rzadko kiedy testujemy proste kalkulatory. Spróbujmy więc omówić ciut bardziej złożony przypadek.

interface CurrencyExchangeRateRepositoryInterface
{
    public function getExchangeRate(Currency $fromCurrency, Currency $toCurrency): ExchangeRate;
}

final class CurrencyExchangeService
{
    private $currencyRepository;

    public function __construct(CurrencyExchangeRateRepositoryInterface $currencyRepository)
    {
        $this->currencyRepository = $currencyRepository;
    }

    public function exchange(Currency $toCurrency, Money $money)
    {
        $exchangeRate = $this->currencyRepository->getExchangeRate($money->getCurrency(), $toCurrency);
        $newValue = $money->getAmount() * $exchangeRate->getValue();

        return new Money($newValue, $toCurrency);
    }
}

Powyższa klasa ma za zadanie przeliczyć wartość jednej waluty na drugą. Posiada ona zależność w postaci repozytorium zwracającego aktualny kurs wymiany waluty. Specyficzna implementacja tego repozytorium, w ramach naszej Czystej Architektury powinna znajdować się na poziomie infrastruktury. Serwis tego typu w naszym przypadku przynależy do warstwy aplikacji. W warstwie domeny z kolei będą znajdować się takie koncepty, zaprezentowane jako ValueObject’y, jak Currency (waluta), Money (pieniądz), czy ExchangeRate (kurs wymiany). Spróbujmy dopisać testy dla tej klasy.

class CurrencyExchangeServiceTest extends TestCase
{
    public function testExchange()
    {
        // Arrange
        $repositoryMock = \Mockery::mock(CurrencyExchangeRateRepositoryInterface::class);
        $repositoryMock
            ->shouldReceive('getExchangeRate')
            ->withArgs([Currency::class, Currency::class])
            ->andReturn(new ExchangeRate(0.25));

        $currencyExchangeService = new CurrencyExchangeService($repositoryMock);
        $money = new Money(100, new Currency('PLN'));
        $exchangeTo = new Currency('USD');

        // act
        $newValue = $currencyExchangeService->exchange($exchangeTo, $money);

        // assert
        $this->assertInstanceOf(Money::class, $newValue);
        $this->assertEquals(new Money(25.0, $exchangeTo), $newValue);
    }

   public function tearDown()
    {
        \Mockery::close();
    }
}

Już na pierwszy rzut oka widać, że test ten jest dużo bardziej skomplikowany. Co więcej liczba linijek testu jest większa, niż samego kodu testowanego! Ten jeden test zajął mi dużo więcej czasu niż 3 testy dla klasy Calculator łącznie. Test sam w sobie nie jest skomplikowany, chociaż jego przygotowanie wymagało pewnego wysiłku intelektualnego. Warto zauważyć, że najwięcej linijek kodu zawiera sekcja Arrange. Jako, że klasa CurrencyExchangeService posiada zewnętrzną zależność, należało ją w pierwszej kolejności zastąpić obiektem klasy udającym prawdziwe repozytorium. Taki obiekt nazywamy po polsku atrapą lub po angielsku Test Double (nazwa nawiązująca do określenia Stunt Double, czyli kaskadera, który zastępuje aktora w niebezpiecznych scenach). Ma on zastąpić oryginalny obiekt na którym będzie można wywołać potrzebne metody i zwrócić wcześniej przygotowane dane. Ma to na celu zapewnienie powtarzalności wykonania testu oraz odcięciu prawdziwych zależności, aby móc dany kod testować w odosobnieniu. W tym konkretnym przypadku użyłem atrapy typu Mock, która umożliwia weryfikację, czy dana metoda została użyta w trakcie testu. Umożliwia również określenie jakich parametrów metoda powinna się spodziewać oraz wartość jaką ma zwrócić.

W porównaniu do wcześniejszego przykładu, aby móc wywołać i przetestować metodę exchange musiałem znać wewnętrzną implementację klasy CurrencyExchangeService. Musiałem wiedzieć, że metoda ta wywoła wewnątrz metodę getExchangeRate oraz jaką wartość taka metoda zwróci. Stworzenie atrapy wydłużyło i skomplikowało proces pisania testu. Nie jest on również tak przejrzysty jak testy dla klasy Calculator. Testy, gdzie wymagana jest znajomość implementacji nazywamy testami białej skrzynki (ang. whitebox tests). Co interesujące, praktycznie wszystkie testy jakie pisałem w problemie opisywanym na początku tego artykułu były właśnie testami białej skrzynki. Klasy testowane posiadały dużą liczbę zależności, więc etap Arrange zawierał w pierwszej kolejności instancjonowanie wielu atrap, których pisanie zasadniczo utrudniało cały proces testowania. Nadmienię również, że przygotowywanie tych atrap było wyjątkowo nudnym zajęciem oraz wymagało dokładnego przeglądania kodu (jako, że testy te pisane były w formie TAD – Test After Development) w poszukiwania konkretnego ich wykorzystania. Jednoznacznie można stwierdzić, że testy biało-skrzynkowe są dużo trudniejsze w pisaniu, chociaż na chwilę obecną wydaje się, że mimo wszystko są dużo bardziej „życiowe” niż testy czarno-skrzynkowe. Idealną sytuacją byłoby, gdybyśmy byli w stanie przetestować daną funkcjonalność tylko z wykorzystaniem testów czarno-skrzynkowych. Pojawia się w takim razie pytanie jak to zrobić oraz w jaki sposób miałaby nam w tym pomóc nasza architektura aplikacji? Spójrzmy na jeszcze jeden przykład i spróbujmy go przekształcić w taki sposób, aby odpowiedzieć na te pytania.

Klasyk, czyli testowanie koszyka

class CartService
{
    private CartRepositoryInterface $cartRepository;
    private DiscountRepositoryInterface $discountRepository;
    private EventDispatcherInterface $eventDispatcher;

    public function __construct(
        CartRepositoryInterface $cartRepository,
        DiscountRepositoryInterface $discountRepository,
        EventDispatcherInterface $eventDispatcher
    ) {
        $this->cartRepository = $cartRepository;
        $this->discountRepository = $discountRepository;
        $this->eventDispatcher = $eventDispatcher;
    }

    public function applyDiscountHandler(ApplyDiscountCommand $applyDiscountCommand): void
    {
        try {
            $cart = $this->cartRepository->getCart();
            $discount = $this->discountRepository->getDiscountByCoupon($applyDiscountCommand->getDiscountCoupon());

            if ($discount->isExpired()) {
                throw new DiscountCannotBeApplied();
            }

            if ($discount->applyOnlyToTheThirdProduct()) {
                $cart->calculateOnlyThirdProductDiscount($discount->getDiscountPercentage());
            }

            if ($discount->applyToAllProducts()) {
                $cart->calculateForAllProducts($discount->getDiscountPercentage());
            }

            $this->discountRepository->disableDiscountCoupon($applyDiscountCommand->getDiscountCoupon());
            $this->cartRepository->saveCart($cart);
            $this->eventDispatcher->dispatch(new DiscountApplySuccessEvent());

        } catch (CartNotExists | DiscountCouponNotExists | DiscountCannotBeApplied $e) {
            $this->eventDispatcher->dispatch(new DiscountApplyFailedEvent());
        }
    }
}

Powyżej mamy kod odpowiedzialny za użycie kuponu z procentowym rabatem na rzeczy dodane do koszyka zakupowego. Metoda applyDiscountHandler przyjmuje komendę (czyli żądanie wykonania akcji) i pobiera instancję aktualnego koszyka oraz odnajduje kupon w bazie danych. Następnie aplikuje kupon w zależności od reguł biznesowych, zapisuje zmieniony koszyk oraz blokuje kupon, aby nie można było go użyć po raz kolejny. Struktura kodu oraz zależności rozłożona jest w ramach Czystej Architektury.

Rys 2. Struktura kodu modułu Cart

Możemy wyszczególnić tutaj charakterystyczne bloki z jakich budujemy aplikację z wykorzystaniem projektowania za pomocą DDD. Zastanówmy się w jaki sposób moglibyśmy przetestować tę metodę. Klasa CartService posiada 3 zależności. Dwie do repozytoriów dla koszyka i kuponów oraz serwisu do rozgłaszania zdarzeń (ang. event – zdarzenie, które zaszło w systemie). Oznacza to, że w pierwszej kolejności musielibyśmy stworzyć atrapy dla tych zależności. Następnie, należałoby sprawdzić, jakie metody są wykorzystywane (ile razy, z jakimi parametrami i co zwracają) i je odpowiednio ustawić. Kluczową akcją jaką próbujemy wykonać jest tak naprawdę zmiana stanu obiektu Cart. Klasa CartService nie zwraca jednak w żaden sposób instancji tej klasy. Weryfikacja jej stanu również musiałaby odbyć się za pomocą mock’a. Zdecydowanie byłby to test biało-skrzynkowy. Co więcej kod nam nie ułatwia, nie zwracając niczego, przez co należałoby stosować obejścia i sztuczki. Test nie byłby ani łatwy do napisania, ani do przeczytania (a testy to najlepsza dokumentacja!). Wiedz, że coś się dzieje! W tym momencie powinniśmy mieć przeczucie, że coś jest nie tak z naszym kodem. Spróbujmy zrefaktoryzować nasz kod, aby nasze testy były trochę łatwiejsze w napisaniu oraz trochę bardziej czytelne. Zastanówmy się, co jest nie tak z powyższym kodem?

  • metoda zdecydowanie łamie zasadę SRP (ang Single Responsibility Principle) i robi za dużo – dobrze byłoby więc rozdzielić od siebie operacje na samym kuponie oraz koszyku.
  • logika biznesowa dotycząca zasad liczenia rabatu wyciekła z domeny do serwisu aplikacyjnego. Warstwa aplikacji odpowiedzialna jest za część operacyjną programu. Nie powinno podejmować się w niej decyzji biznesowych.
  • mieszają się tutaj dwie różne logiki biznesowe. Logika zarządzania kuponem i jego zablokowaniem oraz logika przeliczania koszyka.

W pierwszej kolejności zajmijmy się rozdzieleniem odpowiedzialności.

class DiscountService
{
    private DiscountRepositoryInterface $discountRepository;
    private EventDispatcherInterface $eventDispatcher;

    public function __construct(
        DiscountRepositoryInterface $discountRepository,
        EventDispatcherInterface $eventDispatcher
    ) {
        $this->discountRepository = $discountRepository;
        $this->eventDispatcher = $eventDispatcher;
    }

    public function applyDiscountHandler(ApplyDiscountCommand $applyDiscountCommand): void
    {
        try {
            $discount = $this->discountRepository->getDiscountByCoupon($applyDiscountCommand->getDiscountCoupon());

            $discountEvent = $discount->useDiscount();

            $this->discountRepository->handleDiscount($discountEvent);
            $this->eventDispatcher->dispatch($discountEvent);
        } catch (DiscountCouponNotExists $e) {
            $this->eventDispatcher->dispatch(new DiscountFailedEvent());
        }
    }
}

Stworzyliśmy nową klasę, która zajmuje się tylko i wyłącznie walidacją i wykorzystaniem kuponu. Na znalezionym kuponie wywołujemy metodę useDiscount, która zwraca nam zdarzenie mówiące o zmianie stanu kuponu. Spójrzmy na klasę Discount.

class Discount
{
    private DiscountType $discountType;
    private DiscountPercentage $discountPercentage;
    private DiscountId $id;
    private DiscountExpirationDate $discountExpirationDate;

    public function __construct(
        DiscountType $discountType,
        DiscountPercentage $discountPercentage,
        DiscountExpirationDate $discountExpirationDate,
        DiscountId $id
    ) {
        $this->discountType = $discountType;
        $this->discountPercentage = $discountPercentage;
        $this->id = $id;
        $this->discountExpirationDate = $discountExpirationDate;
    }

    public function useDiscount(): DiscountEventInterface
    {
        if ($this->discountExpirationDate->isExpired()) {
            return new DiscountApplyFailedEvent();
        }
        // some logic to check if the discount is still valid after usage
        return new DiscountCouponUsedEvent($this->discountType, $this->discountPercentage, $this->id);
    }
}

Klasa Discount nie posiada żadnych zależności. Wszystkie informacje wymagane do podjęcia decyzji o zmianie stanu zostały jej dostarczone przy jej inicjalizacji. Doprowadziliśmy więc do sytuacji, gdzie możemy przetestować naszą logikę biznesową za pomocą testu… czarno-skrzynkowego!

class DiscountTest extends TestCase
{
    public function testDiscountExpired(): void
    {
        // Arrange
        $discount = new Discount(
            new DiscountType(DiscountType::TO_ALL_PRODUCTS),
            new DiscountPercentage(25.0),
            new DiscountExpirationDate(new \DateTimeImmutable("2020-02-01")),
            new DiscountId("88d881be-2e54-4c24-b0c4-bb226befd7a5")
        );

        // Act
        $discountEvent = $discount->useDiscount();

        // Assert
        $this->assertInstanceOf(DiscountApplyFailedEvent::class, $discountEvent);
    }

    public function testDiscountUsedSuccessfully(): void
    {
        // Arrange
        $discount = new Discount(
            new DiscountType(DiscountType::TO_ALL_PRODUCTS),
            new DiscountPercentage(25.0),
            new DiscountExpirationDate(new \DateTimeImmutable("now + 2 days")),
            new DiscountId("88d881be-2e54-4c24-b0c4-bb226befd7a5")
        );

        // Act
        $discountEvent = $discount->useDiscount();

        // Assert
        $this->assertInstanceOf(DiscountCouponUsedEvent::class, $discountEvent);
    }
}

Zdecydowanie test naszej logiki biznesowej wygląda dużo schludniej niż test serwisu do przeliczenia kursu walut i napisanie go zajęło dużo mniej czasu. Dzięki zwracanym zdarzeniom, jesteśmy w stanie badać relacje zachodzące pomiędzy parametrami wejściowymi, a wyjściowymi z metody. Wykorzystanie koncepcji zdarzeń jest też nieprzypadkowe. Poprzez zdarzenia rozumiemy istotną z punktu widzenia systemu zmianę stanu. Tak też się stało w tym przypadku. Następnie, w ramach DiscountService robimy dwie rzeczy z tym zdarzeniem:

  • utrwalamy zmianę – w zależności od architektury systemu możemy w ramach naszej bazy danych zapisać to zdarzenie. Suma wszystkich zdarzeń (czyli zmian stanu) na obiekcie da nam zawsze jego aktualny stan. Z kolei w systemie, gdzie te historyczne zmiany stanu nie mają znaczenia, możemy zaktualizować stan obiektu bezpośrednio. W tym drugim przypadku dobrze jest jednak, aby wewnątrz zdarzenia przekazana została nowa, spójna instancja zapisywanego obiektu.
  • publikujemy zdarzenie – dzięki temu inne obiekty nasłuchujące na konkretne zdarzenia mogą podjąć własne akcje. Właśnie w ten sposób umożliwimy zaaplikowanie naszego kuponu na koszyku.
class CartService
{
    private CartRepositoryInterface $cartRepository;
    private EventDispatcherInterface $eventDispatcher;

    public function __construct(
        CartRepositoryInterface $cartRepository,
        EventDispatcherInterface $eventDispatcher
    ) {
        $this->cartRepository = $cartRepository;
        $this->eventDispatcher = $eventDispatcher;
    }

    public function applyDiscountHandler(DiscountCouponUsedEvent $discountUsedEvent): void
    {
        try {
            $cart = $this->cartRepository->getCart();

            $cartEvent = $cart->applyDiscount(
                $discountUsedEvent->getDiscountPercentage(),
                $discountUsedEvent->getDiscountType()
            );

            $this->cartRepository->handleCartEvent($cartEvent);
            $this->eventDispatcher->dispatch($cartEvent);

        } catch (CartNotExists $e) {
            $this->eventDispatcher->dispatch(new DiscountFailedEvent());
        }
    }
}

Zdarzenie DiscountCouponUsedEvent spowodowało wywołanie metody applyDiscountHandler w ramach obiektu klasy CartService. Zdarzenie to niesie ze sobą potrzebne informacje, które są nam potrzebne do zaaplikowania kuponu na naszym koszyku zakupowym. Jak widać nie ma potrzeby przekazywania całego obiektu Discount, a tylko informacji o rodzaju (DiscountType) oraz procencie rabatu (DiscountPercentage)

class Cart
{
    private Money $cartNetPrice;

    public function __construct(Money $cartNetPrice)
    {
        $this->cartNetPrice = $cartNetPrice;
        // other data required for cart, ie. list of products
    }

    public function applyDiscount(
        DiscountPercentage $discountPercentage,
        DiscountType $discountType
    ): CartEventInterface {

        if ($discountType->applyOnlyToTheThirdProduct()) {
            $cartNetValue = $this->calculateOnlyThirdProductDiscount($discountPercentage);
            return new CartNetPriceUpdated($cartNetValue);
        }

        if ($discountType->applyToAllProducts()) {
            $cartNetValue = $this->calculateForAllProducts($discountPercentage);
            return new CartNetPriceUpdated($cartNetValue);
        }

        return new CartUpdateFailed();
    }

    private function calculateOnlyThirdProductDiscount(DiscountPercentage $discountPercentage): Money
    {
        $newValue = $this->cartNetPrice;
        // some calculation logic
        $newValue += $newValue * $discountPercentage->getValue() / 100;
        return $newValue;
    }

    private function calculateForAllProducts(DiscountPercentage $discountPercentage): Money
    {
        $newValue = $this->cartNetPrice->getAmount();
        // some calculation logic
        $newValue += $newValue * $discountPercentage->getValue() / 100;

        return new Money($newValue, $this->cartNetPrice->getCurrency());
    }
}

Ponownie, klasa Cart posiada wszystkie informacje wymagane, aby zaaplikować rabat oraz w wyniku wywołania metody applyDiscount zwraca zdarzenie. Następnie zdarzenie to utrwalamy oraz publikujemy. Takie podjeście tworzy łańcuch przyczynowo-skutkowy, który można bardzo łatwo śledzić i zrozumieć. Testy klasy Cart również będą testami czarno-skrzynkowymi. Pojawia się jednak pytanie, co z klasami CartService oraz DiscountService? Obie posiadają zależności, przez co ich testowanie wymusza na nas testowanie biało-skrzynkowe. W tym miejscu chciałbym zwrócić uwagę na odpowiedzialności tych serwisów. Ich zadaniem jest:

  • przygotowanie naszego obiektu (Cart/Discount),
  • wywołanie odpowiedniej metody z odpowiednimi parametrami,
  • wywołanie akcji mających na celu utrwalenie oraz publikację zdarzeń.

Czy nie przypomina to trochę schematu: Arrange, Act, Assert? Musimy odpowiedziec na pytanie, co tak naprawdę chcielibyśmy przetestować w tych klasach? Wydaje się, że ich testowanie nie ma sensu, ponieważ istotą ich odpowiedzialności jest tak naprawdę wywołanie naszej logiki biznesowej. Byłyby to więc te same testy, który napisaliśmy dla klas Discount oraz Cart, ale opakowane w skomplikowaną strukturę atrap i mock’ów. W związku z czym dużo większy sens miałyby tutaj testy integracyjne, a nie jednostkowe.

To gdzie te agregaty?

Tytuł tego artykułu obiecał agregaty, a nadal nic o nich nie wspomniałem. Jest to zabieg celowy, gdyż wydaje mi się, że temat ten przez wielu programistów jest nie do końca zrozumiany. W tym przykładzie jedyne co zrobiliśmy to trochę zrefaktorowaliśmy oraz zreorganizowaliśmy nasz kod. Dzięki temu udało nam się doprowadzić go do stanu, gdzie byliśmy w stanie przetestować go za pomocą dużo prostszego testowania czarno-skrzynkowego. Dzięki temu nasze klasy Cart oraz Discount nabrały pewnej właściwości, której wcześniej nie miały, a mianowicie:

posiadają wszystkie wymagane informacje, aby podjąć decyzję biznesową

Dzięki temu, mogą chronić dane za pomocą spójnych reguł i podejmować decyzje o zmianie stanu bez potrzeby pytania innych części systemu o dodatkowe warunki. Dodajmy do tego jeszcze dodatkowe obwarowania, które nie wynikają bezpośrednio z kodu:

  • obiekty klasy Cart i Discount powinny być zawsze tworzone w całości (powinny mieć załadowane wszystkie wymagane dane),
  • zmiany stanu powinny być zapisywane atomowo (czyli zawsze wszystkie razem),
  • tylko klasy Cart i Discount mogą modyfikować swój stan – żadna inna część systemu nie może tego robić.

Dodając tych kilka dodatkowych warunków okazuje się, że nasze klasy Cart i Discount są technicznie rzecz ujmując… agregatami w rozumieniu DDD. A dokładniej korzeniami tego agregatu. Aby lepiej to zobrazować spójrz w jaki sposób zreorganizowałem strukturę katalogową naszego komponentu.

Rys 3. Struktura modułu Cart zawierająca agregaty

Klasa Domain zawiera 3 katalogi: Commons, zawierający wspólne koncepty oraz Cart i Discount, gdzie znajdują się nasze agregaty. Na niebiesko z kolei podświetliłem ich korzenie, czyli klasy, które stanowią publiczny interfejs do interakcji z agregatem. Wydaje mi się, że takie przedstawienie tego konceptu może być dużo bardziej przystępne, gdyż nie tylko pokazuje nam korzyści w postaci dużo lepszej testowalności naszego kodu, ale również jeden ze sposobów w jakich jesteśmy w stanie wyłuskać agregaty z naszego systemu. Oczywiście istnieją do tego dużo lepsze i odpowiedniejsze narzędzia (np. sesje procesowe i taktyczne w Event Storming’u), ale nawet nie znając tych konceptów możemy w ten sposób ulepszyć nasze aplikacje.

Kto jest twoim szefem?

Na zakończenie chciałbym jeszcze przestawić pewną analogię, która mi się nasuwa, kiedy myślę o odpowiedzialnościach wartw w ramach czystej architektury. Analogia ta może być pomocna, kiedy jesteśmy zmuszeni podejmować decyzje, jak powinniśmy zorganizować kod naszej aplikacji.

Rys 4. Analogia Czystej Architektury do Organizacji, diagram własny

Wyobraź sobie, że firma składa się z trzech poziomów:

  • szefa – osoby decyzyjnej
  • managera średniego stopnia
  • pracowników

Zadaniem managera średniego szczebla jest wykonanie planu przewidzianego przez osobę decyzyjną. Nie wykonuje on go sam, ale deleguje pracę do pracowników. Pracownicy wykonują ją fizycznie. Co więcej, manager jest odpowiedzialny za dostarczenie danych potrzebnych szefowi, aby mógł podjąć konkretne decyzje. Przygotowanie tych danych również jest zlecane pracownikom, jednak manager może również je inaczej sformatować lub zorganizować przed przekazaniem ich do szefa. W dużej organizacji szef bardzo często nie zna nawet swoich pracowników. Dane dostarcza mu manager, więc nie ma potrzeby pytać i szukać danych po pracownikach najniższego szczebla. Nie interesuje go również sposób wykonania zleconych zadań.

Nasza struktura aplikacji działa podobnie. Warstwa aplikacyjna zleca fizyczne pobranie i przygotowanie danych do konkretnych instancji w warstwie infrastruktury, które pobierają je z bazy danych i zwracają nasz korzeń agregatu. Następnie następuje wywołanie i przekazanie parametrów do metody agregatu, która już w warstwie domeny podejmuje decyzję o zmianie stanu. Decyzja ta jest zwracana za pomocą zdarzenia do warstwy aplikacji, które następnie są delegowane z powrotem do warstwy infrastruktury w celu ich utrwalenia i publikacji dalej.

Spróbuj sam!

W ramach tego artykułu stworzyłem również implementację tego rozwiązania. Jak mogłeś zauważyć, nie wszystko o czym pisałem zostało przetestowane. Co więcej nie została tutaj w żaden sposób przedstawiona warstwa infrastruktury, która w naszym przypadku może w zupełności zostać zastąpiona testami. Jeżeli miałbyś/miałabyś ochotę poćwiczyć, z forkuj repozytorium, spróbuje rozwinąć istniejące testy, zmodyfikuj klasę CurrencyExchangeService w ten sam sposób w jaki zrobiłem to z klasą CartService. Zaproponuj własne rozwiązania i podziel się swoją implementacją ze wszystkimi w komentarzach. Jestem ciekawy Waszych opinii jak rozumiecie przedstawiony temat oraz pomysłów na implementację.

Do poczytania 🙂

Link do repozytorium: https://github.com/lukasz-devkick/aggregates.testing

O Autorze: Linkedin

Leave a Reply

Twój adres e-mail nie zostanie opublikowany.