7. Základní možnosti při psaní testovacích případů
PHPUnit nám dává široké možnosti při vytváření testovacích případů. Máme k dispozici tři základní sady možností:
- použití různých
assertXY()
metod ze třídyPHPUnit\Framework\Assert
ve smyslu rovnosti / nerovnosti - testování vyhození / nevyhození výjimek
- definování akcí, které mají běžet před spuštěním celého testu a i jednotlivých testovacích případů a po jejich ukončení
Je zcela běžné, že testovací třída obsahuje více testovacích případů – podrobně viz dále. Pak se nazývá testovací sada (test suite [swi:t]).
7.1. Testovací metody z PHPUnit\Framework\Assert
Třída PHPUnit\Framework\TestCase
dědí od třídy PHPUnit\Framework\Assert
, takže může využívat všechny dále zmiňované metody. Z důvodů přehlednosti bude v souvislosti s assert metodami zmiňována rodičovská třída PHPUnit\Framework\Assert
.
Třída PHPUnit\Framework\Assert
dává k dispozici značné množství assert metod (více než 100). Všechny poskytují výsledek podle stejného principu:
- V případě selhání (tj. assert nevyhověl) zajistí, že je vyhozena výjimka
ExpectationFailedException
a je vygenerována informace o selhání. - V případě úspěchu se neděje nic, což znamená, že testovací případ prošel bez problémů.
Assert metody jsou statické, ovšem v kombinaci se třídou PHPUnit\Framework\TestCase
je lze volat i jako instanční. To umožňuje dva rovnocenné způsoby volání:
$this->assertEquals(2, $predmet->getZnamka());
self::assertEquals(2, $predmet->getZnamka());
Poznámka:
Je třeba si zvolit jeden z nich a ten důsledně používat. Běžnější způsob je $this->assert
.
Každá assert metoda má přetíženou verzi se zadanou chybovou zprávou pro generování případné dodatečné informace o důvodu selhání. Tuto dodatečnou informaci typu string
přidává programátor testovacího případu. Doporučuje se tuto možnost používat.
Ovšem informace o tom, proč aserce neprošla, je vypsána i při použití assert metody bez zprávy. Rozdíly v chybové zprávě budou patrné z následujícího příkladu.
<?php namespace app\zaklad; use app\zaklad\HodnoceniPredmetu; use PHPUnit\Framework\TestCase; class HodnoceniPredmetuTestZprava extends TestCase { public function testSetZnamka_BezZpravy() : void { $predmet = new HodnoceniPredmetu("Matika"); $predmet->setZnamka(3); $this->assertEquals(2, $predmet->getZnamka()); } public function testSetZnamka_SeZpravou() : void { $predmet = new HodnoceniPredmetu("Matika"); $predmet->setZnamka(3); $this->assertEquals(2, $predmet->getZnamka(), "Vracena hodnota znamky je chybna: "); } }
Vypíše:
1) testSetZnamka_BezZpravy() Failed asserting that 3 matches expected 2. Expected :2 Actual :3 2) testSetZnamka_SeZpravou() Vracena hodnota znamky je chybna: Failed asserting that 3 matches expected 2. Expected :2 Actual :3
Pokud chceme programově způsobit selhání testu (praktické použití viz dále), použijeme motodu fail()
nebo fail(string : chybová_zpráva)
Poznámka:
PHPUnit ve verzi 9.5 dává k dispozici 60 různých assert metod. K nim přibývají vždy ještě jejich „Not“ verze. Například assertEquals()
a assertNotEquals()
. A samozřejmě vždy i jejich přetížené verze s dodatečnou zprávou.
7.1.1. Testy rovnosti
Jedná se o základní a nejčastější typ assert metody přetížené pro všechny datové typy. Vždy platí, že rovnají-li se hodnoty, test prošel.
assertEquals(datový_typ očekávanáHodnota, datový_typ skutečnáHodnota)
Pro nerovnost lze použít assertNotEquals()
.
Pokud porovnáváme reálná čísla, tj. typy double
nebo float
, použijeme verzi s tolerancí přesnosti:
class DoubleTest extends TestCase { public function testPresnost_OK() { $this->assertEqualsWithDelta(pi(), 3.14, 0.01); } public function testPresnost_Chyba() { $this->assertEqualsWithDelta(pi(), 3.14, 0.0001, "Presnost na 0.0001"); } }
Pro druhý testovací případ vypíše:
Presnost na 0.0001 Failed asserting that 3.14 matches expected 3.141592653589793. Expected :3.1415926535898 Actual :3.14
Testy rovnosti lze použít i pro porovnávání objektů. Pozor však na to, že testy rovnosti jsou jiné, než testy shodnosti assertSame()
– viz dále.
7.1.2. Testy logické hodnoty
assertFalse(bool skutečnáLogickáHodnota) assertTrue(bool skutečnáLogickáHodnota)
Tyto metody jsou často (nevhodně) nahrazovány předchozím asertem:
assertEquals(true/false, skutečnáLogickáHodnota)
7.1.3. Testy shodnosti
assertSame(mixed $expected, mixed $actual) assertNotSame(mixed $expected, mixed $actual)
U těchto metod musejí mít porovnávané veličiny stejný typ a stejnou hodnotu.
class SameTest extends TestCase { public function testRovnost_OK() { $this->assertEquals("3.14", 3.14); } public function testShodnost_Chyba() { $this->assertSame("3.14", 3.14); } }
Po spuštění testRovnost_OK()
projde. Ale testShodnost_Chyba()
selže a vypíše:
Failed asserting that 3.14 is identical to '3.14'
7.1.4. Testy řetězců
Řetězce se dají samozřejmě porovnávat na rovnost pomocí obecné assertEquals()
.
V této skupině jsou uvedeny assert metody určené speciálně pro řetězce, přičemž názvy metod jsou dostatečně významové:
assertStringContainsString(string $cast, string $celek)
assertStringContainsStringIgnoringCase(string $cast, string $celek)
assertEqualsIgnoringCase(mixed $expected, mixed $actual)
assertStringStartsWith(string $prefix, string $string)
assertStringEndsWith(string $suffix, string $string)
assertMatchesRegularExpression(string $pattern, string $string)
assertStringMatchesFormat(string $format, string $string)
assertStringEqualsFile(string $expectedFile, string $actualString)
7.1.5. Testy datových typů
assertIsArray($actual)
assertIsBool($actual)
assertIsFloat($actual)
assertIsInt($actual)
assertIsNumeric($actual)
assertIsObject($actual)
assertIsString($actual)
assertIsIterable($actual)
7.1.6. Testy adresářů a souborů
assertDirectoryExists(string $directory)
assertDirectoryIsReadable(string $directory)
assertDirectoryIsWritable(string $directory)
assertFileEquals(string $expected, string $actual)
assertFileExists(string $filename)
assertFileIsReadable(string $filename)
assertFileIsWritable(string $filename)
7.1.7. Testy nerovností
assertGreaterThan(mixed $expected, mixed $actual)
assertGreaterThanOrEqual(mixed $expected, mixed $actual)
assertLessThan(mixed $expected, mixed $actual)
assertLessThanOrEqual(mixed $expected, mixed $actual)
7.1.8. Testy speciálních hodnot
assertEmpty(mixed $actual)
assertNull(mixed $variable)
assertNan(mixed $variable)
assertInfinite(mixed $variable)
7.1.9. Testy existence prvku v kontejneru
assertArrayHasKey(mixed $key, array $array)
assertContains(mixed $prvek, iterable $kontejner)
assertContainsOnly(string $type, iterable $kontejner)
assertCount($expectedCount, $kontejner)
7.1.10. Testy tříd a objektů tříd
assertClassHasAttribute(string $attributeName, string $className)
assertClassHasStaticAttribute(string $attributeName, string $className)
assertContainsOnlyInstancesOf(string $classname, array $seznam)
assertObjectEquals(object $expected, object $actual, string $method = 'equals')
assertContainsOnlyInstancesOf(string $classname, array $seznam)
assertInstanceOf($expected, $actual)
assertObjectHasAttribute(string $attributeName, object $object)
7.1.11. Testy formátů typu JSON a XML
assertJsonFileEqualsJsonFile(mixed $expectedFile, mixed $actualFile)
assertJsonStringEqualsJsonFile(mixed $expectedFile, mixed $actualJsonString)
assertJsonStringEqualsJsonString(mixed $expectedJson, mixed $actualJson)
assertXmlFileEqualsXmlFile(string $expectedFile, string $actualFile)
assertXmlStringEqualsXmlFile(string $expectedFile, string $actualXmlString)
assertXmlStringEqualsXmlString(string $expectedXml, string $actualXml)
7.2. Testy vyhození výjimek
V jednotkových testech potřebujeme někdy ověřit i vyhození očekávané výjimky. Toto se používá pro testování metod, které pracují s výjimkami, např. metoda setZnamka()
ze třídy HodnoceniPredmetu
.
/** * Nastavuje známku v rozsahu od VYBORNE do NEDOSTATECNE * @param znamka nastavovaná známka * @throws InvalidArgumentException pokud je známka mimo rozsah */ public function setZnamka(int $znamka) : void { if ($znamka >= self::VYBORNE && $znamka <= self::NEDOSTATECNE) { $this->znamka = $znamka; } elseif ($this->znamka === null && $znamka == self::DOSUD_NEHODNOCENO) { // prvni nastaveni v konstruktoru $this->znamka = $znamka; } else { throw new \InvalidArgumentException($znamka); } }
Pro test vyhození výjimky se používá expectException(jmenoVyjimky::class)
, která se uvádí před kódem, který výjimku vyvolá. Test – je to negativní test – projde, pokud je během něho vyhozena tato konkrétní výjimka. Test selže v ostatních případech, tj. vyhození jiné výjimky nebo nevyhození výjimky vůbec.
Pro speciálnější test výjimky se používá expectExceptionMessage(string $zprava)
, kdy test projde, pokud je během něho vyhozena výjimka generující danou zprávu. A test neprojde v ostatních případech, tj. výjimka generuje jinou zprávu nebo výjimka není vůbec vyhozena.
Poznámka:
V případech 2. a 3. je porušeno pravidlo, že „testujeme aby test prošel“. Tyto testovací případy jsou napsány tak, aby bylo možné ověřit, jak vypadá selhání testu. Proto jejich název končí na Chyba
. Případy 1. a 4. z tohoto důvodu končí na OK
, byť je to zbytečné (je to očekávaný způsob testování) a běžně toto označení nepoužíváme.
- // 1.
testSetZnamka_Vyjimka_OK()
- – testuje přímo výjimku
InvalidArgumentException
, která je metodou vyhazována při nesprávné hodnotě parametruznamka
(zde hodnota8
) - – test projde
- // 2.
testSetZnamka_Vyjimka_Chyba()
- – testuje přímo výjimku
InvalidArgumentException
, která ale zde není metodou vyhazována, protože hodnota parametruznamka
je správná (zde hodnota1
) - – test selže, je vypsáno:
Failed asserting that exception of type "InvalidArgumentException" is thrown.
- // 3.
testSetZnamka_JinaVyjimka_Chyba()
- – testuje úplně jinou výjimku
OutOfRangeException
, která ale není metodou vyhazována, ačkoliv je nesprávná hodnota parametruznamka
(zde hodnota8
) - – test selže, je vypsáno:
Failed asserting that exception of type "InvalidArgumentException" matches expected exception "OutOfRangeException". Message was: "8"
- // 4.
testSetZnamka_VyjimkaZprava_OK()
- – pomocí metody
expectExceptionMessage()
testuje zprávu vyhozenou výjimkouInvalidArgumentException
, která je metodou vyhazována při nesprávné hodnotě parametruznamka
(zde hodnota8
) - – test projde
Testovací třída má obsah:
class VyjimkyTest extends TestCase { // 1. public function testSetZnamka_Vyjimka_OK() { $predmet = new HodnoceniPredmetu("Matika"); $this->expectException(\InvalidArgumentException::class); $predmet->setZnamka(8); } // 2. public function testSetZnamka_Vyjimka_Chyba() { $predmet = new HodnoceniPredmetu("Matika"); $this->expectException(\InvalidArgumentException::class); $predmet->setZnamka(1); } // 3. public function testSetZnamka_JinaVyjimka_Chyba() { $predmet = new HodnoceniPredmetu("Matika"); $this->expectException(\OutOfRangeException ::class); $predmet->setZnamka(8); } // 4. public function testSetZnamka_VyjimkaZprava_OK() { $predmet = new HodnoceniPredmetu("Matika"); $this->expectExceptionMessage("8"); $predmet->setZnamka(8); } }
7.3. Akce před a po spuštění testovacích případů
Pro testování metod jedné třídy nebo i jedné metody používáme běžně více testovacích případů – pozitivní i negativní testy. Opakované testování ale s sebou nese problém, že by se často opakoval kód nastavení podmínek pro testovací případ. Tím by docházelo k porušení principu DRY (Don't Repeat Yourself).
Tento neblahý stav lze laicky vyřešit např. umístěním společného „nastavovacího“ kódu do samostatné privátní metody, zde nastaveni()
.
<?php namespace app\fixture; use app\zaklad\HodnoceniPredmetu; use PHPUnit\Framework\TestCase; class HodnoceniPredmetuTestOpakovane extends TestCase { private $predmet; private function nastaveni() : void { $this->predmet = new HodnoceniPredmetu("Matika"); $this->predmet->setZnamka(1); } public function testSetZnamka() : void { $this->nastaveni(); $this->assertEquals(1, $this->predmet->getZnamka()); } public function testIsNehodnoceno() : void { $this->nastaveni(); $this->assertFalse($this->predmet->isNehodnoceno()); } }
PHPUnit umožňuje toto opakované nastavování (test fixture) vyřešit mnohem elegantněji. Kód lze zjednodušit pomocí čtyř metod se speciálními jmény:
public static function setUpBeforeClass() : void
Provede se jednou před spuštěním všech testovacích případů této třídy. To je výhodné pro nastavení všech případných statických proměnných – typicky je to připojení do databáze nebo nastavení driveru.
public static function tearDownAfterClass() : void
Provede se jednou po skončení všech testovacích případů této třídy, což je výhodné pro závěrečný „úklid“ nebo pro statistiku testu.
public function setUp() : void
Provede se před spuštěním každého testovacího případu. Výhodné pro opakované nastavení testovaného objektu u každého testovacího případu.
public function tearDown() : void
Provede se po ukončení každého testovacího případu – výhodné pro průběžný „úklid“.
Ukázka využití jednotného nastavování – příklad má stejnou funkčnost jako předchozí příklad. Navíc jsou v něm jen informační výpisy na konzoli.
<?php namespace app\fixture; use app\zaklad\HodnoceniPredmetu; use PHPUnit\Framework\TestCase; class HodnoceniPredmetuTest_Fixture extends TestCase { private $predmet; public static function setUpBeforeClass() : void { fwrite(STDOUT, "Před všemi testovacími případy\n"); } public static function tearDownAfterClass() : void { fwrite(STDOUT, "Po všech testovacích případech\n"); } public function setUp() : void { fwrite(STDOUT, " Před každým testovacím případem\n"); $this->predmet = new HodnoceniPredmetu("Matika"); $this->predmet->setZnamka(1); } public function tearDown(): void { fwrite(STDOUT, " Po každém testovacím případu\n"); } public function testSetZnamka() : void { fwrite(STDOUT, " 1.testovací případ\n"); $this->assertEquals(1, $this->predmet->getZnamka()); } public function testIsNehodnoceno() : void { fwrite(STDOUT, " 2.testovací případ\n"); $this->assertTrue($this->predmet->isNehodnoceno()); } }
První test projde, druhý plánovaně selže – to ukazuje že tearDown()
funguje vždy – a vypíše se:
Před všemi testovacími případy Před každým testovacím případem 1.testovací případ Po každém testovacím případu Před každým testovacím případem 2.testovací případ Po každém testovacím případu Failed asserting that false is true. Po všech testovacích případech FAILURES! Tests: 2, Assertions: 2, Failures: 1.
8. Organizace více testovacích případů
Je zcela běžné, že testovací třída obsahuje více testovacích případů (test case) a tím se z ní stává testovací sada (test suite). U testované třídy se testují všechny potřebné (viz dále) metody třídy a konstruktor. Navíc se každá metoda či konstruktor testují pomocí více testovacích případů, což je výhodné ze dvou základních pohledů. Za prvé – testovací metody mohou být dostatečně jednoduché, tj. přehledné a snadno upravovatelné. A za druhé – mohou se vždy zaměřit jen na jednu testovanou vlastnost.
Dohromady jsou všechny testovací případy uloženy v jedné třídě a spuštění testů znamená spuštění všech testů dané testovací třídy.
Pokud je testovaná třída rozsáhlá, lze mít víc tříd testů na jednu testovanou třídu. To nijak nevadí z testerského pohledu, ale z návrhářského pohledu je to často signál, že třída má víc různých zodpovědností.
V každém případě pak existuje několik tříd testů, u kterých je nutno zajistit, aby bylo v případě potřeby možné najednou je všechny spustit – viz dále.
Varování:
Pokud budeme mít v PhpStorm v jednom adresáři více tříd testů a budeme je chtít spouštět všechny najednou (viz dále) musí jména těchto tříd končit na Test
, např. HodnoceniPredmetu_KonstruktorTest
. Pokud budou pojmenovány jinak, např. HodnoceniPredmetuTest_Konstruktor
, půjdou spustit jednotlivě, jako třídy, ale nepůjdou spustit společně.
8.1. Ukázka sady testů entitní třídy
V ukázce budeme testovat tutéž třídu HodnoceniPredmetu
, která je nyní ve jmenném prostoru skutecnytest
. Aby nebylo nutné hledat její zdrojový kód v dřívějších částech, je zde uveden znovu.
<?php namespace app\skutecnytest; class HodnoceniPredmetu { // konstanty pro známkování const VYBORNE = 1; const NEDOSTATECNE = 5; const DOSUD_NEHODNOCENO = 0; const DOSUD_NEHODNOCENO_SLOVY = "N/A"; private $nazev; private $znamka; public function __construct(string $nazev, int $znamka=self::DOSUD_NEHODNOCENO) { $this->nazev = $nazev; $this->setZnamka($znamka); } public function getNazev() : string { return $this->nazev; } public function getZnamka() : int { return $this->znamka; } /** * Nastavuje známku v rozsahu od VYBORNE do NEDOSTATECNE * @param znamka nastavovaná známka * @throws InvalidArgumentException pokud je známka mimo rozsah */ public function setZnamka(int $znamka) : void { if ($znamka >= self::VYBORNE && $znamka <= self::NEDOSTATECNE) { $this->znamka = $znamka; } elseif ($this->znamka === null && $znamka == self::DOSUD_NEHODNOCENO) { // prvni nastaveni v konstruktoru $this->znamka = $znamka; } else { throw new \InvalidArgumentException($znamka); } } /** * Zjistí, zda je předmět dosud nehodnocen * @return true, pokud je předmět nehodnocen */ public function isNehodnoceno() : bool { return ($this->znamka == self::DOSUD_NEHODNOCENO); } public function __toString() : string { if ($this->isNehodnoceno() == true) { return $this->nazev . ": " . self::DOSUD_NEHODNOCENO_SLOVY . "<br>"; } else { return $this->nazev . ": " . $this->znamka . "<br>"; } } }
Tato třída bude otestována testovacími případy rozdělenými do tří tříd (test suite), aby bylo později vidět, jak je možné spustit všechny tři najednou.
Poznámka:
Je nutné dodat, že zde rozdělení do více tříd nemá praktický smysl, protože testovacích případů je relativně málo. Pokud je testovacích případů málo, pak by v reálném případě byl význam dělení do více tříd nejspíše při potřebě mít různé metody setUp()
.
Poznámka:
Všechny uvedené testovací případy až na jeden projdou. Selže testKonstruktor_Znamka()
, aby byl vidět způsob reakce PHPUnit a potažmo i PhpStorm na selhání.
Třída HodnoceniPredmetu_KonstruktorTest
testuje správnou funkci konstruktorů. První testovací případ je víceméně zbytečný, protože se testuje pouze správnost přiřazovacího příkazu. Další dva testy již testují kontrakt.
<?php namespace app\skutecnytest; use app\skutecnytest\HodnoceniPredmetu; use PHPUnit\Framework\TestCase; class HodnoceniPredmetu_KonstruktorTest extends TestCase { public function testKonstruktor_NazevPredmetu() : void { $predmet = new HodnoceniPredmetu("Matika", 5); $this->assertEquals("Matika", $predmet->getNazev()); } public function testKonstruktor_Znamka() : void { $predmet = new HodnoceniPredmetu("Matika", 5); $this->assertEquals(2, $predmet->getZnamka()); } public function testKonstruktor_ZnamkaNevyplnena() : void { $predmet = new HodnoceniPredmetu("Matika"); $this->assertEquals(HodnoceniPredmetu::DOSUD_NEHODNOCENO, $predmet->getZnamka()); } }
Třída HodnoceniPredmetu_ZnamkaTest
testuje všechny základní možnosti metody setZnamka()
.
<?php namespace app\skutecnytest; use app\skutecnytest\HodnoceniPredmetu; use PHPUnit\Framework\TestCase; class HodnoceniPredmetu_ZnamkaTest extends TestCase { private $predmet; public function setUp() : void { $this->predmet = new HodnoceniPredmetu("Matika"); } public function testSetZnamka_DolniMez() : void { $this->predmet->setZnamka(1); $this->assertEquals(1, $this->predmet->getZnamka()); } public function testSetZnamka_HorniMez() : void { $this->predmet->setZnamka(5); $this->assertEquals(5, $this->predmet->getZnamka()); } public function testSetZnamka_MimoMezeDolni() : void { $this->expectException(\InvalidArgumentException::class); $this->predmet->setZnamka(0); } public function testSetZnamka_MimoMezeHorni() : void { $this->expectException(\InvalidArgumentException::class); $this->predmet->setZnamka(6); } }
Třída HodnoceniPredmetu_MetodyTest
testuje zbylé dvě metody.
Z pedagogických důvodů jsou pro metodu isNehodnoceno()
v jednom testovacím případu testovány oba stavy ( true
, false
). Pozor – tento způsob není vhodný. Smyslem xUnit testů je testovat co nejvíce jednoduchých (jednoznačných) příkladů, tj. jeden assert v jednom testovacím případu (viz též dříve pravidlo Arange – Act – Assert). Tento vhodný přístup byl v předchozí ukázce použit pro testy dolní a horní meze známky.
Dále je zde testována metoda __toString()
a to již ve dvou nezávislých případech, což je vhodnější.
<?php namespace app\skutecnytest; use app\skutecnytest\HodnoceniPredmetu; use PHPUnit\Framework\TestCase; class HodnoceniPredmetu_MetodyTest extends TestCase { const NAZEV_PREDMETU = "Matika"; private $predmet; public function setUp() : void { $this->predmet = new HodnoceniPredmetu(self::NAZEV_PREDMETU); } public function testIsNehodnoceno() : void { $this->assertTrue($this->predmet->isNehodnoceno()); $this->predmet->setZnamka(HodnoceniPredmetu::VYBORNE); $this->assertFalse($this->predmet->isNehodnoceno()); } public function testToString_Nehodnoceno() : void { $ocekavano = self::NAZEV_PREDMETU . ": " . HodnoceniPredmetu::DOSUD_NEHODNOCENO_SLOVY . "<br>"; $this->assertEquals($ocekavano, $this->predmet->__toString()); } public function testToString_Hodnoceno() : void { $this->predmet->setZnamka(HodnoceniPredmetu::NEDOSTATECNE); $ocekavano = self::NAZEV_PREDMETU . ": " . HodnoceniPredmetu::NEDOSTATECNE . "<br>"; $this->assertEquals($ocekavano, $this->predmet->__toString()); } }
9. Praktické náležitosti
9.1. Kam se soubory testů fyzicky umísťují
Soubory testů se umísťují do jiného adresáře než testovaná třída, ale do stejného jmenného prostoru. Pro výše uvedený příklad bude výsledná adresářová struktura ze zeleně podbarvenými testy následující:
V textové podobě je struktura souborů a adresářů:
src
app
skutecnytest
HodnoceniPredmetu.class.php
test
app
skutecnytest
HodnoceniPredmetu_KonstruktorTest.php
HodnoceniPredmetu_MetodyTest.php
HodnoceniPredmetu_ZnamkaTest.php
9.2. Jak se spouští najednou testy z více testovacích souborů
Použijeme kontextové menu na adresáři test/app/skutecnytest
, odkud můžeme najednou spustit všechny testy uložené v tomto adresáři.
Po spuštění testů dostaneme (vtestKonstruktor_Znamka()
je opět záměrná chyba, aby byl zřejmý způsob informace o selhání testu):