Přejít k navigační liště

Zdroják » PHP » Testování v PHP: tvorba testovatelného kódu

Testování v PHP: tvorba testovatelného kódu

Články PHP

Co nám může přinést testování v praxi? Jak díky testování psát lepší kód? Nejen na tyto otázky se zaměříme v tomto díle seriálu, kdy se pokusíme refaktorovat špatně navrženou třídu do testovatelnější podoby.

Nálepky:

V minulých dílech našeho seriálu jsme se seznámili s problematikou testování kódu, vysvětlili si základní pojmy a vyzkoušeli testovací framework PHPUnit. V následujících dvou dílech se na toto téma podíváme z druhé strany a ukážeme si, jak díky testování psát lepší kód.

Začněme rozborem příkladu, který nás bude oběma díly provázet. Máme ne zrovna vhodně navrženou třídu Configuration, která nám abstrahuje práci s konfigurací. Konfigurační hodnoty uchovává jako pole klíč-hodnota, poskytuje get/set rozhraní a kromě toho umí načítat uložené konfigurace ze souborů INI nebo XML. Podotýkám, že některé části kódu jsou kvůli stručnosti značně zjednodušeny a slouží jen k ilustraci problému.

class Configuration
{
    private $filename;
    private $data = array();

    public function __construct($filename)
    {
        $this->filename = $filename;

        if (substr($this->filename, -3) == "ini") {
            $this->loadFromIni();
        } elseif (substr($this->filename, -3) == "xml") {
            $this->loadFromXml();
        } else {
            Logger::getInstance()->log("Unknown file type");
        }
    }

    public function get($name)
    {
        return isset($this->data[$name]) ? $this->data[$name] : null;
    }

    public function set($name, $value)
    {
        $this->data[$name] = $value;
    }

    private function loadFromIni()
    {
        // some dark magic ...
        $this->data = parse_ini_file($this->filename);
    }

    private function loadFromXml()
    {
        // some dark magic
        $xml = simplexml_load_file($this->filename);
        foreach ($xml->param as $param) {
            $this->data[$param['name']] = $param['value'];
        }
    }
}

Netestovatelné metody

Co se stane, pokud se rozhodneme tuto třídu pokrýt testy? Velice záhy narazíme na první problém – jak otestovat soukromé nebo chráněné metody? Možností se nabízí hned několik:

Možnost 1: změna viditelnosti (vše veřejné)?

Toto je asi nejhloupější řešení, které nás v danou chvíli může napadnout. Zapouzdření je jedním ze stavebních prvků OOP, neexistuje žádný argument pro obhajobu tohoto postupu.

Možnost 2: změna viditelnosti jen pro testy?

Že bychom na to šli „od lesa“ a viditelnost změnili jen pro testy? Třeba testováním dědičné třídy?

class ConfigurationForTests extends Configuration
{
    public function loadFromIni()
    {
        return parent::loadFromIni();
    }

    public function loadFromXml()
    {
        return parent::loadFromXml();
    }
}

Toto řešení je bohužel stejně hloupé jako předchozí. Možná ještě hloupější. Nejen, že opět porušujeme zapouzdření původní třídy, navíc si přiděláváme zbytečnou práci. Při jakékoli změně rozhraní původní třídy (např. změna počtu nebo pořadí parametrů metod) musíme změnit i rozhraní dědice. A samozřejmě i testy.

Možnost 3: změna viditelnosti pomocí reflexe?

$class = new ReflectionClass("Configuration");
$method = $class->getMethod("parseIni");
$method->setAccessible(true);

Do třetice… Tento postup se v praxi občas skutečně používá (hackování Singletonů), ale není o moc lepší než dva předchozí. To už můžeme viditelnost rovnou změnit na public a jsme zpět u možnosti 1.

Dělej jen jednu věc a dělej ji pořádně

Jaká je tedy správná odpověď? Jak tedy testovat chráněné metody? Asi už vás nepřekvapí, že: NIJAK. Chráněné nebo soukromé metody se zkrátka explicitně netestují. Všechny výše uvedené pokusy totiž řeší důsledek a nikoli příčinu. Vzpomínáte si na slovní popis třídy?

Uchovává konfiguraci, ke které poskytuje get/set rozhraní A umí ji načítat z INI A umí ji načítat z XML.

Co když budeme potřebovat umět načítat z YAML? Přidáme další A? Nějak moc spojek A, nemyslíte? Jinak řečeno – trochu moc práce na jednoho. Kdybychom logiku načítání ze souborů dokázali z naší třídy nějak vyjmout, nemuseli bychom problém s (ne)testovatelností chráněných metod vůbec řešit. Pojďme to zkusit.

Nejprve připravíme obecné rozhraní pro budoucí implementaci načítání za souborů. Detaily nás v tuto chvíli nezajímají. Metody load konkrétních tříd implementujících toto rozhraní budou vracet pole s načtenou konfigurací, žádné „triky na pozadí“.

interface Configuration_Loader {
    /**
     * @return array
     */
    public function load();
}

Z původní třídy vyjmeme vše, co se načítání ze souborů týká.

class Configuration
{
    private $data = array();

    public function __construct(Configuration_Loader $loader)
    {
        $this->data = $loader->load();
    }

    public function get($name)
    {
        return isset($this->data[$name]) ? $this->data[$name] : null;
    }

    public function set($name, $value)
    {
        $this->data[$name] = $value;
    }
}

Použitím jednoduchého refactoringu máme rázem čistější a bez problémů testovatelnou třídu Configuration, která má pouze jednu odpovědnost: poskytovat rozhraní k načtené konfiguraci. Tak trochu náhodou jsme právě poznali jeden ze základních principů objektového návrhu – Single Responsibility principle (zkráceně SRP). Ten říká přesně to, čeho jsme docílili – třída by měla mít jen jediný důvod ke změně, tedy jen jednu odpovědnost.

Kolizi s tímto principem poznáte docela snadno – nejste schopni popsat odpovědnost třídy bez spojky A. Dalším příznakem bývá nadbytek chráněných nebo soukromých metod. Třída poskytuje rozhraní, sestávající se z několika málo veřejných metod a „cosi si bastlí“ skrytě.

SRP je jedním z pětice základních principů objektového návrhu, který je označován zkratkou SOLID. Na toto téma psal na Zdrojáku výbornou sérii článků Martin Jonáš, doporučuji přečíst alespoň Návrhové principy: SOLID. Budete překvapeni, jak úzce spolu obě témata souvisí. Ale nepředbíhejme…

Problémy s rozšiřitelností

Refactoring dokončíme cestou nejmenšího odporu a původní logiku načítání ze souborů umístíme do nové třídy Configuration_FileLoader. Současně uděláme jen malou úpravu – metodám pro načítání z konkrétních typů souborů nastavíme viditelnost na public. Není důvod skrývat, že třída zabývající se načítáním ze souborů umí pracovat s více formáty.

class Configuration_FileLoader implements Configuration_Loader
{
    private $filename;

    public function __construct($filename)
    {
        $this->filename = $filename;
    }

    public function load()
    {
        if (substr($this->filename, -3) == "ini") {
            return $this->loadFromIni($this->filename);
        } elseif (substr($this->filename, -3) == "xml") {
            return $this->loadFromXml($this->filename);
        } else {
            Logger::getInstance()->log("Unknown file type");
        }

        return array();
    }

    public function loadFromIni($filename)
    {
        // some dark magic ...
        return parse_ini_file($filename);
    }

    public function loadFromXml($filename)
    {
        // some dark magic
        $xml = simplexml_load_file($filename);
        $data = array();
        foreach ($xml->param as $param) {
            $data[$param['name']] = $param['value'];
        }

        return $data;
    }
}

Vrhneme-li se na testování nové třídy, záhy narazíme na další problém – rozšiřitelnost. Pokud budeme chtít podporovat další typ souborů, např. JSON, pak to bude znamenat:

  • přidat metodu loadFromJson
  • upravit detekci typu souboru v metodě load
  • upravit testy metody load

První krok je celkem logický, ale sahat vždy do kódu, který je již otestovaný jen kvůli rozšíření funkčnosti třídy? To není moc dobré. Ti z vás, kteří četli např. výše zmiňovanou sérii článků, už tuší, že jde o kolizi s dalším principem objektového návrhu – Open Closed principle (OCP). Ten říká, že třída by měla být otevřená pro rozšiřování, ale uzavřená pro změny. Na první pohled se může zdát, že jde o protimluv, ale vysvětlení je jednoduché: měli bychom být schopni rozšiřovat funkčnost třídy bez zásahu do již hotového a otestovaného kódu. Tím je v našem případě metoda load. Pojďme tedy opět refaktorovat.

Společnou třídu Configuration_FileLoader roztrháme na sadu tříd, které budou implementovat požadované rozhraní Configuration_Loader, jehož metodu load upravíme – přidáme parametr s názvem souboru, ze kterého má být konfigurace načtena.

interface Configuration_Loader {
    /**
     * @return array
     */
    public function load($filename);
}

Implementujeme konkrétní třídy pro načítání konfigurace z INI a XML.

class Configuration_IniLoader implements Configuration_Loader
{
    public function load($filename)
    {
        // some dark magic ...
        return parse_ini_file($filename);
    }
}

class Configuration_XmlLoader implements Configuration_Loader
{
    public function load($filename)
    {
        // some dark magic
        $xml = simplexml_load_file($filename);
        $data = array();
        foreach ($xml->param as $param) {
            $data[$param['name']] = $param['value'];
        }

        return $data;
    }
}

Potřebujeme přidat možnost načítání ze souborů JSON? Žádný problém, přidáme třídu, která se o načítání bude starat, otestujeme si ji a můžeme vesele používat. Stávající kód ani jeho testy tato změna nijak neovlivní.

Co zbývá?

Možná se ptáte – kam se ale poděl kód, který se staral o rozlišení, který loader použít? Odpověď je nasnadě – to už není naše starost – je to odpovědnost volajícího, tedy kódu, který vytváří instanci třídy Configuration.

Pokud vás tato odpověď neuspokojuje, pak si postrádanou funkčnost můžete představit jako součást nějaké tovární třídy, která na základě přípony vrátí požadovaný loader:

class Configuration_LoaderFactory
{
    public function getLoader($filename)
    {
        if (substr($filename, -3) == "ini") {
            return new Configuration_IniLoader();
        } elseif (substr($filename, -3) == "xml") {
            return new Configuration_XmlLoader();
        } else {
            throw new Exception("Unknown file type");
        }
    }
}

Pozorným čtenářům jistě neuniklo, že v tovární metodě je namísto statického volání loggeru, vyvolána výjimka. Tím trošku předbíhám, na téma „peklo se Singletony“ se podíváme příště.

Komentáře

Subscribe
Upozornit na
guest
13 Komentářů
Nejstarší
Nejnovější Most Voted
Inline Feedbacks
View all comments
pav

Hezký článek, nutí to k zamyšlení.

Přesto se cítím nejistě u tvrzení, že se testují jen veřejné třídy. Protože často separuji do privátních metod část kódu jen za účelem lepší čitelnosti kódu. Přesto by mohly být tyto metody testovány nějakým jednotkovým testem.

Vyumělkovaný příklad: potřebuji ve veřejné metodě provést součet nějakého pole (pomiňme existenci php funkcí) a tento kód dám do privátní metody, abych udržel čitelnost té veřejné. Neopakuje se, neliší se, prostě jen pro čitelnost. Nedává mi smysl „separovat odpovědnost“ pro něco takového do vlastní třídy. Ale proč to netestovat?

Podbi

Vždycky je možnost otestovat veřejnou metodu, která provede i volání vnitřní privátní metody, která je tedy v tomto ohledu testy pokryta.
Já osobně v případech, kdy cítím potřebu otestovat nějakou privátní metodu nutně samostatně sáhnu po Reflekcích…

arron

Jsou případy, kdy se to opravdu hodí a pak je docela prima si pro takové věci dopsat podporu do nějaké testovací knihovny. Na druhou stranu, ke všem protected a private metodám se nutně musí být možné dostat přes některou z public metod. To znamená, že pokud se dobře otestují všechny usecase, tak by měly být otestované i všechny protected a private metody. No a pokud nejsou, tak pravděpodobně obsahují kód, který je možné odstranit, protože se nevolá :-)

Jan Machala

Ano jde to, ale většinou se počet průchodů tou veřejnou metodou tak, abych pokryl všechny (mezní) případy té volané privátní, znásobí. A tím i dost naboptná test :-(

arron

Jo, testy dokážou nabobtnat nehorázným způsobem :-) Ale je pravda, že na jednoduchý kód je jednoduchý test a na složitý kód je řádově složitější test. Takže když test opravdu nabobtná, je na čase se zamyslet nad tím, jestli příslušný kód není příliš složitý a nestálo by za to ho nějak zjednodušit/rozdělit. Já osobně vždycky přijdu na to, že to jde a že je to tak správně :-)

Martin Mystik Jonáš

Privátní metody jsou privátní proto, aby o nich okolní kód – ať už provozní nebo testy nevěděly. Hlavní motivace je aby se daly snadno změnit bez nutnosti měnit další kód. Aby se změny v kódu zbytečně nešířily do dalších částí. Unit testy by jednotky (třídy) měly testovat z vnějšího pohledu – tetovat chování jejich rozhraní. Neměla by se podrobně testovat jejich vnitřní struktura.

Ostatně testy píšeme primárně proto abychom mohli vnitřní strukturu měnit a něco (testy) nám hlídalo, že jsme přitom nerozbili něco ve vnějším rozhraní.

honza

Díky za zajímavé pokračování seriálu. Mně osobně také nepřijde optimální vůbec netestovat privátní metody. Navrhovaný postup „otestovat veřejné, které ty privátní volají“ mi připadá, že popírá princip unit testů, protože když to dovedu do extrému, tak bych mohl prostě spustit celý program a říct „ok, funguje, takže ty funkce co jsou uvnitř také fungují“.

Btw – nový systém diskuzí na Zdrojáku je evidnentně hardcore – nevyplním mail, dostanu se na stránku kde je „chyba“ a nic víc – absolutně žádný způsob řešení, například odkaz zpět na diskuzi abych nemusel celý příspěvek psát znovu, nic. Co se děje? WordPress z roku 1998?

Martin Hassman

Reaguji na ty komentáře, ta část ještě není dokončená, víme o tom a dostaneme se k tomu časem.

arron

Jo, na tohle jsou různé názory napříč celou php komunitou. Někteří berou jako unit celou třídu (a pak dává smysl testovat jednotlivé usecase třídy, čili public metody), někteří berou jako unit jenom jednu funkci (a pak se testování neveřejných metod v podstatě samo nabízí). Záleží na konkrétním případu, co se hodí a co už ne. Obecně jsem zatím došel k tomu, že pokud se opravdu respektuje SRP, tak jsou třídy tak krátké, že neveřejné metody není potřeba testovat :-)

Clary

Hlavně za poslední větu +1

BoneFlute

Jednotkové testy testují třídu a její metody. A z pohledu testů privátní metody vůbec neexistují.

Clary

Pročetl jsem zdejší komentáře a v podstatě všechny jsou pravdivé a zároveň nevyvracejí, co je napsáno v článku. Pokud mám ve třídě mnoho soukromých metod, velmi pravděpodobně porušuji SRP, pak je dobré třídu rozdělit. Samozřejmě i pak mohu mít ve třídě privátní metody, ale ty by již měly být natolik jednoduché, že je lze snadno otestovat při průchodu veřejné metody, která je využívá.
Co se týče toho, že testujeme pouze rozhraní, je třeba si uvědomit, že rozhraním metody nejsou pouze její vstupy a výstupy ale i interakce s okolím – typicky když uvnitř metody pošlu SQL dotaz na databázový adaptér.
V případě protected metody bych se pro testu nebál vytvoření potomka pouze pro účely, kde se protected změní na public. Tento postup je vlceku přijatelný, používají ho i největší kapacity na testování a refaktoring.

arron

To vytváření potomka je zcela „validní“ postup/přístup s jednou jedinou malou chybou…je to děsný opruz :-D Reflexe se z tohoto pohledu ukazuje jako daleko efektivnější, obzvlášť, pokud má oporu v nějakém testovacím frameworku (opora = nezajímá mě, že je to reflexe a nemusím na to moc myslet).

Enum a statická analýza kódu

Mám jednu univerzální radu pro začínající programátorty. V učení sice neexistují rychlé zkratky, ovšem tuhle radu můžete snadno začít používat a zrychlit tak tempo učení. Tou tajemnou ingrediencí je statická analýza kódu. Ukážeme si to na příkladu enum.