Videa Blog

Testování PHP kódu

Petr Hejna  

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.

2 definice, kterých se držím

Test je blok kódu

K testu nepřistupuji jako ke třídě, k testu nepřistupuji jako k fukci K testu přistupuji jako k bloku kódu – jako ke scriptu. Následováním tohoto přístupu:

Test má 3 složky

Neustále si uvědomuji, že test se skládá z

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.

Píši 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ě

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. ;)

Pečlivě oděluji části testu

Čí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
}

Závislosti testovaného kódu a jejich skládání

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ů).

Kdy mockuji a kdy ne

Mockovat je drahé. Je drahé mocky psát a je drahé je pak udržovat. Proto většinou nemockuji:

Naopak mockuji:

Nedědím od sebe testy

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:

A pak už být nekompromisní, žádná další vrstva dědičnosti. Takže test-třídy píši final.

Pojmenovávám hodnoty v Data Providerech

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],
     ];
}

Dependency Injection Container vždy vytvářím čerstvý pro každý běh scriptu

Když test potřebuje container:

Zjištění aktuálního data, náhody, a podobně vždy předávám jako závislost

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.

Nepoužívám PHPUnit, když nemusím

PHPUnit má jednu výhodu: super integraci s PHPStorm IDE. Ale jinak je to bolest.

Když musím používat PHPUnit

Separuji testy podle typu a paralelizuji

Držím strukturu testů tak, aby kopírovala kód aplikace

Většinou se držím toho, aby:

Používám PHPStorm IDE

V čem nemám jasno / kacířské myšlenky

Závěrem

Napadá vás nějaký dobrý practice, který jsem nezmínil? Napište mi sem do komentářů nebo mi ho tweetněte. Díky!

Článek vyšel také na blogu autora.