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

V závěrečném díle seriálu si ukážeme tipy, jak odstínit závislosti v legacy kódu, jak podobným závislostem čelit a jak psát kód ještě jednodušší a testovatelnější.

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ém díle jsme si ukázali, jak vyřešit problém s (ne)testovatelností chráněných metod a také jak refaktorovat třídu, aby byla mnohem lépe rozšiřitelná a nepřinášela při testování více zbytečné práce navíc než užitku. Dnes se podíváme na další překážky, které nás mohou při tvorbě testovatelného kódu potrápit.

Pokud si vzpomínáte, posledním krokem refaktoringu třídy Configuration bylo vyjmutí odpovědnosti za vytvoření instance konkrétního loaderu a její přenesení do tovární metody. Rovnou jsme vyhodili i statické volání třídy Logger a nahradili jej výjimkou.

Pojďme se vrátit o jeden krok zpět a podrobněji se podívat na důvod poslední úpravy. Před změnou mohl kód tovární metody vypadat zhruba takto:

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 {
            Logger::getInstance()->log("Unknown file type");
        }
    }
}

Při návrhu původní třídy Configuration jsme, v záplavě nadšení, implementovali Logger podle návrhového vzoru Singleton – potřebujeme přece jen jedinou instanci:

class Logger
{
    protected static $instance = null;

    private function __construct() {}

    public static function getInstance()
    {
        if (self::$instance === null) {
            self::$instance = new self;
        }

        return self::$instance;
    }

    public function log($msg)
    {
        // ... log message to a file
    }
}

Co je na tomto přístupu špatně? Kód je přece jasný – pokud je možné rozpoznat správný loader, pak je vrácen, v opačném případě je zalogována chyba… Problémů je hned několik.

Statické volání

Static methods are death to testability. (Miško Hevery)

Při pokusu o testování výše uvedeného kódu okamžitě narazíme na zákeřného protivníka – statické volání. Klidně si jej přeložte jako „globální volání“, vyjde to úplně na stejno. Jedno ze základních pravidel dobrých testů říká, že testy by měly být nezávislé, což tady očividně neplatí a při použití jakéhokoli statického přístupu ani nikdy platit nebude. Třída Configuration_LoaderFactory je závislá na třídě Logger a co je horší – na první pohled (bez znalosti kódu metody getLoader) to nejsme schopni nijak zjistit! V lepším případě to zjistíme v momentě, kdy nám testy začnou zaplňovat logy. Co s tím?

Určitě nechceme aby unit testy třídy Configuration_LoaderFactory jakkoli sahaly na logy, tzn. bylo by dobré třídu Logger pro potřeby testování nahradit nějakým mockem. Ale jak mockovat něco, k čemu nemáme přístup? Existuje workaround pomocí reflexe:

class Configuration_LoaderFactoryTest extends PHPUnit_Framework_TestCase
{
    public function testGetLoader()
    {
        // pripravime si mock:
        // konstruktor tridy Logger je chraneny,
        // proto jej nebudeme volat
        $builder = $this->getMockBuilder("Logger")->disableOriginalConstructor();
        $mock = $builder->getMock();
        $mock->expects($this->once())
            ->method("log");

        $class = new ReflectionClass("Logger");
        $property = $class->getProperty("instance");
        // zpristupnime property $instance
        $property->setAccessible(true);
        // nastavime do ni mock
        $property->setValue($mock);

        $conf = new Configuration_LoaderFactory();
        // pripona .doc nebude rozpoznana, bude volan Logger
        $conf->getLoader("data.doc");
    }
}

Sice jsme vyřešili problém s (ne)testovatelností, ale opět jsme řešili důsledek a nikoli příčinu. Kromě toho jsme si do testů zanesli hodně nepříjemnou závislost – hacknutý Singleton Logger bude i nadále vracet mockovanou instanci! Jak je to možné? Má ji přece uloženou ve své statické (globální) property! Statický přístup je krása, že? :-)

Zase to vstřikování

Jestliže třída Configuration_LoaderFactory opravdu potřebuje instanci třídy Logger, tak proč tuto závislost otevřeně nepřiznat?

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

Uživatel naší třídy nyní jasně vidí, že pro volání metody getLoader potřebuje instanci třídy Logger a nemusí to nikde složitě zjišťovat. Tento postup je znám jako Dependency injection – tedy předávání závislostí namísto jejich vytváření. Toť vše, nehledejte za tím žádnou složitost nebo magii.

Nabízí se otázka – jak postupovat v případě, že bychom namísto Singletonu vytvářeli novou instanci přímo v metodě getLoader?

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 {
            $logger = new Logger();
            $logger->log("Unknown file type");
        }
    }
}

Ideální řešení je úplně stejné jako předchozí – pomocí dependency injection: metoda má závislost na třídě Logger, deklarujme toto jasně v jejím rozhraní. Kde „se vezme“ instance Logger, nás nemusí vůbec zajímat, to už je starost volajícího. My oznamujeme „chceš-li použít tuto metodu, budeš k tomu potřebovat X a Y“.

Workaround pro legacy kód také existuje, ale už je složitější než v případě Singletonu. Můžeme sáhnout například po extension php-test-helpers (https://github.com/sebastianbergmann/php-test-helpers), jehož autorem je Sebastian Bergmann, konkrétně po funkci set_new_overload.

S její pomocí je možné „přetížit“ operátor new tak, aby namísto instance třídy uvedené v testovaném kódu vrátil instanci třídy jiné – např. nějakého „fake“.

function callback($className) {
    if ($className == "Logger") {
        $className = "FakeLogger";
    }

    return $className;
}

set_new_overload('callback');

Podobně je možné „přetěžovat“ i volání funkcí nebo chování funkce exit, která je často pro testy také smrtelná.

Přílišné nadšení

Ale zpět k tématu. Okolo dependency injection koluje spousta mýtů a polopravd, v případě Dependency injection container (dále jen DIC) to platí dvojnásob. DIC je možné si představit jako konfigurovatelnou továrnu na objekty. Chceme instanci třídy A, která má závislost na třídě B? Požádáme o ni kontejner a on nám ji vyrobí. Prosté. Hned se nabízí otázka – proč tedy explicitně uvádět závislosti tříd, když by stačilo, aby všechny třídy byly závislé na DIC:

class Configuration_LoaderFactory
{
    public function getLoader($filename, Dic $container)
    {
        if (substr($filename, -3) == "ini") {
            return new Configuration_IniLoader();
        } elseif (substr($filename, -3) == "xml") {
            return new Configuration_XmlLoader();
        } else {
            $container->get("logger")->log("Unknown file type");
        }
    }
}

Bude-li potřeba další závislost, pak si ji jednoduše vytáhneme z containeru… Nebo jinak – všechny třídy budou dědit od třídy MagicObject, která bude mít DIC jako property! Pak budeme mít DIC dostupný všude a nemusíme jej stále předávat!

abstract class MagicObject
{
    protected $container;

    public function __construct()
    {
        $this->container = new Dic();
    }
}

class Configuration_LoaderFactory extends MagicObject
{
    public function getLoader($filename)
    {
        if (substr($filename, -3) == "ini") {
            return new Configuration_IniLoader();
        } elseif (substr($filename, -3) == "xml") {
            return new Configuration_XmlLoader();
        } else {
            $this->container->get("logger")
                ->log("Unknown file type");
        }
    }
}

Budete se možná divit, ale některé hloupé implementace MVC frameworků to takto skutečně mají nebo v době „DIC fanatizmu“ měly! Asi už tušíte, že oba postupy jsou postavené na hlavu. Psát testy pro podobný kód je horor, protože se „umockujete“ k smrti. Pokud by byla třída Configuration_LoaderFactory podobně závislá na třídě Logger, jejíž instance si sama získává z DI containeru, pak bychom museli nejprve připravit mock DIC a teprve do něj vložit mock třídy Logger. V případě, že by třída Logger měla své vlastní závislosti, pak bychom samozřejmě museli mockovat i tyto závislosti. A tak dále…

Stejně jako jsme v minulém díle, víceméně náhodou, narazili na dva principy objektového návrhu, nyní se nám rýsuje další – Law of Demeter. Ve zkratce říká – „nemluv s cizími“, tzn. nevolejme metody instancí, které nám např. vrátí jiná metoda jiné instance. Fluent interface je sice sexy, ale pro testování je to hotové peklo.

Kiss!

Je-li celá třída Configuration_LoaderFactory nebo její metoda getLoader závislá na třídě Logger, pak toto deklarujme jasně a neschovávejme to za žádným kontejnerem nebo jinou magií!

Zamysleme se na závěr ještě nad jednou (zdánlivě) drobností – požadujeme-li loader k souboru známého typu, pak je vše OK, ale v opačném případě? Sice dojde k nějakému zalogování chyby, ale volání metody getLoader tiše skončí. Metoda patrně vrátí null a způsobí volajícímu řadu nepříjemností.

To není moc dobré. To opět zavání tím, že metoda řeší více, než by měla. Je opravdu odpovědností metody getLoader logovat problémy? Samozřejmě že ne! Je to jako bychom říkali: „vytvoř mi instanci loaderu podle tohoto souboru a kdyby to náhodou nešlo, tak to zapiš pomocí tohoto loggeru“. Budeme-li se držet obecného doporučení, známého pod zkratkou KISS (Keep it simple, stupid), pak by naše metoda měla buď udělat, co má, nebo selhat. Nic víc. Fakt, že namísto vytvoření instance byla vyvolána výjimka, by měl řešit volající a nikoli volaný.

class Configuration_LoaderFactory
{
    /**
     * @return Configuration_Loader
     * @throws InvalidArgumentException
     */
    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 InvalidArgumentException("Unknown file type");
        }
    }
}

Dobrým zvykem je upozornit uživatele metody na vše, co od ní může očekávat, pomocí Javadoc anotace. Zde: metoda vrací instance typu Configuration_Loader a může vyvolat výjimku třídy InvalidArgumentException.

Dependency injection je mocná zbraň, ale jak už to bývá – i tato zbraň může být dvojsečná, pokud není správně pochopena nebo používána s přílišným nadšením. Ano, předávejme závislosti namísto jejich vytváření, ale jen ty, které skutečně potřebujeme, a udržujme kód „stupid simple“. Jen tak budeme schopni náš kód snadno testovat a také v budoucnu rozvíjet.

Závěr

Tím jsme se dostali na úplný závěr seriálu o testování v PHP. Jak jste v posledních dvou dílech mohli vidět – díky testování jsme schopni psát mnohem lepší kód i bez znalosti nějakých obecných principů softwarového návrhu. Prostě k nim sami dojdeme. A touto myšlenkou bych rád seriál ukončil:

Pište, jak nejlépe umíte, a vše testujte. Na ostatní už přijdete sami…

Zdrojové kódy příkladů uvedených v seriálu najdete na GitHubu: https://github.com/josefzamrzla/serial-testovani-v-php. Kdyby vás cokoli zajímalo nebo něco nebylo úplně jasné, napište. Zastihnout mě můžete buď na mailu: josef.zamrzla(at)gmail.com nebo na Twitteru: @JosefZamrzla.

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 

Věděli jste, že nám můžete zasílat zprávičky? (Jen pro přihlášené.)

Komentáře: 31

Přehled komentářů

5o Fajn článok
maryo Zapomenutý Dic
maryo oprava
anti war logování všeho
honza Dependency Injection
Martin Mystik Jonáš Re: Dependency Injection
honza Re: Dependency Injection
Martin Mystik Jonáš Re: Dependency Injection
honza Re: Dependency Injection
Martin Mystik Jonáš Re: Dependency Injection
honza Re: Dependency Injection
Yetty Re: Dependency Injection
oo Re: Dependency Injection
honza Konec?
Jan Re: Konec?
honza Re: Konec?
Honza Marek Re: Konec?
arron Re: Konec?
pav Re: Konec?
arron Re: Konec?
Honza Marek Re: Konec?
arron Re: Konec?
Pepa Re: Konec?
arron Re: Konec?
Petr Re: Konec?
arron Re: Konec?
arron Re: Konec?
Clary Re: Konec?
Rob Jak napsat testy pro funkci jejíž výsledek může být z velké množiny hodnot ?
Rob Re: Jak napsat testy pro funkci jejíž výsledek může být z velké množiny hodnot ?
failer 5 operací
Zdroj: https://www.zdrojak.cz/?p=7247