Testování aplikací není vždy tak snadné, jak se na papíře jeví. Svojí zkušeností jsem dospěl k několika zásadám a postupům, které se mi osvědčily a které se tu pokusím sepsat a částečně i zdůvodnit. Pomáhají mi k psaní čítelnějších a udržovatelnějších testů. Za hlavní přínos pak považuji snadnou rozšiřitelnost testů, jejíž potřeba přichází s rozšiřováním fukcionality projektu.
Psaní testů do tříd
TestCase
tedy považuji jen za syntactic sugar testovacích frameworků, což poskytuje jistý komfort (setUp
,tearDown
,@dataProvider
).
Z těchto základů jsem si pak vyvodil několik zásad.
TestCase
třídy bezstavověNepíši žádné $this->someObject
s nějakými daty, mocky nebo testovanými subjekty. Vše předávám přes parametry
metod. Přidává to na přehlednosti a čitelnosti, a tak to usnadňuje pozdější rozšiřování testu.
Správně:
@dataProvider
, extrahuji parametr 5
a očekávanou hodnotu xyz
.public function testFoo() : void
{
$bar = $this->createMockBar(5);
$service = new Service($bar);
$result = $service->foo();
Assert::equals('xyz', $result);
}
Špatně:
public function setUp(): void
{
$this->mockBar = $this->createMockBar(5);
}
public function testFoo(): void
{
$service = new Service($this->mockBar);
$result = $service->foo();
Assert::equals('xyz', $result);
}
Do setUp()
dávám věci, které připravují prostředí pro test, například strukturu databáze. Nedávám tam ale už
insert testovacích dat, která jsou specifická pro daný scénář testu. Skryl bych tím totiž definici výchozího
stavu konkrétního scénáře.
Z těchto principů také přímo vyplývá, že
TestCase
třída je immutable. Protože není co měnit. ;)
Čím výrazněji jsou od sebe části testu odděleny a čím menší a jednodušší jsou, tím rychleji při čtení kódu pochopím, co test testuje.
Proto:
/**
* @dataProvider getDataForFooTest
*/
public function testFoo(string $expectdResult, string $valueForFoo, string $valueForBar): void
{
$bar = $this->mockBar($valueForBar); // Příprava výchozího stavu
$foo = $this->mockFoo($valueForBar);
$service = new Xyz($foo, $bar);
$result = $service->foo(); // Přechod
Assert::equals('xyz', $result); // Assertace výsledného stavu
}
Když musím kódu, který testuji, dodat nějaké závislosti (často namockované), vždy vytvářím factory metody.
Při sestavování závislostí dbám na to, abych praktikoval Dependency Injection skrze parametry factory metody a aby každá factory metoda vytvářela jen jednu věc.
Správně:
public function testXyz(string $expected, int $valueForBar): void
{
// Když budu chtít přidat $valueForBar2, upravím jen jedno místo.
$bar = $this->mockBar($valueForBar);
// Předávám už hotový objekt – tedy celou závislost. Factory metoda
// pak z vnějšího pohledu dělá jen jednu věc, vytváří mock Foo
// a je závislá na tom, aby dostala třídu typu Bar.
$foo = $this->mockFoo($bar);
$service = new Xyz($foo);
$result = $service->xyz();
Assert::equals($expected, $result);
}
public function mockFoo(Bar $bar): Foo
{
return Mockery::mock(Foo::class)->shouldRecieve('getBar')->andReturn($bar)->getMock();
}
Špatně:
public function testXyz(string $expected, int $valueForBar)
{
// Předává se pouze hodnota a factory metoda pak dělá dvě věci,
// z vnějšího pohledu vytváří mock pro Foo i pro Bar.
$foo = $this->mockFoo($valueForBar);
$service = new Xyz($foo);
$result = $service->xyz();
Assert::equals('expected', $result);
}
public function mockFoo(int $valueForBar): Foo
{
// Když budu chtít přidat $valueForBar2, budu muset upravit všechny metody po cestě.
$bar = $this->mockBar($valueForBar);
return Mockery::mock(Foo::class)->shouldRecieve('getBar')->andReturn($bar)->getMock();
}
Factory metody nemusí být vůbec definované na
TestCase
třídě daného testu, ale pokud se jedná o factorky určené jen pro konkrétní test, je praktické si je držet na jednom místě. Pokud je ale znovupoužívám, extrahuji je do helperů (v PHPUnit do traitů).
Mockovat je drahé. Je drahé mocky psát a je drahé je pak udržovat. Proto většinou nemockuji:
Naopak mockuji:
Hlavní zásadu kterou dodržuji je, že testy od sebe nedědím. Mít DatabaseTestCase
, ApiTestCase
a podobně,
je zneužití dědičnosti a cesta k obrovské třídě plné kódu, z kterého každý potomek využívá jen nějaký (a vždy jiný)
subset.
Ideální by bylo, kdyby všechny testy dědily přímo od TestCase
, který je ve frameworku. Avšak v praxi se mi osvědčilo
si pro testovanou aplikaci udělat abstract MyTestCase
a všechno dědit od něj.
Důvody pro toto porušení jsou:
Mockery::close()
do tearDown()
ve společném předkovi jen jednou, aby se neopakoval v každém testu,
kde se na to navíc snadno zapomene.Legacy_Class_Registry::clearStaticInMemoryCache()
a podobné perličky.A pak už být nekompromisní, žádná další vrstva dědičnosti. Takže test-třídy píši final
.
Zvyšuje čitelnost a zrychluje orientaci v kódu.
Špatně:
public function getDataForXyzTest(): array
{
return [
[true, 7, true],
[false, 3, false],
];
}
Správně:
private const USER_ONLINE = true;
private const USER_OFFLINE = false;
private const USER_ID_KAREL = 7;
private const USER_ID_FERDA = 3;
private const USER_ACTIVE = true;
private const USER_NOT_ACTIVE = false;
public function getDataForXyzTest(): array
{
return [
[self::USER_ONLINE, self::USER_ID_KAREL, self::USER_ACTIVE],
[self::USER_OFFLINE, self::USER_ID_FERDA, self::USER_NOT_ACTIVE],
];
}
Když test potřebuje container:
createContainer()
musí v testu vždy vrátit nově sestavený container,$this->container
v TestCase
třídě.
Když je náhodou potřeba (ale nemělo by), tak se předává argumentem metody.V aplikačním kódu nepíši new DateTime()
, time()
, NOW()
, rand()
.
Získávání nějakého „globálního“ stavu vždy obstarává služba.
Příkladem může být DateTimeFactory nebo:
class RandomProvider
{
public function rand(int $min, int $max): int
{
return mt_rand($min, $max);
}
}
V testech si pak tuto závislost namockuji a předám. V integračních testech upravím službu v DI Containeru:
/**
* @dataProvider getDataForXyzTest
*/
public function testXyz(..., \DateTimeImmutable $subjectTime): void
{
$container = $this->createContainer();
$dateTimeFactory = Mockery::mock(DateTimeFactoryImmutable::class);
$dateTimeFactory->shouldReceive('getNow')->andReturn($subjectTime);
$container->removeService('dateTimeFactory');
$container->addService('dateTimeFactory', $dateTimeFactory);
}
Ušetří to pár vrásek, letní-zimní čas a další magické chyby v testech.
PHPUnit má jednu výhodu: super integraci s PHPStorm IDE. Ale jinak je to bolest.
TestCase
třída má asi milión metod, které vůbec mít nemá a ve kterých se nikdo nevyzná.$this->assertXyz(...)
.$this->getMockBuilder(...)
,tearDown
za tu práci nestojí.Většinou se držím toho, aby:
TestCase
třídy kopírovaly třídy v aplikaci (src/A/B/Service.php
+ tests/A/B/ServiceTest.php
), testXyz
metody kopírovaly metody v testované třídě,tests
kopírovala strukturu aplikace,Tests
.[Ctrl]
+ [Shift]
+ [T]
pro: Navigating Between Test and Test Subject.tests
složku. V extrémním případě by každá třída měla test třídu hned vedle sebe.Napadá vás nějaký dobrý practice, který jsem nezmínil?
Tweetněte mi ho. ;)