Warstwa Abstrakcji - stanowi izolację pomiędzy kodem programisty a zewnętrznymi modułami, pozwala odseparować system od jego zależności przez co czyni go podatnym na zmiany i aktualizacje. Przywiązując system do innej usługi czynimy go słabym.

Modular Monolith PHP - Testy post

2020-10-12

W modularnym monolicie kluczowa jest izolacja modułów, izolacja zarówno na poziomie kodu, jak i testów. Pierwszy raz implementując monolit z ambicjami na mikroserwisy największą zagwozdką było jak sensownie podejść do testów tak aby w razie ewentualnego wydzielenia nie utknąć z test suitem, którego nie da się ruszyć. W tym celu, podążając za mocną inspiracją Behatem wdrożyliśmy coś, co zostało nazwane Kontekstami.

Jestem pewny, że konteksty w testach nie są niczym odkrywczym, zapewne wielu z was ma już z nimi jakieś mniej lub bardziej przyjemne doświadczenia. Zanim jednak powiesz "Tak, robiłem UserContext i finalnie miałem z nim strasznie dużo problemów", poczekaj.

Konteksty, o których myślę nie sprawdzają się zbytnio kiedy są zbyt małe. Nadmierna granulacja kontekstów kończy się przeważnie dużym couplingiem. Przykładowo, jeżeli stworzymy sobie BlogPostContext oraz CategoryContext bardzo szybko zorientujemy się, że każda encja/agregat w systemie ma swój oddzielny kontekst a co gorsze, od tego już tylko o krok do uzależnienia jednego kontekstu od drugiego. Następnie, zanim się zorientujemy BlogPostContext zależy od CategoryContext i wzajemnie, tworząc bardzo nieprzyjemne circular dependency.

Oczywiście można prościej, nie ma większego sensu tworzyć BlogPostContext oraz CategoryContext, przy założeniu, że Blog to nic innego jak niezależny system/moduł. W takiej sytuacji o wiele prościej zdefiniować BlogContext, którego odpowiedzialnością będzie przygotowywanie modułu w czasie testów.

W ramach przypomnienia, testy z reguły składają się z 3 kroków.


<?php class UnitTest extends TestCase { public function test_something() : void { // Arrange - prepare for test // Act - execute operation under test // Assert - check if test output matches expectations } }

Nie ważne czy mówimy tu o testach jednostkowych, integracyjnych czy funkcjonalnych. Reguła AAA jest uniwersalna.

Konteksty w największej mierze wykorzystywane są w kroku Arrange, szczególnie kiedy testowany kod posiada zależności. Bardzo często te zależności wymagają odpowiedniego przygotowania.

Dużo lepszym sposobem na podział kontekstów jest podział ze względu na systemy/moduły/zewnętrzne zależności. Przykładowo, jeżeli wspominany wyżej blog komunikuje się z systemem do tłumaczeń za pośrednictwem jakiegoś API, możemy śmiało stworzyć dwa konteksty. BlogContext i TranslationContext. Tylko po co?

Z zewnętrznym systemem nasz system komunikować się będzie prawdopodobnie poprzez jakieś SDK. Dobrą praktyką byłoby, aby takie SDK implementowało jakiś interfejs.

<?php

class Translation
{
    private string $content;

    private string $language;

    public function __construct(string $content, string $language)
    {
        $this->content = $content;
        $this->language = $language;
    }
}

interface TranslationService 
{
    public function translate(string $string, string $language) : Translation;  
}

W środowisku produkcyjnym implementacja będzie oczywiście oparta o jakiś protokół komunikacji typu Http natomiast w środowisku testowym moglibyśmy spodziewać się jakiegoś Fake'a.

Jeżeli nie wiesz czym jest Fake, poczytaj o Test Doubles.

Teoretycznie moglibyśmy bezpośrednio w testach integracyjnych inicjalizować taki fake. Moglibyśmy też czerpać go bezpośrednio z service containera (o ile taki posiadamy).

Istnieje jednak czystsze rozwiązanie, które przy okazji odizoluje nasz test suite od Service Containera (czyli de facto od frameworka, którego używamy).

W pierwszej kolejności musimy jednak zdefiniować punkt styku wszystkich elementów. Takim punktem styku będzie coś, co roboczo nazwiemy IntegrationTestCase. Ja osobiście nazywam taką klasę od nazwy modułu i wkładam do odpowiedniego namespace'a (Integration czy też Functional). Przykładowo Blog/Test/Integration/BlogTestCase.

Jeżeli testujemy za pomocą PHPUnit'a taki IntegrationTestCase będzie prawdopodobnie dziedziczył z innego TestCase'u. Na przykład KernelTestCase jeżeli pracujemy z Symfony.

W ten sposób dostajemy bezpośrednio dostęp do Service Containera (którego jednak nie wystawiamy nigdzie dalej), czyli de facto do całego frameworka.

Service Container to miejsce, z którego wyciągniemy zarówno SDK odpowiednie dla danego środowiska jaki i również nasz system/moduł. Lepiej jednak aby nie przenikał do testów, w ten sposób uzależnimy cały test suite od frameworka. Wprowadzając IntegrationTestCase po którym dziedziczymy, który sam dziedziczy po KernelTestCase wprawdzie zależność dalej istnieje, ale jeżeli w żadnym teście nie odwołamy się do Service containera, zależność ta stanie się nieistotna.

Zwizualizujmy wszystko co napisałem.

<?php

use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;

class IntegrationTestCase extends KernelTestCase 
{
    protected TranslationContext $translationContext;

    protected BlogContext $blogContext; 

    protected function setUp() : void
    {
        $kernel = parent::bootKernel();

        $this->translationContext = new TranslationContext(
            $kernel->getContainer()->get(TranslationService::class)
        );       

        $this->blogContext = new BlogContext(
            $kernel->getContainer()->get(Blog::class)
        );   
    }
}

BlogContext oraz TranslationContext to jedyne rzeczy, z których testy dziedziczące z IntegrationTestCase mogą korzystać. Tylko w ten sposób nie dopuścimy do couplingu między testami a frameworkiem.

Wracając do testów, wyglądałoby to mniej więcej tak:

<?php

class BlogPostTranslationTest extends IntegrationTestCase
{
    public function test_something_related_to_translation() : void
    {
        $this->translationContext->setTranslation('en', 'witam', 'welcome');

        $this->blogContext->facade()->doSomething();

        $this->assertThat($this->blogContext->facade()->querySomethingIn('en'), 'welcome'); 
    }
}

Przykład oczywiście bardzo naiwny pokazuje jednak o co chodzi. Po pierwsze nigdzie nie odwołujemy się do service containera. Redukując przy tym zależność od frameworka praktycznie do zera. Po drugie nie operujemy na Fake'u TranslationService tylko na kontekście, który go ładnie przykrywa. Po trzecie zwiększamy czytelność testu usuwając nieistotne detale techniczne.

W powyższym przykładzie można też zauważyć, że BlogContext posiada metodę facade, która zwraca nic innego jak fasadę modułu blog. Więcej o Fasadach można przeczytać w poprzednim wpisie.

Kolejnym bardzo fajnym przykładem przydatnego kontekstu może być CalendarContext czy ClockContext, szczególnie kiedy zachowanie systemu/modułu zależne jest od czasu. Dzięki niemu w każdym momencie możemy ustawić datę i czas w jakiej obecnie chcielibyśmy aby znalazł się system.

Jeżeli szukasz prostej, obiektowej abstrakcji daty i czasu zachęcam do sprawdzenia biblioteki Aeon Calendar, którą rozwijam.

No dobra, ale co to wszystko ma do modularnego monolitu?

Teraz kiedy mamy ujednolicone pojęcia techniczne, mogę po prostu powiedzieć, że każdy moduł naszego modularnego monolitu, który komunikuje się z innymi modułami powinien robić to za pośrednictwem kontekstu.

Moduł w ramach którego piszemy testy będzie posiadał więc kontekst samego siebie oraz po jednym kontekście na każdy moduł z którym wchodzi w interakcję.

<?php

class ContextCTest extends IntegrationTestCase
{
    public function test_interaction_between_modules() : void
    {
        $this->moduleAContext->setSomething();
        $this->moduleBContext->setSomethingElse();

        $this->moduleCContext->module()->doSomething();

        $this->assertTrue($this->moduleCContext->module()->querySomething()); 
    }
}

Powyższy test jest testem modułu C. Moduł C zależny jest od modułu A i B, test wymaga, aby te moduły znalazły się w odpowiednim stanie. Stan ten ustawiany jest za pomocą kontekstów, następnie wykonywana jest operacja oraz asercja.

Tylko po co to wszystko?

Podczas ewentualnego wydzielania modułów A oraz B jako oddzielnych mikroserwisów, wszystkie testy w które zamieszane były te moduły automatycznie się zdezaktualizują. Im więcej szczegółów technicznych danego modułu przeniknie do testu, tym więcej kodu trzeba będzie przepisać. Dzięki kontekstom w zasadzie cała praca sprowadzi się do przygotowania odpowiedniego Fake'a (tak, jeżeli moduł wyleci z modularnego monolitu jego rolę w testach przejmie reprezentujący go Fake). i przepisaniu kontekstu, czyli jednej klasy.

Cała ta zabawa ma na celu jedynie uniknięcie zabetonowania modułów testami. Tak jak już pisałem w poprzednim wpisie odnośnie do wprowadzenia do modularnego monolitu, jest to faza przejściowa, przygotowująca system na ewentualną migrację w stronę mikroserwisów. Migrację do której może nigdy nie dojść, jednak nawet jeżeli tak się stanie i system pozostanie w formie modularnego monolitu po kres swoich dni, konteksty oraz odpowiednia separacja nie tylko poprawią czytelność, ale też ułatwią utrzymanie testów w odpowiedniej formie.


Masz pytanie?
Napisz: [email protected]
Akceptuję

Ten serwis używa plików cookies. Więcej o plikach cookies.