Testování modelů s databází v Nette\Tester

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:

  1. 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.
  2. Data uchováváme v MySQL.
  3. Aplikace používá composer balíčky.
  4. 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.