Píšeme testy

Každý test je běžný PHP skript. I když se to zdá jako maličkost, je v tom ukryt velký potenciál. Test můžeme krokovat v IDE anebo načíst přes webový prohlížeč.

Aserce

Na stránce s průvodcem jsme v ukázkovém testu použili dvě aserce: Assert::same() a Assert::exception(). Nyní si ukážeme i všechny ostatní a vysvětlíme, jak se používají. Jsou to metody třídy Tester\Assert, ale pro zjednodušení je dále uvádíme bez jmenného prostoru.

Assert::same($expected, $actual)

$expected musí být totožný s $actual. To samé jako PHP operátor ===.

Assert::notSame($expected, $actual)

Opak Assert::same().

Assert::equal($expected, $actual)

$expected musí být stejný s $actual. Ignoruje se identita objektů a pořadí klíčů v polích.

Assert::notEqual($expected, $actual)

Opak Assert::equal().

Assert::contains($needle, $actual)

$actual musí obsahovat $needle. Pokud je $actual řetězec, musí obsahovat podřetězec $needle. Pokud je pole, musí obsahovat prvek $needle.

Assert::notContains($needle, $actual)

Opak Assert::contains().

Assert::true($value)

$value musí být true, tedy $value === true.

Assert::truthy($value)

$value musí být pravdivý, tedy $value == true.

Assert::false($value)

$value musí být false, tedy $value === false.

Assert::falsey($value)

$value musí být nepravdivý, tedy $value == false.

Assert::count($count, $value)

$count musí být počet prvků ve $value. Počitatelné je pole nebo objekt implementující Countable.

Assert::null($value)

$value musí být null, tedy $value === null.

Assert::nan($value)

$value musí být Not a Number.

Assert::type($type, $value)

$value musí být daného typu. Jako $type můžeme použít řetězec:

  • array
  • list – je jako array, ale indexy musí být od nuly a číslované po jedné
  • bool
  • callable
  • float
  • int nebo integer
  • null
  • object
  • resource
  • scalar
  • string
  • název třídy nebo přímo objekt, potom musí být $value instanceof $type

Assert::exception($callable, $class, $message = null, $code = null)

Při zavolání $callable musí být vyhozena výjimka instance $class. Pokud uvedeme $message, musí pasovat (viz. Assert::match()) i zpráva výjimky a pokud uvedeme $code, musí se shodovat i kódy. Následující test selže, protože neodpovídá zpráva výjimky:

Assert::exception(function () {
    throw new App\InvalidValueException('Zero value');
}, App\InvalidValueException::class, 'Value is to low');

Assert::exception() je zvláštní tím, že vrací vyhozenou výjimku. Lze tak otestovat i výjimku předchozí.

$e = Assert::exception(function () {
    throw new MyException('Something is wrong', 0, new RuntimeException);
}, MyException::class, 'Something is wrong');

Assert::type(RuntimeException::class, $e->getPrevious());

Assert::error($callable, $type, $message = null)

Pokud jako $type uvedete název třídy, chová se aserce naprosto stejně jako Assert::exception().

Pokud je $type jedna z konstant E_..., tedy například E_WARNING, musí $callable při zavolání tuto chybu vygenerovat. A pokud uvedeme $message, musí pasovat (viz. Assert::match()) i chybová zpráva. Například:

Assert::error(function () {
    $i++;
}, E_NOTICE, 'Undefined variable: i');

A poslední varianta, pokud je $type pole, musí $callable vygenerovat všechny takto očekávané chyby. Nejlépe asi vysvětlí příklad:

Assert::error(function () {
    $a++;
    $b++;
}, [
    [E_NOTICE, 'Undefined variable: a'],
    [E_NOTICE, 'Undefined variable: b'],
]);

Assert::noError($callable)

Kontroluje, že funkce $callable nevygenerovala žádné varování, chybu nebo výjimku. Hodí se pro testování kousků kódu, kde není žádná další aserce.

Assert::match($pattern, $actual)

$actual musí vyhovět vzoru $pattern. Můžeme použít dvě varianty vzorů.

Jako $pattern předáme regulární výraz. K jeho ohraničení musíme použít ~ nebo #, jiné oddělovače nejsou podporovány. Například test, kdy $var musí obsahovat pouze hexadecimální číslice:

Assert::match('#^[0-9a-f]$#i', $var);

Druhá varianta je podobná běžnému porovnání řetězců, ale v $pattern můžeme použít různé modifikátory:

  • %a% one or more of anything except the end of line characters
  • %a?% zero or more of anything except the end of line characters
  • %A% one or more of anything including the end of line characters
  • %A?% zero or more of anything including the end of line characters
  • %s% one or more white space characters except the end of line characters
  • %s?% zero or more white space characters except the end of line characters
  • %S% one or more of characters except the white space
  • %S?% zero or more of characters except the white space
  • %c% a single character of any sort (except the end of line)
  • %d% one or more digits
  • %d?% zero or more digits
  • %i% signed integer value
  • %f% floating point number
  • %h% one or more HEX digits
  • %w% one or more alphanumeric characters
  • %% one % character

Příklady:

# Opět test na hexadecimální číslo
Assert::match('%h%', $var);

# Zobecnění cesty k souboru a čísla řádky
Assert::match('Error in file %a% on line %i%', $errorMessage);

Assert::matchFile($file, $actual)

Aserce je totožná s Assert::match(), ale vzor se načítá ze souboru $file. To je užitečné pro testování velmi dlouhých řetězců. Soubor s testem zůstane přehledný.

Assert::fail($message, $actual = null, $expected = null)

Tato aserce vždy selže. Někdy se to prostě hodí. Volitelně můžeme uvést i očekávanou a aktuální hodnotu.

Zkoumání chybných asercí

Když aserce selže, Tester vypíše, v čem je chyba. Pokud porovnáváme složitější struktury, Tester vytvoří dumpy porovnávaných hodnot a uloží je do adresáře output. Například při selhání smyšleného testu Arrays.recursive.phpt budou dumpy uloženy následovně:

demo/
└── tests/
    ├── output/
    │   ├── Arrays.recursive.actual    # aktuální hodnota
    │   └── Arrays.recursive.expected  # očekávaná hodnota
    │
    └── Arrays.recursive.phpt          # selhávající test

Název adresáře můžeme změnit přes Tester\Dumper::$dumpDir.

Přeskakování testů

Některé testy mohou běžet pouze za určitých podmínek. Pokud nejsou tyto podmínky splněny, můžeme test přeskočit. Například testy jen pro určitý operační systém:

if (defined('PHP_WINDOWS_VERSION_BUILD')) {
    Tester\Environment::skip('Requires UNIX.');
}

Test bude ukončen a označen jako přeskočený (s – skipped). Později se dozvíme, jak přeskočit test pomocí anotace @skip.

TestCase

V jednoduchých testech mohou aserce následovat jedna za druhou. Někdy je ale výhodnější aserce zabalit do testovací třídy a tak je strukturovat. Třída musí být potomkem Tester\TestCase a zjednodušeně o ní mluvíme jako o testcase. Třída musí obsahovat testovací metody začínající na test. Tyto metody budou spuštěny jako testy:

use Tester\Assert;

class GreetingTest extends Tester\TestCase
{
    public function testOne()
    {
        Assert::same(......);
    }

    public function testTwo()
    {
        Assert::match(......);
    }
}

# Spuštění testovacích metod
(new GreetingTest)->run();

Takto napsaný test lze dále obohatit o metody setUp() a tearDown(). Jsou volány před, resp. za každou testovací metodou:

use Tester\Assert;

class NextTest extends Tester\TestCase
{
    public function setUp()
    {
        # Příprava
    }

    public function tearDown()
    {
        # Úklid
    }

    public function testOne()
    {
        Assert::same(......);
    }

    public function testTwo()
    {
        Assert::match(......);
    }
}

# Spuštění testovacích metod
(new NextTest)->run();

/*
Pořadí volání metod
-------------------
setUp()
testOne()
tearDown()

setUp()
testTwo()
tearDown()
*/

Pokud dojde k chybě v setUp() nebo tearDown() fázi, test celkově selže. Pokud dojde k chybě v testovací metodě, i přes to se metoda tearDown() spustí, avšak s potlačením chyb v ní.

Anotace pro TestCase

U testovacích metod máme k dispozici několik anotací, které nám testování usnadní. Zapisujeme je k testovací metodě.

@throws

Je ekvivalentem použití Assert::exception() uvnitř testovací metody. Zápis je ale přehlednější:

/**
 * @throws RuntimeException
 */
public function testOne()
{
    ...
}


/**
 * @throws LogicException  Wrong argument order
 */
public function testTwo()
{
    ...
}

@dataProvider

Chceme-li testovací metodu spustit vícekrát, ale s jinými parametry, hodí se právě tato anotace. Za ní uvedeme název metody, která vrací argumenty pro testovací metodu. Metoda musí vrátit pole nebo Traversable. Jednoduchý příklad:

public function getLoopArgs()
{
    return [
        [1, 2, 3],
        [4, 5, 6],
        [7, 8, 9],
    ];
}


/**
 * @dataProvider getLoopArgs
 */
public function testLoop($a, $b, $c)
{
    ...
}

Druhá varianta anotace @dataProvider přijímá jako parametr cestu k INI souboru (relativně k souboru s testem). Metoda je volána tolikrát, kolik je v INI souboru sekcí. Soubor loop-args.ini:

[one]
a=1
b=2
c=3

[two]
a=4
b=5
c=6

[three]
a=7
b=8
c=9

a metoda, která INI soubor využívá:

/**
 * @dataProvider loop-args.ini
 */
public function testLoop($a, $b, $c)
{
    ...
}

Obdobně můžeme namísto INI souboru odkázat na PHP skript. Ten musí vrátit pole nebo Traversable. Soubor loop-args.php:

return [
   ['a' => 1, 'b' => 2, 'c' => 3],
   ['a' => 4, 'b' => 5, 'c' => 6],
   ['a' => 7, 'b' => 8, 'c' => 9],
];

Anotování souboru s testem

Následující anotace jsou vyhodnoceny pouze pokud test spustíme pomocí Testeru, nikoliv ručně jako běžný PHP skript.

Anotace se zapisují na začátek souboru s testem a nebere se ohled na velikost písmen. Smyšlený příklad:

/**
 * TEST: Basic database query test.
 *
 * @dataProvider files/databases.ini
 * @exitCode 56
 * @phpVersion < 5.5
 */

require __DIR__ . '/../bootstrap.php';

TEST:

To vlastně ani není anotace, pouze určuje nadpis testu, který se vypisuje při selhání nebo do logu.

@skip

Test se přeskočí. Hodí se pro dočasné vyřazení testů.

@phpVersion

Test se přeskočí pokud není spuštěn s odpovídající verzí PHP. Anotaci zapisujeme jako @phpVersion [operator] verze. Operátor můžeme vynechat, výchozí je >=. Příklady:

/**
 * @phpVersion 5.3.3
 * @phpVersion < 5.5
 * @phpVersion != 5.4.5
 */

@phpExtension

Test se přeskočí, pokud nejsou načtena všechna uvedená PHP rozšíření. Více rozšížení můžeme uvést v jedné anotaci, nebo ji použít vícekrát.

/**
 * @phpExtension pdo, pdo_pgsql, pdo_mysql
 * @phpExtension json
 */

@dataProvider

Zapisujeme jako @dataProvider file.ini, cesta k souboru se bere relativně k souboru s testem. Test bude spuštěn tolikrát, kolik je sekcí v INI souboru. Předpokládejme INI soubor databases.ini:

[mysql]
dsn = "mysql:host=127.0.0.1"
user = root
password = ******

[postgresql]
dsn = "pgsql:host=127.0.0.1;dbname=test"
user = postgres
password = ******

[sqlite]
dsn = "sqlite::memory:"

a ve stejném adresáři test database.phpt :

/**
 * @dataProvider databases.ini
 */

$args = Tester\Environment::loadData();

Test bude spuštěn třikrát a $args bude obsahovat vždy hodnoty ze sekce mysql, postgresql nebo sqlite.

Existuje ještě varianta, kdy anotaci zapíšeme s otazníkem jako @dataProvider? file.ini. V tomto případě se test přeskočí, pokud INI soubor neexistuje.

Tím možnosti anotace nekončí. Za název INI souboru můžeme specifikovat podmínky, za kterých bude test pro danou sekci spuštěn. Rozšíříme INI soubor:

[mysql]
dsn = "mysql:host=127.0.0.1"
user = root
password = ******

[postgresql 8.4]
dsn = "pgsql:host=127.0.0.1;dbname=test"
user = postgres
password = ******

[postgresql 9.1]
dsn = "pgsql:host=127.0.0.1;dbname=test;port=5433"
user = postgres
password = ******

[sqlite]
dsn = "sqlite::memory:"

a použijeme anotaci s podmínkou:

/**
 * @dataProvider  databases.ini  postgresql, >=9.0
 */

Test bude spuštěn pouze jednou a to pro sekci postgresql 9.1. Ostatní sekce filtrem podmínky neprojdou.

Obdobně můžeme namísto INI souboru odkázat na PHP skript. Ten musí vrátit pole nebo Traversable. Soubor databases.php:

return [
    'postgresql 8.4' => [
        'dsn' => '...',
        'user' => '...',
    ],

    'postgresql 9.1' => [
        'dsn' => '...',
        'user' => '...',
    ],
];

@multiple

Zapisujeme jako @multiple N, kde N je celé číslo. Test bude spuštěn právě N-krát.

@testCase

Anotace nemá parametry. Použijeme ji, pokud testy píšeme jako Tester\TestCase třídy. V tom případě je test spuštěn tolikrát, kolik má testovacích metod. Každá testovací metoda se pak spustí v samostatném procesu. To může výrazně urychlit celý proces testování.

@exitCode

Zapisujeme jako @exitCode N, kde N je návratový kód spuštěného testu. Je-li v testu například voláno exit(10), anotaci zapíšeme jako @exitCode 10 a pokud test skončí s jiným kódem, je to považováno za selhání. Pokud anotaci neuvedeme, je ověřen návratový kód 0 (nula).

@httpCode

Anotace se uplatní pouze pokud je PHP binárka CGI. Jinak se ignoruje. Zapisujeme jako @httpCode NNN kde NNN je očekávaný HTTP kód. Pokud anotaci neuvedeme, ověřuje se HTTP kód 200. Pokud NNN zapíšeme jako řetězec vyhodnocený na nulu, například any, HTTP kód se neověřuje.

@outputMatch a @outputMatchFile

Funkce anotací je shodná s asercemi Assert::match() a Assert::matchFile(). Vzor (pattern) se ale hledá v textu, který test poslal na svůj standardní výstup. Uplatnění najde pokud předpokládáme, že test skončí fatal errorem a my potřebujeme ověřit jeho výstup.

@phpIni

Pro test nastavuje konfigurační INI hodnoty. Zapisujeme například jako @phpIni precision=20 a funguje stejně, jako kdybychom zadali hodnotu z příkazové řádky přes parametr -d precision=20.

Helpery

DomQuery

Tester\DomQuery je třída usnadňující testování obsahu HTML nebo XML. Popis všech metod najdeme v API dokumentaci, zde si ukážeme základní použití:

# HTML, které chceme testovat
$html = $template->render();

# Z HTML vytvoříme DOM strukturu
$dom = Tester\DomQuery::fromHtml($html);

# Můžeme testovat
Assert::true($dom->has('form#registration'));
Assert::true($dom->has('input[name="username"]'));
Assert::true($dom->has('input[name="password"]'));
Assert::true($dom->has('input[type="submit"]'));

Jako selektor metody DomQuery::has($selector) používáme CSS selektory.

FileMock

Tester\FileMock emuluje v paměti soubory a usnadňuje testování kódu, který používá funkce fopen(), file_get_contents(), parse_ini_file() a podobné. Příklad použití:

# Testovaná třída
class Logger
{
    private $logFile;

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

    public function log($message)
    {
        file_put_contents($this->logFile, $message . "\n", FILE_APPEND);
    }
}

# Nový prázdný soubor
$file = Tester\FileMock::create('');

$logger = new Logger($file);
$logger->log('Login');
$logger->log('Logout');

# Testujeme vytvořený obsah
Assert::same("Login\nLogout\n", file_get_contents($file));

Environment::purge()

Třída Tester\Helpers nám nabízí metodu purge(), která vytváří zadaný adresář, ale pokud ten již existuje, smaže celý jeho obsah. Hodí se pro vytvoření dočasného adresáře. Například v tests/bootstrap.php:

@mkdir(__DIR__ . '/tmp');  # @ - adresář již může existovat

define('TEMP_DIR', __DIR__ . '/tmp/' . getmypid());
Tester\Helpers::purge(TEMP_DIR);

Environment::lock()

Dále se dozvíme, že se testy spouštějí paralelně. Někdy ovšem potřebujeme, aby se běh testů nepřekrýval. Typicky u databázových testů je nutné, aby si test připravil obsah databáze a jiný test mu po čas běhu do databáze nesahal. V těchto testech použijeme Tester\Environment::lock($name, $dir):

Tester\Environment::lock('database', __DIR__ . '/tmp');

První parametr je jméno zámku, druhý je cesta k adresáři pro uložení zámku. Test, který získá zámek první proběhne, ostatní testy musí počkat na jeho dokončení.

Environment::bypassFinals()

Třídy anebo metody označené jako final se obtížně testují. Volání Tester\Environment::bypassFinals() na začátku testu způsobí, že jsou klíčová slova final během načítání kódu vypuštěna.

require __DIR__ . '/bootstrap.php';

Tester\Environment::bypassFinals();

class MyClass extends NormallyFinalClass  # <-- NormallyFinalClass už není final
{
    ...
}

Environment::setup()

  • zlepší čitelnost výpisu chyb (včetně obarvování), jinak je vypsán výchozí PHP stack trace
  • zapne kontrolu, že byly v testu volány aserce, jinak test bez asercí (například zapomenutých) projde také
  • při použití --coverage spustí automaticky sběr informací o spuštěném kódu (popsáno dále)

Použití je volitelné, ale doporučované.

Environment::RUNNER

Může být užitečné vědět, jestli jsme test spustili ručně, anebo pomocí Testeru.

if (getenv(Tester\Environment::RUNNER)) {
    # spuštěno Testerem
} else {
    # spuštěno jinak
}

Environment::THREAD

Tester spouští testy paralelně v zadaném počtu vláken. Pokud nás zajímá číslo vlákna, zjistíme ho z proměnné prostředí:

echo "Běžím ve vlákně číslo " . getenv(Tester\Environment::THREAD).

Tipy

  • Není dobré používat exit() a die() k ukončení testu s chybovou zprávou. Například exit('Error in connection') ukončí test s návratovým kódem 0 (nula) což signalizuje úspěch. Vždy použijte echo 'Error in connection' a následně exit(1).
  • Vybírejte co nejvhodnější aserce. Například Assert::same($a, $b), nikoliv Assert::true($a === $b). Pouze v prvním případě dostaneme smysluplnou chybovou zprávu. V druhém případě pouze false should be true což nám o obsahu proměnných $a a $b nic neříká.
  • Pro testování NAN hodnoty (Not a Number) použijte Assert::nan(). Hodnota NAN je velmi specifická a aserce Assert::same() nebo Assert::equal() mohou fungovat neočekávaně.

API dokumentace

Vygenerovanou API dokumentaci najdete tady.