🐘

Jak psát Unit Testy (v PHP)

Před lety jsem školil, jak psát testy v PHP. Nejen unit testy, ale i ostatní. Říkám si, že k tomuto typu školení se už vracet asi nebudu, ale protože je hodně vývojářů, kteří nepíšou testy, ikdyž by měli, mohlo by být praktické některé myšlenky z mého školení sdílet.

90% obsahu bude naprosto nezávislé na PHP, ale příklady napíšu v PHPku.

Co je Unit Test

Existuje škála toho, jak velký kus systému v jednu chvíli testujete. Na jedné straně budete testovat úplně celou aplikaci od UI až po práci s databází, kdy máte jistotu, že všechno funguje dohromady, ale testy vám moc nepomáhají odhalit, kde přesně je chyba.

Na druhé straně máte testování nejmenšího kusu aplikace, který dává smysl testovat v co největší izolaci. Tím činíte extrémně jednoduché odhalit chyby, ale ztrácíte jistotu, že tenhle kus aplikace správně spolupracuje se zbytkem.

Ten nejmenší kus se nazývá Unit. Může to být jedna třída, jeden namespace, může to být kombinace 1 public třídy a X servisních tříd, které existují jen pro tu 1 public třídu. (PHP nemá privátní třídy jako syntaktický konstrukt, ale architektonicky asi každý chápete, že můžou existovat třídy, které se nikdy nedostanou mimo vlastní namespace)

Unit testy jsou populární, protože programátorovi usnadňují práci v okamžiku, kdy implementuje určitý kousek funkcionality. Místo toho, aby var_dumpoval, může psát testy a ty mu zůstanou navždycky.

Co přesně testovat?

Budu se tady soustředit na objektově orientované PHP. Sice v PHP nemusíte objekty používat, ale upřímně věřím, že skoro každý vážně míněný nový (novější než PHP 5.0) projekt v PHP objekty používá.

Máme tedy objekty, které mají nějaký vnitřní stav, nějaké závislosti a metody. Testujeme chování objektu/třídy, když je zavolána nějaká metoda.

Když zavoláme metodu, může se stát jedna nebo více z následujících věcí:

  • Metoda vrátí hodnotu (prostřednictvím return nebo yield)
  • Objekt změní svůj vnitřní stav
  • Metoda způsobí side effect
  • Metoda zavolá jiný objekt
  • Metoda vyhodí výjimku

Ve všem z toho může být chyba. Vše z toho je tedy dobré testovat, pokud tam některý z daných efektů existuje. Někteří lidé preferují netestovat vnitřní stav a chovat se k objektu jako k black boxu. To respektuju a pokud patříte mezi ne, tento bod si odstraňte.

Test funguje tak, že vytvoříte objekt a zavoláte metodu tak, abyste otestovali různé situace, které se obektu můžou stát.

Počet těch situací může být poměrně velký, pokud si vezmeme všechny možné průchody metodou a další podmínky.

Některé příklady jsou:

  • Větvení na podmínkách if, switch
  • Cykly foreach a while, kdy proběhnou 0krát vs víckrát (pokud inicializujete uvnitř scope proměnnou, průběh 0krát může způsobit chybu)
  • Předané parametry nebo závislosti můžou být null místo skutečné hodnoty.
  • Side-effecty můžou timeoutnout, soubory můžou být zamčené, API se může změnit a s ním i formát vraceného JSONu.

Eliminujete-li všechny situace a kombinace, které nemůžou nastat, dostanete výčet situací k otestování. I tak je ale ten výčet velký a vy nejspíš budete vybírat jen subset všeho, co se může stát. To je v pořádku.

Anatomie unit testu

Unit test má jasně určenou strukturu. Není potřeba nad tím přemýšlet a následující postup můžete dělat až do smrti a bude vám navždy fungovat:

  1. Určíte, kterou metodu chcete otestovat (může to být i constructor, nebo statická factory metoda).
  2. Bude-li metoda záviset na vnějším prostředí, dostanete to prostředí do kontrolovaného stavu (víte, v jakém stavu je). Pozor, toto je nutné dělat v inicializaci.
  3. Bude-li metoda volat jiné objekty, poskytnete Test Doubles (mocks, falešné objekty, které nahradí závislosti) - tedy izolujete objekt. Výjimku můžou tvořit objekty uvnitř stejného unit a DTO (Data Transfer Objecty) - tam nejspíš necháte ty originální
  4. Zavoláte testovanou metodu tolikrát, abyste demonstrovali všechny kombinace, které chcete otestovat (viz výše zmíněné if, switch, foreach/while 0krát vs Nkrát atd.).
  5. Pro každou kombinaci si položíte otázku po návratové hodnotě, vyhozených výjimkách, změně vnitřního stavu a vnějšího prostředí (side effect) a tam, kde k něčemu z toho má/nesmí docházet, napíšete test, který danou věc ověří.
  6. Volitelně na konci můžete udělat úklid. Pozor, žádný test nesmí záviset na tom, že jiný test něco uklidil. Vždy si připravujete prostředí v inicializaci testu. Tento úklid je vhodný spíš proto, aby se vám v testovací složce nehromadily temp soubory.

Ukázka objektu a testu

Řekněme, že máme kalkulátor a cache výsledků.

class Calculator
{
    private $lastResult;
    private $cache;

    public function __construct($cache)
    {
        $this->cache = $cache;
        $this->lastResult = null;
    }

    public function add($a, $b)
    {
        $result = $a + $b;
        $this->lastResult = $result;
        $this->cache->storeResult("add", $a, $b, $result);
        return $result;
    }

    public function subtract($a, $b)
    {
        $result = $a - $b;
        $this->lastResult = $result;
        $this->cache->storeResult("subtract", $a, $b, $result);
        return $result;
    }

    public function divide($a, $b)
    {
        if ($b == 0) {
            throw new \Exception("Division by zero");
        }
        $result = $a / $b;
        $this->lastResult = $result;
        $this->cache->storeResult("divide", $a, $b, $result);
        return $result;
    }

    public function getLastResult()
    {
        return $this->lastResult;
    }
}


class Cache
{
    private $storage = [];

    public function storeResult($operation, $a, $b, $result)
    {
        $this->storage[$operation][$a][$b] = $result;
    }

    public function getResult($operation, $a, $b)
    {
        return $this->storage[$operation][$a][$b] ?? null;
    }
}

A takto bude vypadat odpovídající test:

require_once 'vendor/autoload.php';
require_once 'src/Calculator.php';


use PHPUnit\Framework\TestCase;

class CalculatorTest extends TestCase
{
    private $calculator;
    private $cache;

    protected function setUp(): void
    {
        $this->cache = new Cache();
        $this->calculator = new Calculator($this->cache);
    }

    public function testAdd()
    {
        $this->assertEquals(8, $this->calculator->add(5, 3));
        $this->assertEquals(8, $this->calculator->getLastResult());
    }

    public function testSubtract()
    {
        $this->assertEquals(2, $this->calculator->subtract(5, 3));
        $this->assertEquals(2, $this->calculator->getLastResult());
    }

    public function testDivide()
    {
        $this->assertEquals(2, $this->calculator->divide(4, 2));
        $this->assertEquals(2, $this->calculator->getLastResult());
    }

    public function testDivideByZero()
    {
        $this->expectException(\Exception::class);
        $this->expectExceptionMessage("Division by zero");
        $this->calculator->divide(5, 0);
    }
}

Jak vidíte, cache jsem nemockoval (nenahradil žádným double), protože cache je vlastně součástí toho Unitu, který chci otestovat.

Typické problémy testovatelnosti

Existuje celá řada problémů, které můžou udělat kód obtížně testovatelný. Několik příkladů:

  • Kód není ve funkcích - typicky staré PHP (v dobách PHP 4) byl často mix kódu a HTML. Lepší je takový kód zavřít do funkcí a na konci souboru funkci zavolat.
  • Extrémně provázaný kód - situace, kdy kód míchá mnoho různých concerns, takže v jednom místě si vezme data z requestu, podle toho vytáhne data z databáze a vykreslí je v HTML. Lepší je kód rozdělit podle nějakého patternu, například MVC.
  • Statické metody, singletony, service lokátory, pevné závislosti - zkrátka situace, kdy nemáte možnost objektu vyměnit to, jak spolupracuje s jinými objekty. Lepší je nic z toho nepoužívat.
  • Globální stav - do objektu vstupují data cestou, která není ani parametr konstruktoru, ani žádné metody a tato situace není viditelná při používání objektu. Skoro každé použití klíčového slova global je špatně a je lepší ho nepoužívat.
  • Obtížné vytváření objektů - objekt může vyžadovat mnoho parametrů a závislostí. To se naprosto normálně stává u controllerů. Mimo controllery je to ale typicky ukázka toho, že je objekt už příliš velký a měl by být rozdělen na menší.
  • Příliš dlouhé metody - u takových metod pak dojdete k tomu, že počet možných kombinací, které můžou nastat, je extrémní. Padající test pak neusnadňuje hledání chyby tolik, jako u kratší metody. Řešení je psát kratší metody.
  • Nemožnost injectnout čas, náhodné hodnoty - jestli váš kód závisí na čase nebo náhodné veličině, nemusíte být schopni otestovat chování dané metody. Jedna možnost je předávat tyto hodnoty vždy parametrem, nebo mít protected metodu, kterou při testování přepíšeme vlastní implementací.
  • Neschopnost vysvětlit, co daný objekt má dělat - programátor, který nechápe, co má objekt přesně dělat, nemůže napsat test, který toto otestuje. Toto není code smell, ale zaslouží si to tady být. Nedorozumění mezi vývojářem a zákazníkem/produkťákem už nejspíš stálo svět biliony korun.

Jak začít testovat, když vaše aplikace má některé z těchto problémů

Jedna z dobrých knih o tomto tématu je: Michael Feathers Working Effectively with Legacy CodeMichael Feathers Working Effectively with Legacy Code

Vyšel i český překlad, Martinus Údržba kódu převzatých programůMartinus Údržba kódu převzatých programů, který ale nedoporučuju, protože se v knize překládají i programátorské termíny, což činí knihu méně čitelnou, než anglický originál.

Podstata ale je:

  1. Najděte místo, “ostrůvek”, který chcete zrefactorovat.
  2. Zrefactorujte ho a otestujte ho.
  3. GOTO 1

U toho má vývojář pracovat v krátkých cyklech, často pouštět testy a vytvářet mnoho ostrůvků čistého kódu. Nový kód má být také už psán čistě a postupně, jak je aplikace upravována, se celý kód přemigruje na čistý. Nejdříve ten, který je upravován nejvíce.