Testování v PHP: odstiňujeme závislosti II.

Je možné mockovat SOAP webservice? A co filesystém? A co když potřebuji otestovat abstraktní třídu? Nejen na tyto otázky vám odpoví další díl seriálu o testování v PHP.

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

minulém díle našeho seriálu jsme si ukázali základy mockování v PHPUnit. Dnes se podíváme ještě na tři metody, které nám mohou pomoci při řešení více či méně zapeklitých situací. Jednou z nich může být – jak otestovat abstraktní třídu?

Testování/mockování abstraktní třídy

Přesně pro tyto účely framework PHPUnit, přesněji třída PHPUnit_Framework_TestCase, nabízí metodu getMockForAbstractClass. Seznam parametrů metody je „téměř stejný“ se seznamem parametrů metody getMock, i její chování je až na několik, ne příliš dobře zdokumentovaných, detailů „téměř totožné“. Zkrátka ve světě PHP nic neobvyklého :-/.

V čem jsou tedy rozdíly? Druhým parametrem není pole se seznamem mockovaných metod. Tento parametr je přesunut až na sedmé místo. I jeho význam je trošku modifikovaný – umožňuje dodefinovat seznam mockovaných metod, které budou přidány k defaultně mockovaným abstraktním metodám. Při vytváření mocku abstraktní třídy tedy musíte myslet na to, že framework za vás defaultně mockuje všechny abstraktní metody!

Kdy se hodí tato možnost? Potřebujete-li např. otestovat abstraktní třídu, která obsahuje šablonovou metodu.

abstract class Vehicle
{
    public function canFly()
    {
        return $this->hasWings();
    }

    abstract public function hasWings();
}

class VehicleTest extends PHPUnit_Framework_TestCase
{
    public function testCanFly()
    {
        $mock = $this->getMockForAbstractClass("Vehicle");
        $mock->expects($this->once())
            ->method("hasWings")
            ->will($this->returnValue(false));

        $this->assertFalse($mock->canFly());
    }
}

Mockování webservice

Pro odstínění závislosti na webservice, konkrétně instanci třídy SOAPClient, je možné použít metodu getMockFromWsdl, která vygeneruje mock podle specifikace popsané v zadaném WSDL souboru. Osobně nepovažuji tuto metodu za příliš užitečnou, protože vytvořený mock je instancí typu SOAPClient, což nemusí být vždy to, co potřebujeme. Mnohem lepším řešením je abstrakce webservice. K tomuto problému se ještě dostaneme v souvislosti s návrhem testovatelného kódu.

Mockování filesystému

Odstiňování závislostí na filesystému patří mezi jedny z nejsložitějších úkolů při testování. Ne vždy je to totiž dost dobře možné a hodně závisí na konkrétní implementaci testované třídy. Jedním z možných řešení je použití stream wrapperu virtuálního filesystému – vfsStream.

Instalace vfsStream

Instalovat vfsStream podle aktuální dokumentace PHPUnit se ani nepokoušejte, už dávno nefunguje. Projekt byl přesunut. Ale začátkem roku byl projekt publikován na serveru Packagist.org aby bylo možné jej instalovat pomocí nástroje Composer, tak doufejme, že to povede k ustálení instalačního postupu.

Jednou z možností je tedy instalace pomocí Composer, takto je možné instalovat vfsStream od verze 1.0.0. Seznam všech možných verzí je dostupný na serveru Packagist.org. Pro instalaci pomocí Composer stačí vfsStream zařadit mezi dependencies.

    "mikey179/vfsStream": "1.1.*"

Alternativní možností je použití instalátoru PEAR, ale takto lze nainstalovat pouze verzi menší než 1.0.0. Pro aktuálnější verze už je nutné použít předchozí postup.

    $ pear channel-discover pear.bovigo.org
    $ pear install bovigo/vfsStream-beta

Malá ukázka použití – mockování filesystému při testování třídy Logger, jejímž účelem je logování do souboru.

class Logger
{
    protected $directory;

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

    public function log($message)
    {
        $fp = fopen($this->directory."/log.txt", "a");
        fwrite($fp, $message . "n");
        fclose($fp);
    }
}
require_once 'vfsStream/vfsStream.php';

class LoggerTest extends PHPUnit_Framework_TestCase
{
    /**
     * @var  vfsStreamDirectory
     */
    private $root;

    protected function setUp()
    {
        $this->root = vfsStream::setup("logs");
    }

    public function testDirectoryIsCreated()
    {
        $logger = new Logger(vfsStream::url("logs"));
        $logger->log("Log message");
        $logger->log("Next message");

        $logFile = $this->root->getChild("log.txt");
        $this->assertEquals("Log messagenNext messagen", $logFile->getContent());
    }
}

Jak už jsem uváděl výše – použití tohoto postupu závisí vždy na konkrétní implementaci testované třídy a v určitých případech může být tento postup předem vyloučen. Příkladem může být definice cesty k souboru přímo v kódu. Takovou závislost nemáme šanci jakkoli nahradit.

Praktický příklad

Tím jsme dokončili průlet základy mockování v PHPUnit, pojďme si nyní opět zkusit něco z praxe. V diskusích pod jednotlivými díly seriálu se často skloňoval výraz „e-shop“, proto zadání zvolím z této oblasti. I tentokrát budeme postupovat metodou „tests first“. Procvičíme si i schopnost rychlé orientace v cizím kódu :-).

Provozovatel e-shopu by rád automaticky importoval do své databáze produkty podle datových souborů svých dodavatelů. Dodavatelé mají k dispozici různou škálu formátů a specifikací (CSV, XML, TXT), proto bude nutné umět vše správně parsovat. Po úvodní schůzce bylo rozhodnuto, že úkol budou řešit paralelně dva týmy – jeden bude řešit parsery, druhý importní třídu. Také bylo dohodnuto společné rozhraní, které budou implementovat všechny parsery. My jsme členy druhého týmu a našim úkolem je tedy implementace importní třídy pouze se znalostí společného rozhraní parserů a znalostí způsobu ukládání dat do databáze. Bohužel nemáme k dispozici žádnou testovací databázi, žádný ad-hoc test zkušebním importem tedy nepřipadá v úvahu.

Pojďme si projít, co vše máme k dispozici.

1) společné rozhraní parserů

interface ProductParser {

    public function getCode();
    public function getName();
}

2) způsob ukládání dat

Máme k dispozici třídu ProductRepository, říkejme jí třeba repozitář, která nabízí dvě metody: insert() a update(). Obě přijímají jako parametr instanci třídy Product (říkejme jí entita), metoda update() navíc vyžaduje index aktualizovaného produktu.

class Product
{
    private $name;

    public function setName($name)
    {
        $this->name = $name;
    }
}

class ProductRepository
{
    public function insert(Product $product) {/*...*/}

    public function update($index, Product $product) {/*...*/}
}

3) model konverzní tabulky

Stejně jako repozitář k ukládání produktů máme k dispozici i model nad konverzní tabulkou. K čemu to potřebujeme? V konverzní tabulce si udržujeme páry „cizí identifikátor – náš identifikátor“ abychom byli schopni určit, zda importovaný produkt už existuje a potřebuje tedy aktualizaci nebo ještě neexistuje a je třeba jej založit. Konverzní tabulka je realizována třídou ProductConversion, která nám nabízí dvě metody:

  • exists($foreignId) – ověřuje, zda existuje záznam pro cizí identifikátor, tedy zda produkt už existuje. Pokud ano, pak vrací náš identifikátor produktu. V opačném případě bool(false).
  • insert($foreignId, $productId) – ukládá nový pár identifikátorů.
class ProductConversion
{
    public function exists($foreignId) {/*...*/}

    public function insert($foreignId, $productId) {/*...*/}
}

Postup importu

Ještě než se vrhneme do kódu, ujasněme si, jak bude import probíhat.

  1. Konkrétní parser nám vrátí instanci třídy ProductParser, která reprezentuje načtený záznam produktu ze souboru.
  2. Pomocí konverzní tabulky se pokusíme načíst náš identifikátor dotyčného produktu.
  3. Pokud náš identifikátor existuje, aktualizujeme záznam produktu v databázi.
  4. V opačném případě založíme nový produkt a uložíme nový konverzní pár identifikátorů.

Implementace kostry

Vytvoříme si kostru importní třídy. Pojmenujeme ji třeba ProductImport a budeme zatím počítat jen s jednou metodou: import(). Bude mít jeden parametr – instanci parseru produktu, který chceme importovat. Naše třída bude určitě ke své práci potřebovat model konverzní tabulky a repozitář pro práci s databází. Obě závislosti uvedeme v konstruktoru. Víc nás zatím nezajímá.

class ProductImport
{
    /**
     * @var ProductConversion
     */
    private $conversion;

    /**
     * @var ProductRepository
     */
    private $repository;

    public function __construct(ProductRepository $repository,
                                ProductConversion $conversion)
    {
        $this->repository = $repository;
        $this->conversion = $conversion;
    }

    public function import(ProductParser $parser) {}
}

Testovací případ pro založení nového produktu

Nyní se pustíme do prvního test case, tím bude založení nového produktu. Co k tomu bude třeba?

  1. Připravit mock třídy ProductRepository, který bude nahrazovat skutečný repozitář. V případě zakládání nového produktu by měla být právě jednou zavolána jeho metoda insert(). Její metoda update() by neměla být zavolána nikdy.
  2. Připravit mock třídy ProductConversion. V případě zakládání nového produktu by měla být právě jednou zavolána jeho metoda exists(), která má vrátit bool(false) (náš identifikátor produktu zatím neexistuje) a právě jednou metoda insert(), ukládající nový konverzní pár.
  3. S pomocí instancí obou mocků vytvořit instanci třídy ProductImport.
  4. Připravit mock parseru, který vytvoříme proti rozhraní ProductParser. Důležité je mockovat metodu getCode(), která bude předchozími mocky volána. Na zbylých nezáleží.
  5. Připravit instanci entity, kterou budeme importovat. Data k její instancializaci nám během skutečného importu poskytne parser.
  6. Zavolat metodu import() s instancí mocku parseru jako parametrem.

Pojďme dát vše dohromady. Jak poznáme, že byl test splněn? Kromě faktu, že všechny expektace musí být splněny, přidáme metodě import() ještě návratovou hodnotu, jíž bude náš identifikátor importovaného produktu. Na závěr test case jej ověříme.

class ProductImportTest extends PHPUnit_Framework_TestCase
{
    public function testInsertNewProduct()
    {
        $futureNewProductId = 111;
        $foreignProductCode = "PROD01";
        $productName = "Test product";

        $product = new Product();
        $product->setName($productName);

        $repository = $this->getMock("ProductRepository");
        $repository->expects($this->once())
            ->method("insert")
            ->with($product)
            ->will($this->returnValue($futureNewProductId));

        $repository->expects($this->never())->method("update");

        $conversion = $this->getMock("ProductConversion");
        $conversion->expects($this->once())
            ->method("exists")
            ->with($foreignProductCode)
            ->will($this->returnValue(false));

        $conversion->expects($this->once())
            ->method("insert")
            ->with($foreignProductCode, $futureNewProductId);

        $parser = $this->getMock("ProductParser");
        $parser->expects($this->once())
            ->method("getCode")
            ->will($this->returnValue($foreignProductCode));

        $parser->expects($this->once())
            ->method("getName")
            ->will($this->returnValue($productName));


        $import = new ProductImport($repository, $conversion);

        $this->assertEquals($futureNewProductId, $import->import($parser));
    }
}

Možná vás délka test case a „ukecanost“ tvorby mocků zprvu vyděsí, ale je to jen otázka zvyku. Stačí se držet jednoduchého postupu: jaká třída, jak často jaká metoda, s jakými parametry, co má udělat/vrátit. Ale zpět k příkladu. Asi nikoho nepřekvapí, že test selže – null se opravdu nerovná 111, což představuje budoucí identifikátor nově uloženého produktu.

PHPUnit 3.7.0 by Sebastian Bergmann.

F

Time: 0 seconds, Memory: 5.75Mb

There was 1 failure:

1) ProductImportTest::testInsertNewProduct
Failed asserting that null matches expected 111.

Testovaná metoda import() celkem logicky vrací null, není totiž vůbec implementována. Máme připraven test, zkusme nastřelit její funkčnost.

class ProductImport
{
    /**
     * @var ProductConversion
     */
    private $conversion;

    /**
     * @var ProductRepository
     */
    private $repository;

    public function __construct(ProductRepository $repository, ProductConversion $conversion)
    {
        $this->repository = $repository;
        $this->conversion = $conversion;
    }

    public function import(ProductParser $parser)
    {
        $product = new Product();
        $product->setName($parser->getName());

        $productId = $this->conversion->exists($parser->getCode());
        if (!$productId) {
            // create new product
            $productId = $this->repository->insert($product);
            $this->conversion->insert($parser->getCode(), $productId);
            return $productId;
        }
    }
}

Implementace vypadá dobře, zkusme pustit test.

There was 1 failure:

1) ProductImportTest::testInsertNewProduct
ProductParser::getCode() was not expected to be called more than once.

Oops, máme špatně definovanou expektaci u mockované metody getCode() v třídě ProductParser. V metodě import() ji totiž nevoláme jednou, ale dvakrát – poprvé pro ověření existence konverzního páru a podruhé pro uložení nového páru. Upravíme tedy test aby odpovídal skutečnosti a spustíme znovu.

$parser->expects($this->exactly(2))
    ->method("getCode")
    ->will($this->returnValue($foreignProductCode));PHPUnit 3.7.0 by Sebastian Bergmann.

...

Time: 0 seconds, Memory: 5.75Mb

OK (3 tests, 7 assertions)

Testovací případ pro aktualizaci existujícího produktu

Super! Variantu se založením nového produktu máme hotovou. Naše metoda import() zavolala správné metody se správnými parametry a vrátila očekávaný výsledek. Zbývá tedy doplnit variantu, kdy produkt už existuje a je třeba jej pouze aktualizovat. Opět si napíšeme nejprve test. Co očekáváme:

  • Bude jednou zavolána metoda ProductConversion::exists a tentokrát vrátí int identifikátor.
  • Metoda ProductConversion::insert nebude zavolána nikdy.
  • ProductParser::getCode bude zavoolána pouze jednou – při ověření existence konverzního páru.
  • ProductParser::getName také jednou.
  • Právě jednou bude zavolána metoda ProductRepository::update, se správnými parametry.
  • ProductRepository::insert nebude zavolána nikdy.
  • Metoda import() vrátí identifikátor aktualizovaného produktu.

Pojďme to přepsat do kódu. Pro stručnost uvedu pouze nově přidaný test case:

public function testUpdateProduct()
{
    $existentProductId = 222;
    $foreignProductCode = "PROD02";
    $productName = "Test product 2";

    $product = new Product();
    $product->setName($productName);

    $repository = $this->getMock("ProductRepository");
    $repository->expects($this->once())
        ->method("update")
        ->with($existentProductId, $product)
        ->will($this->returnValue($existentProductId));

    $repository->expects($this->never())->method("insert");

    $conversion = $this->getMock("ProductConversion");
    $conversion->expects($this->once())
        ->method("exists")
        ->with($foreignProductCode)
        ->will($this->returnValue($existentProductId));

    $conversion->expects($this->never())->method("insert");

    $parser = $this->getMock("ProductParser");
    $parser->expects($this->once())
        ->method("getCode")
        ->will($this->returnValue($foreignProductCode));

    $parser->expects($this->once())
        ->method("getName")
        ->will($this->returnValue($productName));


    $import = new ProductImport($repository, $conversion);

    $this->assertEquals($existentProductId, $import->import($parser));
}

Když nyní testy spustíme, tak celkem logicky selžou.

There was 1 failure:

1) ProductImportTest::testUpdateProduct
Failed asserting that null matches expected 222.

Ano, null se opravdu nerovná 222. Metoda import() zatím není na možnost aktualizace existujícího produktu připravena. Hned to napravíme.

public function import(ProductParser $parser)
{
    $product = new Product();
    $product->setName($parser->getName());

    $productId = $this->conversion->exists($parser->getCode());
    if ($productId) {
        // update existent product
        $this->repository->update($productId, $product);
        return $productId;
    } else {
        // create new product
        $productId = $this->repository->insert($product);
        $this->conversion->insert($parser->getCode(), $productId);
        return $productId;
    }
}

Úprava byla triviální – přidali jsme jen volání metody ProductRepository::update a vrátili ID produktu. Zkusme testy nyní.

PHPUnit 3.7.0 by Sebastian Bergmann.

....

Time: 1 second, Memory: 5.75Mb

OK (4 tests, 11 assertions)

Cool! Všechny expektace byly splněny, máme hotovo! Jak vidíte, byli jsme schopni implementovat požadovanou funkčnost i bez existence jakéhokoli konkrétního parseru. Díky tomu jsme mohli úkol řešit paralelně a dokončit jej dříve než při běžném postupu.

Další možnosti mockování

Pokud vás odpuzuje trochu „ukecanější“ postup tvorby mocku v PHPUnit nebo postrádáte další možnosti nastavení, pak můžete sáhnout hned po několika externích knihovnách. Doporučuji prozkoumat například Mockery (https://git­hub.com/padraic/mockery) nebo Mockista (https://bit­bucket.org/jiriknesl/mockis­ta) od Jirky Knesla.

Všechny uvedené zdrojové kódy příkladu jsou dostupné na Githubu:

https://github.com/josefzamrzla/serial-testovani-v-php

Příště

To je pro dnešek vše. Příště se podíváme na rozšíření DbUnit a integrační testy.

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ářů

Robo Mockování
Josef Zamrzla Re: Mockování
andrej.k Re: Mockování
Honza Marek vfsStream
HosipLan Re: vfsStream
Josef Zamrzla Re: vfsStream
Honza Dobrý příklad
Honza Re: Dobrý příklad
arron Re: Dobrý příklad
Honza Re: Dobrý příklad
EsoRimer. Re: Dobrý příklad
Honza Re: Dobrý příklad
arron Re: Dobrý příklad
Clary Re: Dobrý příklad
Honza Re: Dobrý příklad
jos Re: Dobrý příklad
Honza Re: Dobrý příklad
arron Re: Dobrý příklad
Honza Re: Dobrý příklad
arron Re: Dobrý příklad
Honza Re: Dobrý příklad
arron Re: Dobrý příklad
jos Re: Dobrý příklad
Honza Re: Dobrý příklad
AntiHonza Vzkaz pro Honzu
Clary Re: Vzkaz pro Honzu
Honza Re: Vzkaz pro Honzu
Jakub Vrána Kvalita testu
arron Re: Kvalita testu
Josef Zamrzla Re: Kvalita testu
Jakub Vrána Re: Kvalita testu
Zdroj: https://www.zdrojak.cz/?p=3735