Testování PHP kódu

7 min | by 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:

  • redukuji objem kódu v jednom testovacím scénáři (jsem veden vyčlenit si helpery mimo samotný test),
  • snižuji komplexitu testovacího stacku (jsem veden řešit závislosti správným směrem – jak na to, se rozepíši později v článku).

Test má 3 složky

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

  • definice výchozího stavu
  • přechodu do jiného stavu
  • následně validace konečného stavu

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ě

  • Pro rozšíření testu jen přidám @dataProvider, extrahuji parametr 5 a očekávanou hodnotu xyz.
  • Vše co test obsahuje, je na jednom místě. Detaily jsou skryté za voláním metod.
public function testFoo()  : void
{
    $bar = $this->createMockBar(5);
    $service = new Service($bar);

    $result = $service->foo();

    Assert::equals('xyz', $result);
}

Špatně

  • Pro rozšíření testu musím udělat novou třídní proměnnou a zduplikovat kód testu.
  • V testu není na první pohled patrné, jak je definován počátečná stav.
  • Motivací bývá většinou snaha o znovupoužitelnost objektu (mocku, služby, value-objectu), avšak není pro ni žádný důvod. V praxi vůbec ničemu nevadí si pro každý běh testu objekty vytvářet.
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:

  • Vyčlením definici výchozího stavu do Data Providerů.
  • Kód na přípravu stavu rozkouskuji do metod, které případně obalím factory metodou.
  • Samotný přechod stavu redukuji ideálně jen na volání jediné metody.
  • Asserty oddělím vizuálně od zbytku prázdným řádkem.
/**
 * @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:

  • value objecty,
  • stateless služby – jejich metody tudíž vždy vracejí pro konkrétní vstup stejný výstup.

Naopak mockuji:

  • služby, které sahají na nějaký stav nebo komunikují mimo aplikaci (disk, databáze, api, …),
  • jakékoliv objekty, které mají složitý strom závislostí a je jednodušší je vymockovat, než sestavit jejich závislosti.

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:

  • Zapsání 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.
  • Možnost clearovat globální stav na jednom místě, když pracuji s nějakou legacy codebase. Například 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.

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:

  • každý jeden běh testu musí mít svou vlastní instanci containeru,
  • metoda createContainer() musí v testu vždy vrátit nově sestavený container,
  • container není nikdy v $this->containerTestCase třídě. Když je náhodou potřeba (ale nemělo by), tak se předává argumentem metody.

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.

  • TestCase třída má asi milión metod, které vůbec mít nemá a ve kterých se nikdo nevyzná.
  • Dobrým příkladem jsou asserty, které je zvykem (i když jsou statické) volat na $this->assertXyz(...).
  • Mockování:
    • je ukecané,
    • mock-builder se zase volá z kontextu – $this->getMockBuilder(...),
    • mocky defaultně nezakrývají metody, takže když zapomenu metodu nadefinovat, zavolá se původní.
  • Samotný framework je hrozně složitý – když potřebuji zdebugovat nějaké divné chování, utápím se v tom.
  • Neumí paralelizaci out-of-the-box. Doporučuji podívat se na tento článek.

Když musím používat PHPUnit

  • Helpery si píši jako traity. Jsou context-aware a je mnohem lepší traitit než dědit. Doporučuji přečíst si na toto téma článek od Kora Nordmanna.
  • Snažím se alespoň o to, abych mohl používat jiný mockovací framework (osobně fandím Mockery).

Separuji testy podle typu a paralelizuji

  • Každý jeden test spouštím ve vlastním procesu. Legacy kód často obsahuje špinavosti, které ovlivňují globální stav aplikace, a zajistit 100% vyčištení kontextu po každém testu v tearDown za tu práci nestojí.
  • Snažím se paralelizovat už od prvního testu. I když v případě legacy kódu to bývá težké. Čím víc se paralelizace odloží, tím těžší následně je. Následné hledání, kde na sobě testy závisí, je hledání jehly v kupce sena.
  • Spouštím unit testy odděleně od těch ostatních, které používají databázi a podobně. Když failnou některé z unitových testů, tak ty ostatní už ani nespouštím.

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

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ě,
  • adresářová struktura ve složce tests kopírovala strukturu aplikace,
  • stejně tak namespacy, ty však začínají root namespacem Tests.

Používám PHPStorm IDE

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

  • Kdy používat pro assert konečného stavu snapshoty a kdy nikoliv? Jsou snapshoty vůbec dobrý nápad?
  • Pohrávám si s myšlenkou, že pro větší monolitické aplikace by každý modul aplikace měl mít svou vlastní tests složku. V extrémním případě by každá třída měla test třídu hned vedle sebe.

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.