Testování objektů, které pracují s databázovými daty, to byl vždycky
trochu oříšek.
Mock objekty
Dají se používat tzv. mock objekty, které fungují tak, že
místo databáze podstrčíte jakousi falešnou databázi, která vrací pevně
nastavený výstup a navíc můžou testovat i svůj vstup. V PHPUnit můžete
mock připravit nějak takhle (pseudokód):
class MyTest extends PHPUnit_Framework_TestCase
{
public function testSomething()
{
$mock = $this->getMock("Database");
$mock->expects($this->once())
->method("query")
->will($this->returnValue(array("nějaký", "výstup")));
... kód, u kterého testujeme volání query
}
}
Tím vytvoříte objekt, který navenek vypadá jako Database (ve
skutečnosti se vytvoří nová třída, která je potomkem Database, ale
překrývá všechny její metody, ale to už zabýhám do zbytečných
detailů), který dělá tohle:
- Sám testuje, že se právě jednou
(
->expects($this->once())) zavolá jeho metoda query()
(->method("query")).
- Na to volání query() vrátí array("nějaký", "výstup")
(
->will(array("nějaký", "výstup"))).
Nebo to samé v Simpletest:
Mock::generate("Database");
class MyTest extends UnitTestCase
{
public function testSomething()
{
$mock = new MockDatabase;
$mock->expectOnce("query");
$mock->setReturnValue("query", array("nějaký", "výstup"));
... kód, u kterého testujeme volání query
}
}
Někdy jsou mocky k nezaplacení, jindy je ale potřeba operovat nad
skutečnou databází.
Skutečná databáze, neskutečná data
Jak třeba zjistíte, že metoda pro načtení posledních X článků
z blogu opravdu načte posledních X článků z blogu? Že jí podstrčíte
mocka, který bude očekávat „správný“ select a vracet „správnou“
odpověď?
To asi ne, za prvé to jsou takové implementační podrobnosti, které do
testu nepatří a za druhé to je právě to, co potřebujeme
otestovat → že metoda položí takový dotaz, který opravdu vrátí
to, co potřebujeme. Výstup této metody se ale v produkčním prostředí
mění, co vlastně v testu očekávat?
Řešení je takové, že test probíhá nad skutečnou databází, ve které
jsou ale testovací, předem známá, data. Před každým testem si databázi
připravíme a nasypeme do ní tato data. Díky tomu můžeme docela přesně
říct, že první tři články jsou „Ahoj Světe“, „Nazdar lidi“ a
„blablabla“, v tomto pořadí.
A teď přijde to zábavné, jak na to.
Více databází
Tento přístup je upřednostňován v Ruby on Rails a myslím, že
i v Symfony. Nadefinujete si (v základu) tři databáze:
- Vývojová. Tohle je databáze, se kterou si zkoušíte web
používat a blbnout.
- Testovací. Ta se používá pro automatické testy, před
každým testem se naplní předem známými testovacími daty.
- Produkční. Jakmile projekt upnete na ostrý server, bude
používat tuto databázi.
Pokud budete chtít někdy pouštět své automatické testy i na ostrém
serveru, vytvoříte si ještě jednu databázi tam. Tento přístup zajistí
naprosto spolehlivě, že se testy nikdy ani omylem nedotknou
opravdových dat.
Mě ale vždycky přišlo pohodlnější ke každému projektu provozovat
jenom dvě databáze – jednu na localhostu, druhou na serveru. Navíc
momentálně spouštím testy takovým způsobem, že může nastat
více přístupů k databázi zároveň a pak bych asi musel celý
test obalit transakcí a už bych nemohl transakce používat v samotné
aplikaci… Zkrátka nic pro mě.
TEMPORARY tabulky
Tohle je jenom pro MySQL a je to moje volba.
MySQL umožňuje vytvářet tzv. dočasné tabulky, které se od normálních
tabulek liší v pár detailech:
- Existují pouze v databázovém spojení, ve kterém byly vytvořeny a
spolu s jeho uzavřením zanikají.
- Nenajdete je příkazem SHOW TABLES.
- Můžou „přebít“ normální tabulky. Máte v databázi tabulku
„tab1“ a vytvoříte si dočasnou tabulku se stejným názvem. Jakýkoli
dotaz nebo příkaz, odkazující se na „tab1“, se odteď odkazuje na naši
dočasnou tabulku.
- Asi ještě něco, na co jsem doteď nenarazil.
Schválně si to zkuste:
CREATE TABLE `tab1` (`a` INT);
CREATE TEMPORARY TABLE `tab1` (`b` INT);
SHOW CREATE TABLE `tab1`;
Pak si otevřete druhou konzoli a vyjeďte si SHOW CREATE TABLE
taky tam.
Jak tedy připravím databázi pro testování? Ten kousek kódu vypadá
takhle (ano, přešel jsem na dibi):
dibi::query("DROP TABLE IF EXISTS [tmp];");
foreach (dibi::query("SHOW TABLES;") as $row) {
$table = array_shift($row);
dibi::query("CREATE TEMPORARY TABLE [tmp] LIKE %n", $table);
dibi::query("CREATE TEMPORARY TABLE %n LIKE [tmp]", $table);
dibi::query("DROP TABLE [tmp];");
}
dibi::loadFile(PZ_ROOT . "/tests/fixtures/data.sql");
Pokud používáte některý xUnit, tohle patří někam do metody setUp().
Zbývá už jenom vytvořit si ten soubor s testovacími daty (viz poslední
řádku → data.sql) a je hotovo.
Metoda tearDown() v mém případě není nutná, protože každý test jede
ve vlastním threadu a má svoje vlastní spojení, které ty tabulky uklidí
samo, ale pro úplnost, představoval bych si ji takhle:
foreach (dibi::query("SHOW TABLES;") as $row) {
$table = array_shift($row);
$createTable = dibi::query("SHOW CREATE TABLE %n", $table)->fetch();
$createTable = array_pop($createTable);
if (preg_match("~^CREATE TEMPORARY TABLE ~", $createTable)) {
dibi::query("DROP TABLE %n", $table);
}
}
Pozor musíte dávat akorát s příkazy jako DROP TABLE nebo CREATE TABLE,
těmi si můžete do databáze zanést bordel. To se mi ale ještě nepovedlo.
(Jak často v aplikacích
používáte CREATE/DROP TABLE? Já přibližně nikdy.)
No není ta MySQL skvělá?