Mnohokrát jsem potřeboval ověřit, že mé modely fungují, jak mají, zapisují do databáze a čtou, jak se od nich očekává. Dlouho jsem to řešil jen manuálním ověřením, což je proces zdlouhavý, nedokonalý a chybový. Tento článek ukazuje, jak testovat databázové modely pomocí automatizovaných testů v Nette\Tester.
Disclaimer: Je velká šance, že to, co zde ukazuji, má daleko k best practice, nebyl jsem nikdy na žádném školení, články ostatních čtu jen občas a nové trendy občas chytám s několikaletým zpožděním. Takže si dvakrát rozmyslete, než se mým článkem necháte k čemukoliv inspirovat.
Výchozí předpoklady:
- Máme nějaký hloupoučký model vytvořený pomocí Nette\Database. Žádná Doctrine, žádné migrace, žádné fixtures a podobné fancy věci. Pokud pracujete na pokročilejším projektu (ne vždy má člověk to štěstí), vřele doporučuji výborný článek Jiřího Pudila na toto téma.
- Data uchováváme v MySQL.
- Aplikace používá composer balíčky.
- Pro testy budeme chtít použít totožnou strukturu databáze jako je na live verzi, ale separátní databázi (ideálně s úplně jiným username/password) a zafixovanými „testovacími daty“. Riziko, že někdo omylem spustí test nad produkční databází, doporučuji nepodceňovat.
Aplikace
Máme jednoduchou databázi produktů a kategorií:
CREATE TABLE IF NOT EXISTS `product` ( `id` int(11) NOT NULL, `name` varchar(255) NOT NULL, `price` decimal(10,2) NOT NULL, `url` varchar(255) NOT NULL, `category_id` int(11) NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8; CREATE TABLE IF NOT EXISTS `category` ( `id` int(11) NOT NULL, `name` varchar(255) NOT NULL, `url` varchar(255) NOT NULL, `visible` tinyint(1) NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8; ALTER TABLE `product` ADD PRIMARY KEY (`id`), ADD KEY `category_id` (`category_id`); ALTER TABLE `product` MODIFY `id` int(11) NOT NULL AUTO_INCREMENT; ALTER TABLE `category` ADD PRIMARY KEY (`id`); ALTER TABLE `category` MODIFY `id` int(11) NOT NULL AUTO_INCREMENT; ALTER TABLE `product` ADD CONSTRAINT `product_ibfk_2` FOREIGN KEY (`category_id`) REFERENCES `category` (`id`) ON DELETE NO ACTION ON UPDATE NO ACTION; INSERT INTO `category` (`id`, `name`, `url`, `visible`) VALUES (1, 'Fotoaparáty', 'foto', 1), (2, 'Objektivy', 'objektivy', 1), (3, 'Příslušenství', 'prislusenstvi', 0); INSERT INTO `product` (`id`, `name`, `price`, `url`, `category_id`) VALUES (1, 'Nikon D5300', '13999.00', 'nikon-d5300', 1), (2, 'Nikon D850', '79999.00', 'nikon-d850', 1), (3, 'Canon EOS 5D Mark IV', '55499.00', 'canon-eos-5d-mark-iv', 1), (4, 'Nikon 200-500 mm f/5,6E ED VR', '29999.00', 'nikon-200-500mm-f-5-6e-ed-vr', 2), (5, 'Nikon 35 mm f/1,8 AF-S G', '14999.00', 'nikon-35-1-8-af-s-g', 2), (6, 'Krytka objektivu Nikon', '499.00', 'krytka-objektivu-nikon', 3), (7, 'Krytka objektivu Canon', '459.00', 'krytka-objektivu-canon', 3);
A jednoduchý model, který si injectujeme do Presenteru a vrací nám, co zrovna potřebujeme.
<?php declare(strict_types=1); namespace App\Models; use Nette\Database\Context; use Nette\Database\Table\ActiveRow; use Nette\Database\Table\Selection; use Nette\Utils\Strings; class SimpleModel { /** @var Context */ private $db; public function __construct(Context $db) { $this->db = $db; } public function getProducts(): Selection { return $this->db->table('product'); } public function getVisibleProducts(): Selection { return $this->db->table('product')->where('category.visible', 1); } public function getProduct($id): ?ActiveRow { return $this->db->table('product')->get($id); } public function createProduct(iterable $data) { $sql = [ 'name' => (string) $data->name, 'price' => (float) $data->price, 'category_id' => (int) $data->category_id, 'url' => Strings::webalize($data->url), ]; return $this->db->table('product')->insert($sql); } }
A právě tento model potřebujeme testovat.
Připojení k testovací databázi
Pokud ještě žádný test nemáte, vytvořte si v projektu adresář tests a v příkazové řádce napište
composer require --dev nette/tester
Ve složce tests si vytvořte jednoduchý bootstrap.php
<?php declare(strict_types=1); require_once __DIR__ . '/../vendor/autoload.php'; Tester\Environment::setup(); define('TEMP_DIR', __DIR__ . '/tmp'); @mkdir(dirname(TEMP_DIR)); @mkdir(TEMP_DIR);
Nejprve si vytvoříme traitu ConnectionHelper, kterou použijeme v každém TestCase, která bude mít za úkol připojit se k databázi. Je třeba zajistit, aby se automatické testy nikdy a za žádných okolností nemohly připojit k produkční databázi (nepozorností, přehlédnutím – tedy v případech, k nimž dochází asi tak 100x častěji, než je většina programátorů ex ante ochotna připustit). Doporučuji tedy mít testovací databázi co nejvíce oddělenou (jiné jméno, username, password) a ideálně v testech neincludovat žádný konfigurační soubor z testované aplikace.
Traita bude mít za úkol vytvořit Nette\Database\Connection, případně Nette\Database\Context, který potřebuje náš SimpleModel k životu.
<?php declare(strict_types=1); use Nette\Caching\Storages\FileStorage; use Nette\Database\Connection; use Nette\Database\Context; use Nette\Database\Conventions\DiscoveredConventions; use Nette\Database\Helpers; use Nette\Database\Structure; trait ConnectionHelper { private $dsn = 'mysql:host=127.0.0.1;dbname=mt_test'; private $user = 'travis'; private $password = ''; public function getConnection(): Connection { $database = new Connection($this->dsn, $this->user, $this->password); if($database->query("SHOW TABLES;")->getRowCount() == 0) { Helpers::loadFromFile($database, __DIR__ . '/../../app/sql/structure.sql'); } Helpers::loadFromFile($database, __DIR__ . '/testing_data.sql'); return $database; } public function getContext(): Context { $storage = new FileStorage(TEMP_DIR); $connection = $this->getConnection(); $structure = new Structure($connection, $storage); $conventions = new DiscoveredConventions($structure); $context = new Context($connection, $structure, $conventions, $storage); return $context; } }
Pokud je databáze prázdná, vytvoří se její struktura a naplní se testovacími daty.
Testovací data je možné připravit zkopírováním dat aktuálních a jejich vhodnou úpravou (osobní a citlivé údaje, zmenšní rozsahu apod.). V rámci naplnění testovacích dat doporučuji na začátku vždy tabulky vyprázdnit.
# Enforce database name # To prevent accidentally deleting live data USE `mt_test`; SET FOREIGN_KEY_CHECKS=0; TRUNCATE TABLE `mt_test`.`category`; TRUNCATE TABLE `mt_test`.`product`; SET FOREIGN_KEY_CHECKS=1; INSERT INTO `category` (`id`, `name`, `url`, `visible`) VALUES (1, 'Fotoaparáty TEST', 'foto', 1), (2, 'Objektivy TEST', 'objektivy', 1), (3, 'Příslušenství TEST', 'prislusenstvi', 0); INSERT INTO `product` (`id`, `name`, `price`, `url`, `category_id`) VALUES (1, 'Nikon D5300 TEST', '13999.00', 'nikon-d5300', 1), (2, 'Krytka objektivu Nikon TEST', '499.00', 'krytka-objektivu-nikon', 3), (3, 'Nikon D850 TEST', '79999.00', 'nikon-d850', 1), (4, 'Nikon 200-500 mm f/5,6E ED VR TEST', '29999.00', 'nikon-200-500mm-f-5-6e-ed-vr', 2), (5, 'Canon EOS 5D Mark IV TEST', '55499.00', 'canon-eos-5d-mark-iv', 1), (6, 'Nikon 35 mm f/1,8 AF-S G TEST', '14999.00', 'nikon-35-1-8-af-s-g', 2), (7, 'Krytka objektivu Canon TEST', '459.00', 'krytka-objektivu-canon', 3);
Vytvoření prvního testu
Výborně, vše máme připraveno, připravíme si první test v souboru SimpleModel.phpt:
<?php declare(strict_types=1); use App\Models\SimpleModel; use Tester\Assert; use Tester\TestCase; require __DIR__ . '/../bootstrap.php'; class SimpleModelTest extends TestCase { use ConnectionHelper; public function testClassTypes() { $model = new SimpleModel($this->getContext()); Assert::type(SimpleModel::class, $model); } } (new SimpleModelTest())->run();
Vytvoříme si nový TestCase jako potomka Tester\TestCase, použijeme vytvořenou traitu a napíšeme první test, který ověří, že vše funguje jak má – tedy, že se nám traita připojí do databáze a vrátí Nette\Database\Context, který pak v konstruktoru předáváme našemu modelu.
Spustíme test a v tento moment pravděpodobně dostaneme chybovou hlášku:
d:\www\model-testing>vendor\bin\tester tests -C _____ ___ ___ _____ ___ ___ |_ _/ __)( __/_ _/ __)| _ ) |_| \___ /___) |_| \___ |_|_\ v2.3.0 PHP 7.3.6 (cli) | php -n | 8 threads F -- FAILED: SimpleModel.phpt Exited with error code 255 (expected 0) Fatal error: Trait 'ConnectionHelper' not found in D:\www\model-testing\tests\unit\SimpleModel.phpt on line 11 FAILURES! (1 test, 1 failure, 0.0 seconds)
Do autoload composer.json je totiž potřeba přidat adresáře app a tests, abyste nemuseli jednotlivé části testů (traitu) i testované modely includovat ručně.
"autoload": { "classmap": ["app/", "tests/"] },
Následně v příkazové řádce spusťte příkaz composer dump
, čímž dojde k přegenerování autoload souborů.
Nyní by již test měl skončit úspěšně:
d:\www\model-testing>vendor\bin\tester tests -C _____ ___ ___ _____ ___ ___ |_ _/ __)( __/_ _/ __)| _ ) |_| \___ /___) |_| \___ |_|_\ v2.3.0 PHP 7.3.6 (cli) | php | 8 threads . OK (1 test, 0.5 seconds)
Další testy
Můžeme pokračovat v psaní zajímavějších testů. Je třeba pamatovat na to, že Nette\Tester ve výchozím nastavení spouští více vláken paralelně. To by mohlo vést v případě testování databáze k mnoha nelogickým a těžko odhalitelným chybám.
Do SimpleModel.phpt přidáme ke všem metodám zámek, který zajistí, že testy proběhnou postupně jeden za druhým.
public function setUp() { Environment::lock('database', __DIR__ . '/../tmp'); }
Můžeme si otestovat, jestli nám metody pro čtení produktů getProducts() a getProductsVisible() vracejí, co mají:
public function testProductsListing() { $model = new SimpleModel($this->getContext()); // --- getProducts() $products = $model->getProducts()->order('id ASC')->fetchAll(); $keys = array_keys($products); Assert::count(7, $products); Assert::same(1, array_shift($keys)); Assert::same('Nikon D5300 TEST', array_shift($products)->name); Assert::same(2, array_shift($keys)); Assert::same('Krytka objektivu Nikon TEST', array_shift($products)->name); // --- getVisibleProducts() $visibleProducts = $model->getVisibleProducts()->order('id ASC')->fetchAll(); $keys = array_keys($visibleProducts); Assert::count(5, $visibleProducts); Assert::same(1, array_shift($keys)); Assert::same('Nikon D5300 TEST', array_shift($visibleProducts)->name); Assert::same(3, array_shift($keys)); Assert::same('Nikon D850 TEST', array_shift($visibleProducts)->name); }
Taky bychom mohli otestovat, jestli nám funguje čtení konkrétního produktu podle ID.
public function testProductReading() { $model = new SimpleModel($this->getContext()); // existing $product = $model->getProduct(4); Assert::type(ActiveRow::class, $product); Assert::same(4, $product->id); Assert::same('Nikon 200-500 mm f/5,6E ED VR TEST', $product->name); Assert::same(29999.00, $product->price); Assert::same('nikon-200-500mm-f-5-6e-ed-vr', $product->url); Assert::same('Objektivy TEST', $product->category->name); // not existing $product = $model->getProduct(8); Assert::null($product); }
A nakonec ověříme vytváření nových produktů:
public function testProductCreate() { $model = new SimpleModel($this->getContext()); $data = ArrayHash::from([ 'name' => 'Newly created product', 'price' => 123456.78, 'url' => 'newly created product', 'category_id' => 2, ]); $product = $model->createProduct($data); Assert::type(ActiveRow::class, $product); Assert::same(8, $product->id); $product = $model->getProduct(8); Assert::same('Newly created product', $product->name); Assert::same(123456.78, $product->price); Assert::same('newly-created-product', $product->url); Assert::same(2, $product->category_id); }
Celý kód
Celý jednoduchý příklad včetně testů v Travisu najdete na mém Githubu.
Další možnosti
Myšlenka je poměrně jednoduchá, má jistě mnoho nedostatků a dá se dále vylepšovat:
- Ukázkové testy nejsou unit testy per se, například v případě testProductCreate() závisí test nejen na metodě createProduct(), ale i správném čtení via getProduct()
- Současná podoba traity ConnectionHelper nepočítá s možností, že dojde ke změně struktury. U Travis CI to nevadí, protože tam se vždy vytváří nová databáze. Kdybyste ale testy spouštěli na localhostu, je potřeba rozšířit přípravnou rutinu i o DROP TABLE
- Současná varianta testovacích dat je dosti nepohodlná v případě časté změny databázové struktury nebo častých bugfixů (protože bug nemusí být ve stávajícím stavu testovacích dat reproduktovatelný). V pokročilejších aplikacích na to máme Fixtures, přiznám se ale, že nevím, jestli se dají použít i mimo ORM prostředí. Zajímavou alternativou by mohla představovat testovací data v CSV souborech, která se dají snadno spravovat v Excelu (když si ohlídáte problémy typu 4.4 != 4. dubna) a lze je velmi jednoduše parsovat pomocí pár řádek PHP kódu
- I databázové testy lze paralelizovat, což by se mohlo hodit zejména v případě opravdu velkého počtu testů. Pak je ale třeba, aby testovací uživatel měl práva na vytváření a mazání databází. Každý test si pak může vytvořit novou databázi, třeba podle identifikátoru aktuálního vlákna, naplnit ji daty, připojit se k ní, provést všechny Asserts a následně ji zase zničit.