Testování v PHP: testy integrace s databází

V tomto díle se posuneme v abstrakci o úroveň výše a vyzkoušíme integrační testování, konkrétně testování integrace s databází MySQL za pomoci rozšíření DbUnit frameworku PHPUnit.

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ílem seriálu jsme uzavřeli pomyslnou kapitolu věnovanou unit testování. Dnes se posuneme v abstrakci o úroveň výše a vyzkoušíme integrační testování. Příkladem integračního testování může být např. testování integrace s databází, kterému bude věnován tento díl.

Instalace PHPUnit pomocí Composer

Ještě než se vrhneme na téma tohoto dílu, rád bych doplnil díl věnovaný základům PHPUnit. V době psaní uvedeného dílu ještě nebyla dořešena možnost instalace PHPUnit pomocí nástroje Composer, což je nyní už možné. Instalovat PHPUnit tak můžeme dalšími dvěma způsoby.

Lokálně, jako závislost daného projektu. Stačí jej uvést v souboru composer.json

{
    "require-dev": {
        "phpunit/phpunit": "3.7.*"
    }
}

V tomto případě bude PHPUnit nainstalován do adresáře vendor, je tedy nutné jej také lokálně spouštět:

$ vendor/bin/phpunit

Globálně. Stačí do adresáře, kam se chystáme PHPUnit instalovat umístit soubor composer.json a spustit instalaci.

{
    "name": "phpunit",
    "description": "PHPUnit",
    "require": {
        "phpunit/phpunit": "3.7.*"
    },
    "config": {
        "bin-dir": "/usr/local/bin/"
    }
}

Integrační testování

Dosud jsme všechny naše třídy testovali v naprosté izolaci. Pokud měla třída nějaké závislosti, tak jsme je odstínili pomocí mocků. Tímto postupem můžeme dobře otestovat vlastní funkčnost třídy, ale je-li jednou ze závislostí abstrakce databázové vrstvy, jak otestovat, zda jsou data správně ukládána nebo načítána? Zde přichází na řadu integrační testy. Připravíme si testovací databázi a testovací případy provádíme proti ní.

Integrační testování (testování integrace s databází obzvláště) má celou řadu nevýhod a vždy je třeba si počínat mnohem opatrněji než při unit testování. Jedním z úskalí může být dodržení jednotného fixture (prostředí) pro každý test case. Spouštět test case v prostředí, které bylo modifikováno předchozím testem, je nepřípustné. Je tedy třeba, aby byl vždy dodržen postup:

  1. příprava prostředí (fixture)
  2. spuštění test case
  3. vyhodnocení
  4. úklid prostředí

Pokud budeme uvažovat testy s databází, pak prvním bodem je myšlena obnova databáze do námi definovaného počátečního stavu, nad kterým budeme všechny test case spouštět, a ukazuje hned několik nevýhod integračního testování – jsou pomalé, vyžadují komplexní práva, jsou závislé na prostředí apod.

Zajímavý článek na téma integračního testování před nedávnem napsal Daniel Kolman: Jak na integrační testy s databází. Určitě stojí za přečtení!

Rozšíření DbUnit

Framework PHPUnit pro podporu testování integrace s databází přináší rozšíření jménem DbUnit, a tomu se budeme věnovat v tomto díle. Rozšíření aktuálně podporuje MySQL, PostgreSQL, Oracle a SQLite. Pomocí Zend Framework nebo Doctrine2 je možné pracovat i s IBM DB2 nebo Microsoft SQL Server. Alespoň to tvrdí dokumentace.

Instalace

Pro instalaci rozšíření můžeme volit ze dvou možností:

instalátor PEAR

$ [sudo] pear install phpunit/DbUnit

nástroj Composer

{
    "require-dev": {
        "phpunit/dbunit": ">=1.2"
    }
}

Třetí možností může být samozřejmě naklonování zdrojových kódů z repositáře: https://git­hub.com/sebastianbergmann/dbu­nit. Ať vyberete cokoli, všechny uvedené varianty by měly proběhnout bez problémů.

Začínáme s DbUnit

Oproti testům, které jsme pomocí PHPUnit zatím psali, se integrační testy s rozšířením DbUnit liší hned několikrát. Hlavním rozdílem je, že sady testů dědí od abstraktní třídy PHPUnit_Extensions_Database_TestCase, která vyžaduje implementaci dvou abstraktních (šablonových) metod:

  • getConnection() – vracející instanci typu PHPUnit_Extensions_Databa­se_DB_IDatabaseConnection, reprezentující wrapper připojení k databázi.
  • getDataSet() – vracející instanci typu PHPUnit_Extensions_Databa­se_DataSet_IDataSet. Tato metoda vrací tzv. data set, což představuje inicializační sadu dat.

Připojení k databázi

Možná vás to zprvu trochu zmate, ale opravdu, DbUnit požaduje připojení k databázi předat jako wrapper implementující rozhraní PHPUnit_Extensions_Databa­se_DB_IDatabaseConnection. Ale není třeba se děsit, v drtivé většině případů si vystačíme s defaultním wrapperem: PHPUnit_Extensions_Databa­se_DB_DefaultDatabaseConnec­tion, který obaluje připojení k databázi pomocí PDO:

protected function getConnection()
{
    return $this->createDefaultDBConnection(
        new PDO("mysql:host=localhost;dbname=test", "tester", "password"));
}

Data set

Druhou metodou, kterou jsme nuceni implementovat, je getDataSet() vracející inicializační data set. Co si pod tímto představit? Jak už jsme si řekli před chvílí – před každým testem je nutné uvést databázi do nějakého známého, předem definovaného, stavu, který bude pro všechny test case stejný. A přesně k tomu slouží tzv. data sety. Před každým testem je databáze nejprve vyprázdněna a naplněna daty, která můžeme definovat pomocí několika formátů.

Flat XML DataSet

Asi nejpoužívanější ze všech formátů. Jeho struktura je jednoduchá: co element, to záznam v tabulce jejíž název je shodný s názvem elementu. Sloupce tabulky (záznamu) jsou reprezentovány atributy. Největší nevýhodou tohoto formátu je špatná podpora hodnoty NULL. Pokud potřebujeme některým ze sloupců záznamu nastavit hodnotu NULL, pak je lepším řešením např. formát XML DataSet, který hodnotu NULL plně podporuje.

Příklad Flat XML DataSet:
<?xml version="1.0" encoding="UTF-8" ?>
<dataset>
    <product id="1" name="Test product 1" price="123" />
    <product id="2" name="Test product 2" price="456" />
    <product id="3" name="Test product 3" price="789" />
</dataset>
Natažení Flat XML DataSet:
protected function getDataSet()
{
    return $this->createFlatXmlDataSet("flatDataSet.xml");
}

XML DataSet

Tzv. plný XML formát. Pomocí něj můžeme definovat zvlášť struktury tabulek a zvlášť jednotlivé záznamy (řádky). Plně podporuje hodnotu NULL, viz. ukázka. Jeho nevýhodou je poměrně velká „ukecanost“ daná formátem XML.

Příklad XML DataSet:
<?xml version="1.0" ?>
<dataset>
    <table name="product">
        <column>id</column>
        <column>name</column>
        <column>price</column>
        <row>
            <value>1</value>
            <value>Test product 1</value>
            <value>123</value>
        </row>
        <row>
            <value>2</value>
            <value>Test product 2</value>
            <null />
        </row>
    </table>
</dataset>
Natažení XML DataSet:
protected function getDataSet()
{
    return $this->createXMLDataSet("xmlDataSet.xml");
}

MySQL XML DataSet

Data set, který je možné vytvořit pomocí backup nástroje mysqldump. Nevýhoda je zřejmá – formát je použitelný pouze pro testování integrace s MySQL.

Použití nástroje mysqldump:
$ mysqldump --xml -t -u [username] --password=[password] [database] > /path/to/file.xml
Příklad XML exportu:
<?xml version="1.0"?>
<mysqldump xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
    <database name="test">
        <table_data name="product">
            <row>
                <field name="id">1</field>
                <field name="name">Test product 1</field>
                <field name="price">123</field>
            </row>
            <row>
                <field name="id">2</field>
                <field name="name">Test product 2</field>
                <field name="price">456</field>
            </row>
        </table_data>
    </database>
</mysqldump>
Natažení MySQL XML DataSet:
protected function getDataSet()
{
    return $this->createMySQLXMLDataSet("/path/to/file.xml");
}

YAML DataSet

Dalším dostupným formátem, který si získává stále větší popularitu, je YAML. Je podobný JSONu, jen ještě o malinko úspornější. Problém s hodnotou NULL nemá, stačí vynechat hodnotu. Pozor, prázdná hodnota neznamená prázdný řetězec!

Příklad YAML DataSet:
product:
  -
    id: 1
    name: "Hello buddy!"
    price: 124
  -
    id: 2
    name: "I like it!"
    price: 546
Natažení YAML DataSet:
protected function getDataSet()
{
    return new PHPUnit_Extensions_Database_DataSet_YamlDataSet(
        "yamlDataSet.yml"
    );
}

CSV DataSet

Posledním ze statických formátů data setů, které momentálně PHPUnit nabízí, je notoricky známý formát: CSV. Bohužel i tento formát trpí problémy s hodnotou NULL. Pokud vynecháte hodnotu sloupce, pak bude vložena defaultní hodnota podle definice, nikoli NULL.

Příklad CSV DataSet:
id,name,price
1,"Test product 1",123
2,"Test product 2",456
Natažení CSV DataSet:
protected function getDataSet()
{
    $dataSet = new PHPUnit_Extensions_Database_DataSet_CsvDataSet();
    $dataSet->addTable("product", "csvDataSeed.csv");
    return $dataSet;
}

Kromě statických data setů existují i dynamické, ty si ale ukážeme později, prozatím vyloženě nesouvisí s tématem. Co si ale ještě ukážeme, jsou tři dekorátory: Replacement, Composite a Filter. Umožní nám další možnosti práce s popsanými formáty.

Replacement DataSet

Pomocí Replacement DataSet můžeme definovat sadu pravidel pro úpravu data setů před jejich natažením. Např. je takto možné řešit problém s hodnotou NULL:

<?xml version="1.0" encoding="UTF-8" ?>
<dataset>
    <product id="1" name="Test product 1" price="123" />
    <product id="2" name="Test product 2" price="###NULL###" />
</dataset>

Hodnotu NULL v data setu definujeme pomocí makra ###NULL### a před natažením všechny makra nahradíme:

public function getDataSet()
{
    $dataSet = $this->createFlatXmlDataSet("flatDataSet.xml");
    $replacement = new PHPUnit_Extensions_Database_DataSet_ReplacementDataSet($dataSet);
    $replacement->addFullReplacement("###NULL###", null);
    return $replacement;
}

Composite DataSet

Těm z vás, kteří znají návrhový vzor Composite, bude účel tohoto dekorátoru jasný – slučování několika data setů do jednoho. Pozor na omezení – není možné slučovat dva data sety obsahující data jedné tabulky! V dokumentaci je uveden příklad, který nefunguje!

První dataset:

<?xml version="1.0" encoding="UTF-8" ?>
<dataset>
    <product id="1" name="Test product 1" price="123" />
</dataset>

Druhý dataset:

category:
  -
    id: 2
    name: "Mobile phones"

Sloučení obou data setů. Záměrně použijeme dva různé formáty.

public function getDataSet()
{
    $ds1 = $this->createFlatXmlDataSet("flatXmlDataSet.xml");
    $ds2 = new PHPUnit_Extensions_Database_DataSet_YamlDataSet(
        "yamlDataSet.yml");

    $compositeDs = new PHPUnit_Extensions_Database_DataSet_CompositeDataSet(array());
    $compositeDs->addDataSet($ds1);
    $compositeDs->addDataSet($ds2);

    return $compositeDs;
}

Filter DataSet

Poslední z dekorátorů, jak jeho název napovídá, používá se k filtrování data setů. K čemu to může být dobré? Např. pokud máte velký data set a pro účely daného testu jej nepotřebujete importovat vždy celý. Víte, že test se bude týkat pouze tabulky product, tak proč importovat i tabulky category, user, session a další, které vám jen prodlouží set-up. Ukažme si na příkladu vzorového data setu:

<?xml version="1.0" encoding="UTF-8" ?>
<dataset>
    <product id="1" name="Test product 1" price="123" bar="1" />
    <product id="2" name="Test product 2" price="456" bar="2" />

    <foo id="4" bar="val" baz="valval" />
    <foo id="5" bar="val" baz="valval" />
</dataset>

Řekněme, že pro účely testu chceme importovat pouze data tabulky product, ale ne úplně vše – chceme navíc ignorovat sloupec bar.

protected function getDataSet()
{
    $ds = $this->createFlatXmlDataSet("flatDataSet.xml");

    $filterDataSet = new PHPUnit_Extensions_Database_DataSet_DataSetFilter($ds);
    $filterDataSet->addIncludeTables(array('product'));
    $filterDataSet->setExcludeColumnsForTable('product', array('bar'));

    return $filterDataSet;
}

Příprava fixture

Tím máme víceméně pokryté možnosti, které nám PHPUnit nabízí, co se týče přípravy dat pro fixture. Umíme vyrobit připojení k testovací databázi a importovat testovací data z nejrůznějších formátů. Než vše spojíme do nějakého ukázkového příkladu, pojďme se ještě podívat na to, jak řídit přípravu fixture.

Když se podíváme, co DbUnit provádí v metodě setUp, tak narazíme na volání metody getSetUpOperation(). Defaultně tato metoda vrací sadu operací (opět viz. návrhový vzor Composite) pojmenovanou CLEAN_INSERT, která se skládá z operace TRUNCATE a INSERT. Před každým test case tedy budou všechny tabulky uvedené v data setu promazané pomocí SQL příkazu TRUNCATE a poté naplněny pomocí INSERT.

Ve většině případů nám toto bude naprosto vyhovovat. Jak ale postupovat ve chvíli, kdy místo TRUNCATE potřebujeme např. prostý DELETE? Řešení je jednoduché – metodu getSetUpOperation() v požadované sadě testů překryjeme:

protected function getSetUpOperation()
{
    return new PHPUnit_Extensions_Database_Operation_Composite(array(
        PHPUnit_Extensions_Database_Operation_Factory::DELETE_ALL(),
        PHPUnit_Extensions_Database_Operation_Factory::INSERT()
    ));
}

Nyní bude příprava fixture probíhat: DELETE všech tabulek uvedených v data setu, potom INSERT dat. Pojďme tedy konečně vše spojit do nějakého příkladu.

Příklad

Budeme testovat jednoduchý repozitář ProductRepository, který pracuje s tabulkou product v MySQL a poskytuje dvě metody:

  • getById($id): pro načtení produktu (instance třídy Product) podle ID
  • save(Product $product): pro uložení produktu do databáze. Metoda interně řeší, zda bude vložen produkt nový nebo aktualizován stávající podle existence jeho ID.

Připomínám, že uvedený příklad slouží především k ilustraci probíraného téma, jeho cílem není 100% code coverage ani 100% use-case coverage ;-)

class Product
{
    private $id = null;

    public $name;
    public $price;

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

    public function getId()
    {
        return $this->id;
    }
}

class ProductRepository
{
    /**
     * @var PDO
     */
    protected $db;

    public function __construct(PDO $db)
    {
        $this->db = $db;
        $this->db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
    }

    public function getById($id)
    {
        $stm = $this->db->prepare("
            SELECT * FROM product WHERE id = :id");

        if ($stm->execute(array(":id" => $id))) {
            $data = $stm->fetch();
            $product = new Product($data['id']);
            $product->name = $data['name'];
            $product->price = $data['price'];

            return $product;
        }

        return false;
    }

    public function save(Product $product)
    {
        $data = array(":name" => $product->name, ":price" => $product->price);

        if ($product->getId() === null) {

            $stm = $this->db->prepare("
                INSERT INTO product(name, price) VALUES (:name, :price)");

            if ($stm->execute($data)) {
                return $this->db->lastInsertId();
            }

        } else {
            $stm = $this->db->prepare("
                UPDATE product SET name = :name, price = :price
                WHERE id = :id");

            $data[':id'] = $product->getId();
            if ($stm->execute($data)) {
                return $product->getId();
            }
        }

        return false;
    }
}

Struktura tabulky product:

CREATE TABLE IF NOT EXISTS `product` (
  id int(11) NOT NULL AUTO_INCREMENT,
  name varchar(250) NOT NULL,
  price int(11) NOT NULL DEFAULT '0',
  PRIMARY KEY (`id`)
)

Pojďme se vrhnout na test. Nejprve připravíme kostru, tzn. připojení k databázi a inicializační data set. Předpokládejme instanci MySQL, běžící na localhostu, obsahující databázi jménem „test“ a uživatele „test“ s heslem „test“.

class ProductRepositoryTest extends PHPUnit_Extensions_Database_TestCase
{
    protected function getPdo()
    {
        return new PDO("mysql:host=localhost;dbname=test", "test", "test");
    }

    protected function getConnection()
    {
        return $this->createDefaultDBConnection($this->getPdo());
    }

    protected function getDataSet()
    {
        return $this->createFlatXMLDataSet("productsDataSet.xml");
    }
}

Fixture nastavíme pomocí Flat XML data setu, bude obsahovat dva produkty.

<?xml version="1.0" encoding="UTF-8" ?>
<dataset>
    <product id="1" name="Test product 1" price="123" />
    <product id="2" name="Test product 2" price="456" />
</dataset>

Jako první zkusíme otestovat, zda databáze skutečně obsahuje inicializační data.

public function testInitialData()
{
    $repository = new ProductRepository($this->getPdo());

    $this->assertEquals("Test product 1", $repository->getById(1)->name);
    $this->assertEquals(456, $repository->getById(2)->price);
}

Pokud máme správně nastaveno připojení k databázi, tak by nám test měl ohlásit korektní výskedek – jeden test se dvěma asserty. Zkusme nyní test uložení nového produktu.

public function testInsert()
{
    $product = new Product();
    $product->name = "Brand new product";
    $product->price = 111;

    $repository = new ProductRepository($this->getPdo());
    $id = $repository->save($product);

    $this->assertEquals(3, $id);
    $this->assertEquals("Brand new product", $repository->getById(3)->name);
    $this->assertEquals(111, $repository->getById(3)->price);
}

Nakonec ještě test aktualizace existujícího produktu, opět to bude jednoduché. Nejprve ověříme data před úpravou, pak uložíme upravená data a otestujeme změnu.

public function testUpdate()
{
    $repository = new ProductRepository($this->getPdo());
    $product = $repository->getById(1);

    $this->assertEquals("Test product 1", $product->name);
    $this->assertEquals(123, $product->price);

    $product->name = "Updated name";
    $product->price = 0;

    $repository->save($product);

    $this->assertEquals("Updated name", $repository->getById(1)->name);
    $this->assertEquals(0, $repository->getById(1)->price);
}

Nic složitého, že? Zkuste si cvičně implementovat i další metody repozitáře: delete pro odstranění produktu a getAll pro načtení celé kolekce produktů. Jako vždy, zdrojové kódy (nejen) z tohoto dílu jsou dostupné na Githubu:

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

Příště

Příště si ukážeme pár tipů a triků, jak si testování integrace s databází trochu ulehčit.

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: 7

Přehled komentářů

lordiceman navrh databazy
j Re: navrh databazy
jos Re: navrh databazy
Hane Re: navrh databazy
j Re: navrh databazy
to_je_jedno Re: navrh databazy
povinná přezdívka Re: navrh databazy
Zdroj: https://www.zdrojak.cz/?p=3740