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

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.

Seriál: Testování a tvorba testovatelného kódu v PHP (13 dílů)

  1. Testování a tvorba testovatelného kódu v PHP 13.8.2012
  2. Testování v PHP: Instalace a základy PHPUnit 27.8.2012
  3. Testování v PHP: asserty a constraints 10.9.2012
  4. Testování v PHP: praktický příklad 1.10.2012
  5. Testování v PHP: anotace 8.10.2012
  6. Testování v PHP: odstiňujeme závislosti 22.10.2012
  7. Testování v PHP: odstiňujeme závislosti II. 5.11.2012
  8. Testování v PHP: testy integrace s databází 19.11.2012
  9. Testování v PHP: testy integrace s databází II. 3.12.2012
  10. Testování v PHP: řízení běhu pomocí parametrů 7.1.2013
  11. Testování v PHP: XML konfigurace PHPUnit 21.1.2013
  12. Testování v PHP: tvorba testovatelného kódu 18.2.2013
  13. Testování v PHP: tvorba testovatelného kódu II. 11.3.2013

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ě.

Josef Zamrzla pracuje jako nezávislý vývojář. Před tím působil coby software development engineer ve společnosti Skype, programátor ve společnosti LMC s.r.o. (provozovatel pracovních portálů www.jobs.cz a www.prace.cz) nebo teamleader ve společnosti Kasa.cz 

Komentáře: 13

Přehled komentářů

pav testování jen veřejných tříd
Podbi Re: testování jen veřejných tříd
arron Re: testování jen veřejných tříd
Jan Machala Re: testování jen veřejných tříd
arron Re: testování jen veřejných tříd
Martin Mystik Jonáš Re: testování jen veřejných tříd
honza
Martin Hassman Re:
arron Re:
Clary Re:
BoneFlute Re: testování privátních metod
Clary Testování soukromých metod
arron Re: Testování soukromých metod
Zdroj: https://www.zdrojak.cz/?p=7018