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.

Testy w PHP - Object Mother post

2018-09-25

Pisanie testów nie jest łatwe, tak samo jak pisanie kodu. Utrzymywanie testów jest jeszcze trudniejsze, tak samo jak utrzymywanie kodu. Pisanie czytelnych testów tak samo jak pisanie czytelnego kodu to sztuka. Mother Object to wzorzec który znacząco ułatwia te niezwykle skomplikowane procesy. Na pierwszy rzut oka nie różni się on niczym od zwykłej fabryki, jego cel jest niemalże identyczny, skomponować obiekt. Chodzi jednak o obiekt który wykorzystany będzie jedynie w testach.

Zacznijmy od jakiegoś bardzo prostego przykładu, powiedzmy że piszemy grę, tą samą co w przykładzie dotyczącym funkcyjnego podejścia w PHP.

Przypomnę najpierw krótko jak wygląda podstawowa struktura gry.


final class Board { public function __construct(Tiles $tiles) {} public function click(Position $position) : void {} public function score() : int {} } final class Tiles { public function __construct(Tile ...$tiles){} public function count() : int{} public function has(Position $position) : bool{} public function hasMoreThan(Position $position, int $expectedLimit) : bool {} public function clickedTiles() : int{} public function map(callable $callback) : array {} public function each(callable $callback) : void {} public function find(callable $callback) : Tiles {} } final class Tile { public function __construct(Position $position){} public static function clicked(Position $position) : self{} public function position() : Position {} public function click() : self {} public function isClicked() : bool {} } final class Position { public function __construct(int $row, int $column) {} public function is(Position $position) : bool {} }

Jak widać nie są to jakieś skomplikowane byty, napisanie testów jednostkowych powinno być niezwykle proste.

Zacznijmy od Position.


final class PositionTest extends TestCase { public function test_comparing_two_identical_positions() : void { $position = new Position(1, 1); $this->assertTrue($position->is(new Position(1, 1))); } }

No i super, w zasadzie pozycja nie ma żadnej innej logiki która mogłaby nas interesować.
Popatrzmy zatem na Tile.


final class TileTest extends TestCase { public function test_tile_click() : void { $tile = new Tile(new Position(1, 1)); self::assertTrue($tile->click()->isClicked()); } public function test_that_tile_cant_be_clicked_twice() : void { $tile = new Tile(new Position(1, 1)); $this->expectException(\RuntimeException::class); $tile->click()->click(); } }

Wszystko dalej wygląda prosto i czytelnie prawda? W tym teście jednak jedna rzecz jest tylko zmienną. Chodzi o Position. W zasadzie dla sensowności tego testu wartość pozycji nie jest istotna. Dlaczego więc nie zwiększyć czytelności testu i jawnie powiedzieć „pozycja nie ma znaczenia”?

Stwórzmy najpierw PositionMother, obiekt matkę budujący dla nas pozycję.


namespace Example\ObjectMother\Game; use Example\Game\Position; final class PositionMother { public static function random() : Position { return new Position(\random_int(1, 100), \random_int(1, 100)); } }

A teraz ten sam test Tile, wykorzystujący Mother Object zamiast bezpośrednio budować Position.


namespace Example\Tests\Game; use Example\Game\Tile; use Example\ObjectMother\Game\PositionMother; use PHPUnit\Framework\TestCase; final class TileTest extends TestCase { public function test_tile_click() : void { $tile = new Tile(PositionMother::random()); self::assertTrue($tile->click()->isClicked()); } public function test_that_tile_cant_be_clicked_twice() : void { $tile = new Tile(PositionMother::random()); $this->expectException(\RuntimeException::class); $tile->click()->click(); } }

Już na tak prostym przykładzie zyski z wykorzystania wzorca Mother Object są ogromne, przyjrzyjmy im się.

1) Lepsza czytelność intencji testu

Wszystkie testy piszemy w jakimś celu, im lepiej test obrazuje ten cel tym łatwiej się go potem analizuje oraz utrzymuje. W wypadku Tile od razu wiadome było, że Position nie ma znaczenia, chodzi tylko o to żeby było poprawne. Te wartości 1,1 od razu przykuwają wzrok, dobrze więc się ich pozbyć na rzecz ::random().

2) Większa podatność na modyfikacje, łatwiejsze utrzymanie

Kto utrzymywał jakikolwiek system, ten wie że nie istnieje coś takiego jak „skończona aplikacja”. Jeżeli system ma zarabiać, musi żyć. Życie systemu to ciągłe i nieustanne zmiany. Nietrudno więc wyobrazić sobie sytuację w której nagle musimy zmienić sposób konstruowania Position. Przenosząc odpowiedzialność budowania obiektu do Object Mother ograniczamy ilość miejsc które trzeba będzie w przyszłości poprawić a uwierzcie mi, takich miejsc, szczególnie w testach będzie wiele. Tak wiele, że w pewnym momencie uznacie, że zablokowaliście projekt testami bo zmiana kodu systemu zajmuje 15 minut a zmiany w testach kilka godzin.

3) Większa różnorodność testowanych przypadków

Możecie zauważyć, że tworząc Position użyłem funkcji random_int z konkretnego zakresu. Znam ten kod, wiem że Pozycja nie może być mniejsza niż 1 dlatego śmiało mogę założyć, że zakres od 1 do 100 będzie zawsze poprawny. Pozwala to podczas każdego uruchomienia testu wykonać go dla odrobinę innych kryteriów. Czy to aby na pewno jest dobre? Myślę, że zalety przeważają nad potencjalnymi wadami. Pisząc testy bardzo łatwo zafiksować się na konkretne wartości, które przez zupełny przypadek będą dawały fałszywie pozytywne rezultaty. Takie testy są bardzo niebezpieczne, w rzeczywistości nic nie testują. Jeżeli jednak podstawowe obiekty służące do przygotowania kontekstu dla testu za każdym razem się zmieniają (będąc jednocześnie zawsze poprawne z punktu widzenia biznesowego) o wiele łatwiej będzie wyłapać takie sytuacje. 
Zwiększa się też prawdopodobieństwo przypadkowego nie przechodzenia danego testu aczkolwiek tylko jeżeli „random” nie będzie budował poprawnego obiektu lub kiedy „poprawność” obiektu nie będzie odpowiednio zabezpieczona.

Wróćmy jednak do naszego kodum, zacznijmy od TileMother


declare (strict_types=1); namespace Example\ObjectMother\Game; use Example\Game\Tile; final class TileMother { public static function random() : Tile { return new Tile(PositionMother::random()); } public static function randomClicked() : Tile { return self::random()->click(); } }

Na początku skomponujmy random Tile oraz randomClicked Tile. Będą nam one potrzebne do przetestowania metody zliczającej kliknięte pola. Warto zauważyć, że tworzenie klikniętego Tile to po prostu wykonanie na Tile metody click, nie ma więc powodu żeby nie użyć metody metody random. Znowu minimalizujemy miejsca które trzeba będzie ewentualnie poprawić jeżeli zajdzie potrzeba zmodyfikowania konstruktora Tile lub Position.


final class TilesTest extends TestCase { public function test_counting_clicked_tiles() : void { $tiles = new Tiles( TileMother::random(), TileMother::random(), TileMother::random(), TileMother::randomClicked() ); $this->assertEquals(4, $tiles->count()); $this->assertEquals(1, $tiles->clickedTiles()); } }

Popatrzmy jednak na przykład testu w którym pozycja ma znaczenie. Napiszmy test sprawdzający czy kolekcja Tiles zawiera więcej niż spodziewana ilość Tile z konkretną pozycją.


final class TileMother { public static function random() : Tile { return self::withPosition(PositionMother::random()); } public static function withPosition(Position $position) : Tile { return new Tile($position); } }

Zaczniemy od dodania nowej metody do TileMother. Na pierwszy rzut oka może wydawać się zbędna ale pamiętajmy, że nie myślimy jedynie o tym co tu i teraz ale też o tym co za X miesięcy czy lat. Tworząc tą metodę nie dokładam sobie jakiejś niesamowitej ilości pracy a w przypadku gdyby trzeba było zmienić sposób konstruowania Tile możemy go sporo zaoszczędzić. Przy okazji poprawimy trochę metodę random, to jeszcze bardziej ograniczy ilość miejsc w których bezpośrednio używamy konstruktora.


final class PositionMother { public static function create(int $row, int $column) : Position { return new Position($row, $column); } public static function random() : Position { return self::create(\random_int(1, 100), \random_int(1, 100)); } }

Jednak najpierw poprawmy delikatnie PositionMother


final class TilesTest extends TestCase { public function test_comparing_count_of_tiles_with_specific_position() : void { $position = PositionMother::create(1, 1); $tiles = new Tiles( TileMother::withPosition($position), TileMother::withPosition($position) ); $this->assertTrue($tiles->has($position)); $this->assertTrue($tiles->hasMoreThan($position, 1)); $this->assertFalse($tiles->hasMoreThan($position, 2)); } }

W tym teście również moglibyśmy bezpośrednio konstruować obiekty Tile i Position jednak skorzystanie z Object Mothers znowu pozwala nam ograniczyć bezpośrednie wykorzystanie konstruktorów. Warto zaznaczyć również iż TileMother nie jest bezpośrednio zależny od Position, odwołuje się on jedynie do PositionMother więc zmiana Position nie powinna mieć większego wpływu na TileTest oraz TilesTest. Gdyby Position wszędzie tworzone było za pomocą new już teraz dodając kolejny argument do konstruktora (co nie jest wcale jakimś wydumanym przypadkiem brzegowym) musimy poprawić jedynie PositionTest oraz Position.

Najważniejsze składowe planszy mamy już omówione, przejdźmy więc do BoardTest. Łatwo domyślić się, że przechodząc kolejny poziom wyżej zaczniemy od TilesMother. Muszę tu zwrócić uwagę, że Object Mother nie powstaje razem z obiektem. Najpierw piszemy testy, tworzymy obiekt, zgodnie z TDD i kiedy wszystko jest gotowe idziemy wyżej. Będąc już przy testowaniu czegoś co używa naszego obiektu możemy śmiało tworzyć Object Mother.


namespace Example\ObjectMother\Game; use Example\Game\Tiles; final class TilesMother { public static function withoutDuplicates(int $columns = 10, int $rows = 10) : Tiles { $tiles = []; for ($x = 0; $x < $columns; $x++) { for ($y = 0; $y < $rows; $y++) { $tiles[] = TileMother::withPosition(PositionMother::create($x, $y)); } } return new Tiles(...$tiles); } }

Wróćmy jednak do testów. Najważniejszym dla całego systemu testem w naszym przypadku będzie zliczanie punktacji. W tym teście liczy się aby zbudować poprawną planszę (z niepowtarzalnymi polami), kliknąć w nią oraz zliczyć punktację będącą sumą poprawnych klików.


namespace Example\Tests\Game; use Example\Game\Board; use Example\ObjectMother\Game\PositionMother; use Example\ObjectMother\Game\TilesMother; use PHPUnit\Framework\TestCase; final class BoardTest extends TestCase { public function test_calculating_score_from_clicked_tiles() : void { $board = new Board(TilesMother::withoutDuplicates($columns = 5, $rows = 5)); $board->click(PositionMother::create(1, 1)); $board->click(PositionMother::create(1, 2)); $board->click(PositionMother::create(1, 3)); $this->assertEquals(3, $board->score()); } }

Dla porównania popatrzmy jak mógłby wyglądać BoardTest bez wykorzystania Object Mothers.


final class BoardTest extends TestCase { public function test_calculating_score_from_clicked_tiles_old_school() : void { $tiles = []; for ($x = 1; $x < 5; $x++) { for ($y = 1; $y < 5; $y++) { $tiles[] = new Tile(new Position($x, $y)); } } $board = new Board(new Tiles(...$tiles)); $board->click(PositionMother::create(1, 1)); $board->click(PositionMother::create(1, 2)); $board->click(PositionMother::create(1, 3)); $this->assertEquals(3, $board->score()); } }

Nie ma wprawdzie wielkiej tragedii, dalej da się ten test zrozumieć jednak musimy pamiętać, że testowany przez nas obiekt jest banalny. Widać również, że test pełny jest rozproszeń, zmiennych której zaciemniają intencję. Widać to idealnie na przykładzie metody withoutDuplicates. Od razu wiadomo, że do przetestowania Board musimy mieć kolekcję Tiles bez duplikatów. O wiele trudniej jest wyciągnąć takie wnioski patrząc na pętlę w pętli.

Warto też zwrócić uwagę na to, że teraz BoardTest stał się bezpośrednio zależny od Position, Tile oraz Tiles. Niby niewiele aczkolwiek 3 zależności kontra jedna, TilesMother brzmi zdecydowanie gorzej. Trzeba pamiętać, że wraz z wzrostem poziomu skomplikowania testowanych obiektów będzie też rosła liczba zależności. Niekontrolowane zależności bardzo trudno utrzymać w ryzach i nawet najmniejsze zmiany mogą stać się przez nie uciążliwe.


Masz pytanie?
Napisz: kontakt@zawarstwaabstrakcji.pl
Akceptuję

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