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.