Testování v PHP: anotace

Anotace poskytují širokou škálu možností od ovlivnění běhu jednotlivých test case, nastavení chování frameworku PHPUnit až po usnadnění práce v něm.

Seriál: Testování a tvorba testovatelného kódu v PHP (13 dílů)

  1. Testování a tvorba testovatelného kódu v PHP 13.8.2012
  2. Testování v PHP: Instalace a základy PHPUnit 27.8.2012
  3. Testování v PHP: asserty a constraints 10.9.2012
  4. Testování v PHP: praktický příklad 1.10.2012
  5. Testování v PHP: anotace 8.10.2012
  6. Testování v PHP: odstiňujeme závislosti 22.10.2012
  7. Testování v PHP: odstiňujeme závislosti II. 5.11.2012
  8. Testování v PHP: testy integrace s databází 19.11.2012
  9. Testování v PHP: testy integrace s databází II. 3.12.2012
  10. Testování v PHP: řízení běhu pomocí parametrů 7.1.2013
  11. Testování v PHP: XML konfigurace PHPUnit 21.1.2013
  12. Testování v PHP: tvorba testovatelného kódu 18.2.2013
  13. Testování v PHP: tvorba testovatelného kódu II. 11.3.2013

Anotace

V dnešním díle seriálu o testování v PHP se opět vrátíme k samotnému testovacímu frameworku PHPUnit a podíváme se na anotace. Otázka první – co jsou to vlastně anotace? Abych nebyl nařčen z opisování originální dokumentace, nepoužiji výraz „metadata“ a uvedu mnohem prostší vysvětlení – anotace nejsou nic jiného než komentáře se speciálním významem. Jsou vždy součástí blokového komentáře, který se nejčastěji označuje jako tzv. „javadoc“ (ano, podobnost s programovacím jazykem Java není náhodná). Poznáte jej tak, že začíná znaky /** a končí */.

Javadoc komentář může obsahovat nejrůznější textová data, která se později využívají k tvorbě automatické dokumentace (popisy, ukázky použití, seznamy a popisy parametrů, návratové hodnoty, …) a kromě toho i zmíněné anotace. Anotace vždy začínají znakem @ a pokud jste někdy nahlíželi do zdrojových kódů např. nějakého frameworku nebo open-source knihovny, určitě jste na řadu anotací už narazili. Jsou jimi třeba: @param pro typ a popis vstupního parametru, @return pro typ nebo popis návratové hodnoty, @see pro odkaz na jinou část kódu a mnohé další. Ve většině případů se anotace používají pouze pro automatické generování dokumentace, případně ještě pro code-completion v editorech.

Kromě dokumentace mohou anotace posloužit i k nejrůznějším konfiguračním účelům, což je i náš dnešní případ.

@author, @group

Tyto dvě anotace mají analogický význam, přesněji @author je pouze aliasem anotace @group. Díky nim můžeme test cases zařazovat do skupin a pomocí parametru –group spouštět jen testovací případy zvolené skupiny. Anotací @group (@author) můžeme mít neomezené množství.

/**
 * @group unit
 * @group projectOne
 * @author pepan
 */
public function testSomething() {}

Spouštění testů zvolených skupin:

phpunit --group unit

phpunit --group projectOne

phpunit --group pepan

@backupGlobals, @backupStaticAttributes

Tyto dvě anotace nejsou totožné jako v předchozím případě, souvisí ale s víceméně stejným nešvarem, proto jsem jejich popis spojil dohromady. Jde o problematiku globálního stavu a závislosti na něm. Pod pojmem „globální stav“ si můžete představit vše, k čemu je možné se dostat z jakéhokoli místa v kódu. Globální proměnné a statické metody či vlastnosti tříd jsou zářnými příklady.

Pozorní čtenáři si jistě vzpomínají, že jsem v prvním díle seriálu zmiňoval pravidla pro dobré testy (F.I.R.S.T.), která mimo jiné říkají, že testy by měly být navzájem nezávislé a v různém pořadí opakovatelné. A to je přesně to, co nám jakákoli závislost na globálním stavu bude úspěšně sabotovat. Jakmile nám testovaný kód „kdesi cosi“ mění pod rukama, tak se můžeme jít s podobnými testy rovnou bodnout, protože nám budou celkem vtipně náhodně selhávat. Takové testy nejsou ani nezávislé ani opakovatelné.

Ale protože ne vždy máme to štěstí stavět kód od základů a často jsme nuceni spravovat různě kvalitní legacy kód, tak nám PHPUnit nabízí dvě anotace, které nás částečně od nebezpečí závislosti na globálním stavu chrání. Anotace @backupGlobals říká, zda se před následující sadou testů nebo před následujícím test case mají zálohovat globální proměnné. Možné hodnoty jsou enabled nebo disabled. Nastavením hodnoty „enabled“ zajistíme, že před spuštěním každého testovacího případu jsou zazálohovány hodnoty globálních proměnných a po jeho ukončení opět vráceny. Po ukončení testovacího případu se tak nedostáváme do nějakým způsobem modifikovaného prostředí.

Obdobnou funkci má anotace @backupStaticAttributes, jen s tím rozdílem, že zálohuje statické atributy tříd. Aby nedošlo v průběhu testu k jejich nechtěné modifikaci. Obě anotace si můžete vyzkoušet na následujícím příkladu, kde v testovaném kódu máme jak závislost na globální proměnné, tak na statickém atributu. Zkuste různě měnit enabled/disabled u obou anotací a sledujte jak budou test cases procházet nebo selhávat.

$GLOBALS['globalValue'] = 10;

class SomeClass
{
    public static $staticProperty = 1;

    public function getStaticValue()
    {
        $oldValue = self::$staticProperty;
        ++self::$staticProperty;

        return $oldValue;
    }

    public function getGlobalValue()
    {
        $oldValue = $GLOBALS['globalValue'];
        ++$GLOBALS['globalValue'];

        return $oldValue;
    }
}
require_once "SomeClass.php";

/**
 * @backupStaticAttributes enabled
 * @backupGlobals enabled
 */
class SomeClassTest extends PHPUnit_Framework_TestCase
{
    public function testGlobalValue()
    {
        $object = new SomeClass();
        $this->assertEquals(10, $object->getGlobalValue());
    }

    public function testGlobalValueSecondTest()
    {
        $object = new SomeClass();
        $this->assertEquals(10, $object->getGlobalValue());
    }

    public function testStaticValue()
    {
        $object = new SomeClass();
        $this->assertEquals(1, $object->getStaticValue());
    }

    public function testStaticValueSecondTest()
    {
        $object = new SomeClass();
        $this->assertEquals(1, $object->getStaticValue());
    }
}

Defaultní chování frameworku PHPUnit ve verzi 3.7.0 je: @backupGlobals enabled, @backupStaticAttributes disabled a je možné toto změnit pomocí direktiv v konfiguračním XML souboru, který si podrobně představíme v některém z dalších dílů. K výše uvedeným anotacím ještě jedno doporučení – stavíte-li kód od základů, nechte obě anotace vypnuté a nedopusťte zanesení jakékoli špíny v podobě závislostí na globálním stavu. Prvními příznaky tohoto neduhu vám budou náhodně padající testy. U legacy kódu je lepší nechat obě anotace zapnuté a vypínat je až po důkladném refaktoringu a pokrytí testy.

Problematice globálního stavu se ještě budeme podrobně věnovat v druhé části seriálu, která bude věnována tvorbě kvalitnějšího, testovatelného, kódu.

@covers

Anotace se používá pro označení metod (příp. tříd), které testovací případ pokrývá. Všechny anotace obsahující „cover“ nebo „coverage“ se používají pro omezování, příp. přesnější generování code-coverage reportu (pokrytí kódu testy). Touto anotací říkáme, že informace (např. „navštívené“ řádky kódu) získané z průběhu následujícího test case chceme použít pro generování code-coverage pouze uvedených metod nebo tříd. Pokud test case pokrývá i jiné metody, pro code-coverage nebudou nabyté informace použity. Anotace může nabývat hodnot:

ClassName::methodName test case pokrývá uvedenou metodu
ClassName test case pokrývá celou uvedenou třídu
ClassName<extended> test case pokrývá celou uvedenou třídu a všechny její předky (třídy, rozhraní)
ClassName::<public> test case pokrývá všechny veřejné (public) metody uvedené třídy
ClassName::<protected> test case pokrývá všechny chráněné (protected) metody uvedené třídy
ClassName::<private> test case pokrývá všechny soukromé (private) metody uvedené třídy
ClassName::<!public> test case pokrývá všechny metody uvedené třídy, kromě veřejných
ClassName::<!protected> test case pokrývá všechny metody uvedené třídy, kromě chráněných
ClassName::<!private> test case pokrývá všechny metody uvedené třídy, kromě soukromých

@coversNothing

Opak předešlé – touto anotací můžeme říci, že následující test case „nepokrývá“ žádnou metodu ani třídu. Nepokrývá samozřejmě není úplně přesný výraz – jde o to, že informace získané na základě takto označeného test case nebudou použity pro generování code-coverage.

@codeCoverageIgnore

Vyřazení celé metody nebo třídy z generování code-coverage. Tato anotace se používá v testovaném kódu, ne v testu!

@codeCoverageIgnoreStart, @codeCoverageIgnoreEnd

Vyřazení bloku kódu z code-coverage. I tyto dvě anotace se používají v testovaném kódu, ne v samotných testech.

@dataProvider

Velice užitečná anotace, která vám ušetří spoustu času! Využijete ji především v případě, kdy potřebujete ověřit nějakou funkčnost sadou různých vstupních hodnot a nechcete dokola psát x podobných assertů nebo celých test cases. Pojďme si anotaci ukázat na příkladu:

class CalcTest extends PHPUnit_Framework_TestCase
{
    /**
     * @dataProvider getTestAddData
     */
    public function testAdd($expectedResult, $x, $y)
    {
        $calc = new Calc();
        $this->assertEquals($expectedResult, $calc->add($x, $y));
    }

    public function getTestAddData()
    {
        return array(
            array(3, 1, 2),
            array(10, 5, 5),
            array(0, 5, -5)
        );
    }
}

Ano, opět naše oblíbená třída Calc! Mimochodem, Britové mají pro podobné situace krásný idiom: Slowly Slowly Catchy Monkey. Ale zpět k tématu – předpokládejme, že chceme otestovat metodu add(), která přijímá dva parametry a vrací jejich součet. Namísto toho abychom psali velké množství asertů na všechny možné případy, použijeme pouze jeden asert a anotaci @dataProvider. Hodnotou anotace je název metody vracející iterovatelnou kolekci polí, jejichž prvky budou dosazeny jako parametry testovací metody.

Testovací metoda bude tedy zavolána tolikrát, kolik polí parametrů vrácená kolekce obsahuje. V našem případě třikrát, poprvé s parametry: 3, 1, 2 atd. Úmyslně se držím pojmu „iterovatelná kolekce“, protože nemusí vždy jít jen o „pole polí“, ale i o instanci třídy implementující rozhraní Iterator. U anotace @dataProvider pozor na jedno omezení – odkazovaná metoda musí být veřejná (public)!

@depends

Anotace @depends je opět příkladem něčeho, co by vaše testy neměly vůbec obsahovat. Její pomocí je možné určitým způsobem řídit pořadí, v jakém jsou jednotlivé test case spouštěny, což stejně jako závislost na globálním stavu porušuje pravidla dobrých testů. Jako v případě @backupGlobals a @backupStaticAttributes, i tato anotace se používá jen jako pomůcka pro „debordelizaci“ legacy kódu.

@expectedException, @expectedExceptionCode, @expectedExceptionMessage

S první anotací ze seznamu jsme se již setkali v minulém díle, kde jsme ji používali k ověření, zda test case vyvolá výjimku zadané třídy. A to je přesně její účel. Pokud v testovací metodě, která je touto anotací označena, nedojde k vyvolání výjimky uvedené třídy, pak je test case označen jako neúspěšný (failed). Druhé dvě anotace (s postfixem Code a Message) mají obdobnou úlohu, pomocí nich je možné ještě více upřesnit jakou výjimku očekáváme a to pomocí jejího kódu nebo chybové zprávy.

Při používání anotace @expectedException je třeba pamatovat na fakt, že veškeré odchytitelné chyby PHP (warning, notice, …) jsou v PHPUnit interně převáděny na výjimky! Důrazně se proto nedoporučuje očekávat výjimku bázové třídy Exception, protože může dojít jak k falešně pozitivním, tak falešně negativním výsledkům. Ukažme si to na příkladu:

class SomeClass
{
    public function throwsInvalidArgumentException()
    {
        throw new InvalidArgumentException();
    }

    public function shouldThrowExceptionButDoesNot()
    {
        // throw new InvalidArgumentException();

        // bad logic error!
        explode("foo");

        return false;
    }
}
class SomeClassTest extends PHPUnit_Framework_TestCase
{
    /**
     * @expectedException InvalidArgumentException
     */
    public function testExceptionIsThrown()
    {
        $s = new SomeClass();
        $s->throwsInvalidArgumentException();
    }

    /**
     * @expectedException Exception
     */
    public function testExceptionIsNotThrownButTestIsOk()
    {
        $s = new SomeClass();
        $s->shouldThrowExceptionButDoesNot();
    }
}

Test první metody throwsInvalidArgumentException je naprosto v pořádku – očekáváme výjimku typu InvalidArgumentException a ta je skutečně vyvolána. Druhý test bude však falešně pozitivní, protože očekáváme výjimku bázové třídy Exception, ale naše metoda shouldThrowExceptionButDoesNot žádnou nevyvolá! Jak je tedy možné, že je test označený za úspěšný?

V metodě shouldThrowExceptionButDoesNot máme logickou chybu (chybějící parametr), která vyvolá Warning a ten je interně převeden na výjimku třídy PHPUnit_Framework_Error_Warning, která je samozřejmě potomkem třídy Exception a je tedy naprosto korektně odchycena naší anotací!

Některé verze PHPUnit < 3.7.0 dokonce zakazují používat v této anotaci bázovou třídu Exception, ale v aktuální verzi bylo toto omezení zrušeno. Pokud si nejste skutečně jisti, co děláte, pak zápis @expectedException Exception raději vůbec nepoužívejte!

@requires

Touto anotací říkáme, že pro korektní běh testu je nutné aby prostředí, ve kterém test spouštíme splňovalo definované podmínky. Těmi můžou být např. verze PHP, verze PHPUnit, existence nějaké core funkce nebo rozšíření. Není-li některé z omezení splněno, pak je test označen jako skipped (viz. druhý díl seriálu). Příklady omezení:

Klíčové slovo Význam Příklady
PHP Minimální verze PHP @requires PHP 5.3.3
PHPUnit Minimální verze PHPUnit @requires PHPUnit 3.6.3
function Existence core funkce nebo metody @requires function imap_open, @requires function ReflectionMethod::setAccessible
extension Přítomnost rozšíření @requires extension mysqli

@test

Pokud z nějakého důvodu nechceme nebo nemůžeme testovací metodu označit prefixem „test“ a tím ji nechat frameworkem automaticky spouštět, pak můžeme využít tuto anotaci, která má stejný efekt.

/**
 * @test
 */
public function runThisTest() {/*...*/}

@testdox

Význam této anotace si ukážeme později, až se budeme zabývat XML konfigurací frameworku a popisem výstupních formátů. Pro tuto chvíli alespoň stručně – díky ní je možné ve formátu Testdox nahradit automaticky vygenerovaný název testovací metody námi definovaným.

/**
  * @testdox Calc can sum
  */
public function testCalcIsAbleToSumTwoNumbers() {/*...*/}

Namísto automaticky vygenerovaného názvu „Calc is able to sum two numbers“ bude ve výstupu uvedeno „Calc can sum“.

@runInSeparateProcess, @runTestsInSeparateProcesses

Jak už název napovídá, tyto anotace slouží ke spouštění jednotlivých test cases nebo celých sad v oddělených procesech. K čemu je toto dobré? V mnoha případech opět k testování nejrůznějšího legacy kódu. Jakkoli se PHPUnit snaží aby všechny testovací případy probíhaly ve vzájemně nezávislém prostředí, ne vždy se mu to úplně daří. Jednou ze situací, se kterou si PHPUnit neumí poradit je vícenásobná deklarace třídy nebo definice konstanty. Řešení je nasnadě – spouštět všechny testy v samostatných procesech, ale to má často za následek zpomalení testů, proto toto chování není defaultní.

@outputBuffering

Anotace @outputBuffering je víceméně obdoba funkce ob_start v PHP a slouží k bufferování výstupu uvnitř test case. Narazíme na ni ještě v některém z příštích dílů, používá se např. k testování HTTP hlaviček.

@ticket

Pomocí této anotace jsme schopni jednak označit, které testy patří ke kterým issue, ale hlavně propojit testy s naším Bug-tracking systémem, kterým může být např. Github. Výborný článek na toto téma před časem napsal Radim Daniel Pánek na webu phpunit.cz, rozhodně stojí za přečtení!

Příště…

To je z dnešního průletu anotacemi PHPUnit vše. Jak jsem slíbil, příště to bude opět více o praktických příkladech a vrhneme se na jednu z nejdůležitějších kapitol – odstiňování závislostí pomocí mockování. Stay tuned!

Josef Zamrzla pracuje jako nezávislý vývojář. Před tím působil coby software development engineer ve společnosti Skype, programátor ve společnosti LMC s.r.o. (provozovatel pracovních portálů www.jobs.cz a www.prace.cz) nebo teamleader ve společnosti Kasa.cz 

Věděli jste, že nám můžete zasílat zprávičky? (Jen pro přihlášené.)

Komentáře: 28

Přehled komentářů

Kokeš Anotace v komentářích?
Enemy unknown Re: Anotace v komentářích?
arron Re: Anotace v komentářích?
Kokeš Re: Anotace v komentářích?
Radek Miček Re: Anotace v komentářích?
Kokeš Re: Anotace v komentářích?
something Re: Anotace v komentářích?
Radek Miček Re: Anotace v komentářích?
Kokeš Re: Anotace v komentářích?
Radek Miček Re: Anotace v komentářích?
Kokeš Re: Anotace v komentářích?
Radek Miček Re: Anotace v komentářích?
Kokeš Re: Anotace v komentářích?
Kokeš Re: Anotace v komentářích?
Radek Miček Re: Anotace v komentářích?
Kokeš Re: Anotace v komentářích?
Radek Miček Re: Anotace v komentářích?
Clary Re: Anotace v komentářích?
ic @assert annotation
puty Anotácia != špeciálny komentár
Michal Re: Anotácia != špeciálny komentár
Kokeš Re: Anotácia != špeciálny komentár
puty Re: Anotácia != špeciálny komentár
Martin Hassman Re: Anotácia != špeciálny komentár
Kokeš Re: Anotácia != špeciálny komentár
RDPanek Anotace @small, @medium, @large
Josef Zamrzla Re: Anotace @small, @medium, @large
Podbi Dobrý článek
Zdroj: https://www.zdrojak.cz/?p=3726