sobota, 7 stycznia 2023

Weryfikacja stanu obiektu gdy stan nie jest ujawniony

 

Spotkałem się ostatnio z ciekawym przypadkiem weryfikowania wyniku testu jednostkowego. Testowaniu została poddana void'owska metoda Agregatu i nijak nie można było wyciągnąć na zewnątrz stanu obiektu, który powinien ulec zmianie w wyniku wywołania metody under test, by można było go zweryfikować z oczekiwaniami. Agregat nie posiadał powiązanych z tym stanem getter'ów, wygenerowane event'y nie mogły zostać jawnie zwrócone poza obiektu (\Prooph\EventSourcing\AggregateRoot tego nie udostępniał) i żadna inna metoda jakkolwiek nie wyrzucała stanu na zewnątrz. Dodanie takich funkcji na siłę, tylko na potrzeby wykonania testu jednostkowego wydawało się nadużyciem więc też takie rozwiązanie nie wchodziło w grę.

Przez powyższe problemy, trzeba było wykazać się pomysłowością by napisać test - kolega z zespołu wpadła na takie oto rozwiązanie:

public function test(): void
{
    $objectUnderTest = new Person('Jan', 'Kowalski');

    // When
    // ...

    Closure::fromCallable(function () {
        TestCase::assertSame('Jan', $this->firstName);
        TestCase::assertSame('Kowalski', $this->lastName);
    })->bindTo($objectUnderTest, Person::class)();
}

Gdy pierwszy raz zobaczyłem takie podejście byłem zaskoczony że tak się da, zaraz potem uznałem że tak nie powinno się robić. Tyle raz powtarzano, że testowaniu powinno podlegać jedynie publiczne API obiektu, a jego wewnętrzny stan powinien zostać w ukryciu... i jest to mądre podejście. Dzięki temu mamy wolną rękę w przeprowadzaniu refaktoru - raz napisany test, daje nam możliwość weryfikowania czy ulepszanie wewnętrznej struktury obiektu po zmianach działa dalej tak jak tego oczekujemy.   

To rozwiązanie łamię zasadę, wiążąc test z ukrytymi przed resztą kodu produkcyjnego wnętrznościami Agregatu. Potem jednak zacząłem się zastanawiać na ile realnie problematyczny będzie on w utrzymaniu i czy czasem od dawna nie mierzymy się z tego typu problemami.

🔒 Kod produkcyjny dalej nie wie o stanie Agregatu

Niewątpliwą zaletą zastosowania metody Closure::bindTo jest fakt, że nie wpływa ona w żadnym stopniu na kod produkcyjny. Nie utworzyliśmy specjalnych getter'ów wykorzystanych tylko na potrzeby testów co w przyszłości mogłoby skłonić innych developerów do skorzystania z nich. W pewnym stopniu getter jest formą udostępniania wartości 1:1 z tym co jest wewnętrznym stanem obiektu. Dodanie getter'a wygląda jak rozszerzenie publicznego API, ale tylko na pozór. Bo jeżeli zmienimy wewnętrzny stan to będziemy musieli zmienić owego getter'a. Można by zastanowić się nad innymi realnymi problemami z tym związanymi, ale jest to temat na inny wpis.   

W każdym razie wydaje mi się, że problem tworzenia takich getter'ów istnieje w światku PHP, jest stosunkowo często stosowany i tylko sprawia wrażenie niegroźnego - dlatego powinniśmy zwracać na to uwagę. 

Stosowanie Closure::bindTo zamias dedykowanych getterów chroni kod produkcyjny.

🇬🇧 Szkoła Londyńska

Pisząc test jednostkowy klasy typu serwis (np. Event Subscriber'a, Command Handler'a) według paradygmatów tz. Szkoły Londyńskiej musimy najpierw zamockować jego zależności. Można to zrobić za pomocą customowych implementacji InMemoryRepository bądź przy użyciu narzędzi takich jak Prophecy/MockObject.

I tutaj właśnie zacząłem dostrzegać podobieństwo względem opisywanego przykładu. Dokładnie wiemy jak w teście jednostkowym należy zbudować zależność wymaganą przez object under test oraz jak będzie wyglądała interakcja pomiędzy zależnością, a testowanym obiektem.

Musimy jawnie oprogramować to związanie by test mógł przejść na zielono. Test oczywiście weryfikuje publiczne API serwisu, ale wie też coś o jego wewnętrznej pracy. Zmieniając zależności serwisu będziemy musieli zaktualizować testy - dokładnie tak samo jak w opisywanym przypadku wykorzystania Closure::bindTo.

Jest jednak subtelna różnica, w przypadku Closure::bindTo funkcja anonimowa musi dokładnie wiedzieć w jakim polu znajduje się wartość czyli pożądana przez nas zmiana stanu. W przypadku weryfikowania mock'ów zaś, ta informacja w dalszym ciągu jest przed nami ukryta, ale niewątpliwie w jednym i drugim przypadku odwołujemy się podczas wykonywania asercji do wewnętrznego stanu obiektu.   

Jak widać ten problem towarzyszył w projekcie w którym pracuje praktycznie od samego początku jego powstania, a mimo to dało się z nim żyć - co więcej - nikt nie uznawał tego za problematyczne. W opisywanym przypadku pojawił się jedynie w nieco innej formie (inny typ testowanego obiektu), ale to dalej nic nowego z czym wcześniej się nie borykaliśmy.  

🗃 Dla jakich typów obiektów?

Na samym początku muszę zauważyć, że chodzi o zmianę stanu obiektu dlatego z tego rozwiazania należałoby korzystać tylko w przypadku testowania metod void'owskich. Takich metod nie posiadają obiekty typu:

❌ Event
❌ DTO
❌ Value Object

Serwisy z metodami void'owskimi też weryfikujemy w inny sposób - sprawdzając ich z'mock'owane zależności, dlatego kolejno odpadają nam:

❌ Event Subscriber
❌ Command Handler
❌ Serwis Aplikacyjny/Domenowy

To co właściwie pozostało to:

✅ Encja
✅ Agregat 

Lecz tylko w przypadku gdy takowe już nie udostępniają swojego stanu dla innego kodu produkcyjnego!

🩻 Inny sposób udostępniania stanu

Taki sam efekt możnaby osiągnąć stosując refleksje:

public function test(): void
{
    $objectUnderTest = new Person('Jan', 'Kowalski');

    // When
    // ...

    $reflection = new ReflectionClass(Person::class);
    $firstNameProperty = $reflection->getProperty('firstName');
    $firstNameProperty->setAccessible(true);
    $lastNameProperty = $reflection->getProperty('lastName');
    $lastNameProperty->setAccessible(true);

    self::assertSame('Jan', $firstNameProperty->getValue($objectUnderTest));
    self::assertSame('Kowalski', $lastNameProperty->getValue($objectUnderTest));
}

W porównaniu z zastosowaniem Closure::bindTo wypada podobnie jeżeli chodzi o podejście czy konsekwencje dla potencjalnego refaktoru. Więcej linii kodu negatywnie wpływa na czytelność i w wątpliwość możemy poddawać szybkość działania, co nie powinno stanowić problemu gdy ten kod tak czy inaczej nie będzie działał produkcyjnie. 

♻️ Warstwa abstrakcji na asercję

Można by dodatkowo schować taką implementację przed testem w customowej klasie Assert. Jeżeli z biegiem czasu pojawi się jakiś sposób na lepsze udostępnianie stanu niż za pomocą Closure::bindTo lub refleksji to sama klasa testowa nie będzie wymagała modyfikacji. Dodatkowo wprowadzenie specjalnej klasy asercji wpłynie na poprawę czytelności samego testu.

public function test(): void
{
    $objectUnderTest = new Person('Jan', 'Kowalski');
    
    // When
    // ...
    
    PersonAssert::sameName('Jan', 'Kowalski', $objectUnderTest);
} 

📝 Podsumowanie

Koniec końców uważam, że zastosowanie Closure::bindTo było dobrym pomysłem. Pomimo tego, że udostępniamy prywatny stan na zewnątrz, napisany test jednostkowy przynosi wartość. Co prawda jesteśmy mniej odporni na zmiany klasy poddanej testom, ale z drugiej strony nie mieliśmy zbytniego wyboru. Moglibyśmy użyć testów integracyjnych lecz nie są one tak precyzyjne jak testy jednostkowe. Niekiedy nie dysponujemy takim rozwiązaniem w projekcie co uniemożliwiłoby całkowicie otestowanie takiej klasy. Korzystanie z Closure::bindTo powinno być ograniczone tylko do absolutnej konieczności gdy nie mamy innego wyboru, jest ono obarczone pewnym obciążeniem, ale na tyle małym by w dalszym ciągu utrzymać wszystko w ryzach. 

Jedyne nad czym trzeba się zastanowić to czy weryfikacja działania obiektu jest więcej warta niż poniższe efekty uboczne:

⚠️ zmiana wewnętrznego stanu wymaga naprawienia testu
⚠️ zmiana nazwy pola sprawi że komunikat nieprzechodzącego testu nie będzie do końca jasny (UndefinedProperty)
⚠️ narzędzia do analizy statycznej mogą zgłaszać błąd w związku z nieznanym polem

    

 

Brak komentarzy:

Prześlij komentarz