Knihovna TestIt pro PHPUnit

Píšete jednotkové testy v PHPUnitu? A také vás obtěžuje zdlouhavá a upovídaná konstrukce $this->getMock()—>expects()->method()—>with()->will()? Právě pro vás je tu nadstavbová knihovna TestIt, která tuto konstrukci obchází a zároveň rozšiřuje možnosti mockování závislostí v PHPUnitu.

Přiznám se, že jsem nikdy na psaní testů nepoužil jinou knihovnu než právě PHPUnit. Proč jsem nezkusil například Nette Test (ačkoliv jsem zavilý Nettista)? Jednoduše jsem neměl potřebu a PHPUnit vyhovoval všem mým požadavkům. Tedy skoro všem.

Ačkoliv má PHPUnit skvělé nástroje pro vytváření mocků, definice očekávaných volání (tzn. expectů; omlouvám se všem vlastencům, ale v dalším textu budu počešťovat anglický termín expect) je krajně nepřehledná, zdlouhavá a nudná. Nejdříve jsem chtěl použít nějaký jiný nástroj (třeba Mockista), syntaxe je ale u všech těchto nástrojů podobná.

Co se má v testu stát, když nenadefinuji žádný expect? Osobně mám za to, že když se stane něco, co neočekávám (čili jsem nenadefinoval žádné očekávání), tak test musí nutně spadnout. V případě PHPUnitu tomu ale tak není a v případě, že se zavolá v mocku neočekávaně nějaká metoda, se kterou jsme nepočítali (takže jsme nenadefinovali, že neočekáváme její volání), celý test v klidu projde a programátor se nic nedozví. Tento problém mi při psaní testů přišel natolik zásadní, že jsem ho prostě musel obejít.

TestIt

Postupně tak vzniklo několik málo tříd, které jsem se nakonec rozhodl vyrefaktorovat ze své testovací knihovny do samostatného projektu TestIt, který bych vám dnes rád představil.

TestIt je jednoduchá knihovna, která definuje vlastní TestCase třídu. Ta rozšiřuje možnosti PHPUnitu, ale přitom programátorovi nic nenutí, stále je možné používat všechny běžné konstrukce. TestIt ale používá rozdílnou filozofii pří práci s mocky. Běžně se s mocky pracuje tak, že se vytvoří mock a následně se definuje, jak se tento mock má chovat:

//příklad je převzatý z dokumentace PHPUnitu
$observer = $this->getMock('Observer', array('update'));
$observer
    ->expects($this->once())
    ->method('update')
    ->with($this->equalTo('something'));

To je ale nepraktické, protože v každém testu je potřeba mock definovat, získat jeho instanci a pomocí poměrně zdlouhavého zápisu upravit jeho chování. Při použítí knihovny TestIt stačí definovat všechny mocky na jednom místě (při vytváření testovaného objektu) a v jednotlivých testech již pouze definovat očekávané chování testovaného objektu. O mocky už se dále v testech nemusíme starat, jen definujeme jaká volání závislostí očekáváme, s jakými parametry a co se nám hodí, aby nám daná závislost v daném testu vrátila.

Instalace

TestIt nainstalujte jednoduše přes Composer. Do svých závislostí přidejte arron/TestIt.

{
    "require": {
        "arron/testit": "1.3.1",
    },
}

Popřípadě si můžete stáhnout zdrojový kód na GitHubu.

První test

Knihovnu použijeme velmi jednoduše. Stačí náš test podědit od třídy \Arron\TestIt\TestCase a implementovat jedinou abstraktní metodu createTestObject(). Úkolem této metody je vytvoření a vrácení nové hotové instance objektu, který se chystáme testovat. O zbytek (uložení této instance a podobně) se již nemusíme starat. Pro každý test bude vytvořena nová instance.

Pojmenované závislosti

Každá závislost musí být v testu pojmenovaná (mít alias, chcete-li). Má to dvě zásadní výhody. První, můžeme se na danou závislost jednoduše odkázat. Druhá, můžeme definovat dvě závislosti stejného typu, ale s jiným jménem (například dvě připojení do databáze).

Testujeme

Pojďme si ukázat příklad. Mějme jednoduchou třídu, která slouží jako model pro načítání dat (v tomto případě jednoduché načítání obsahu z nějakého úložiště):

class ContentModel
{
    /** @var IContentStorage */
    protected $storage;

    public function __construct(IContentStorage $contentStorage)
    {
        $this->storage = $contentStorage;
    }

    public function save($id, $content)
    {
        return $this->storage->save($id, $content);
    }

    public function load($id)
    {
        try {
            $content = $this->storage->load($id);
        } catch (ContentNotFoundException $e) {
            $content = 'There is no content yet.';
        }
        return $content;
    }

    public function delete($id)
    {
        $this->storage->delete($id);
    }
}

interface IContentStorage
{
    public function save($id, $content);

    public function load($id);

    public function delete($id);
}

Pokud budeme chtít napsat test pro tuto třídu, nejdříve si vytvoříme jednoduchou kostru (já osobně ji mám nadefinovanou jako šablonu ve svém IDE, testy se pak vytváří opravdu rychle):

class ContentModelTest extends \Arron\TestIt\TestCase
{
    /**
     * @return object
     */
    protected function createTestObject()
    {
        return new ContentModel($this->getMockedClass('\Arron\Examples\IContentStorage', 'storage'));
    }
}

Metoda getMockedClass() vytvoří a vrátí mock daného typu s definovaným jménem. Používá k tomu běžné prostředky PHPUnitu, takže je možné vytvářet mocky z rozhraní, abstraktních tříd a podobně.

Nyní můžeme napsat jednoduchý test například pro metodu save():

public function testSave()
{
    $id = 'idForTest';
    $content = 'content for testing';

    $this->expectDependencyCall('storage', 'save', array($id, $content));

    $this->getTestObject()->save($id, $content);
}

Jak vidíte, test je velmi jednoduchý a přímočarý. Klíčovou metodou je zde expectDependencyCall(). Na rozdíl od běžné PHPUnit konstrukce pro psaní expectů shrnuje vše potřebné do jednoho volání. Prvním parametrem je název závislosti, druhým parametrem je název metody, kterou očekáváme, že se na dané závislosti zavolá. Následuje výčet očekávaných parametrů (v poli) a návratová hodnota, kterou chceme, aby závislost vrátila (v příkladu vynechána, protože žádnou neočekáváme). Dále voláme testovanou metodu. Při tom využíváme metodu getTestObject(), která vrátí instanci testovaného objektu. O nic dalšího se nemusíme starat, TestIt provede veškeré kontroly za nás a v případě, že některé předpoklady nevyhovují, test selže.

Pořadí volání expectDependencyCall() určuje pořadí, ve kterém očekáváme, že budou závislosti volány. TestIt toto hlídá a pokud se zavolá jakákoliv závislost mimo očekávané pořadí, test okamžitě selže. Toto chování je velmi důležité, protože pořádí volání závislostí může být kritické. Například pokud bychom někam odesílali šifrovaná data, tak je velký rozdíl, pokud data nejdříve zašifrujeme a pak odešleme, a nebo nejdříve odešleme a pak teprve zašifrujeme. Unit test by v každém případě měl být schopný tyto dva případy rozlišit a případně spadnout.

Pokud bychom nechtěli kontrolovat předávané parametry (ať už z jakéhokoliv důvodu), stačí metodě expectDependencyCall() místo seznamu očekávaných parametrů předat hodnotu null. Test pak projde při zavolání závislosti s jakýmikoliv parametry.

Testování výjimek

Často se stane, že potřebujeme otestovat, co se stane, když nějaké volání závislosti vyhodí výjimku. Například v metodě load v předchozím příkladu modelu. TestIt si i s tímto případem dokáže jednoduše poradit. Pokud metodě expectDependencyCall() předáme jako návratovou hodnotu instanci třídy \Exception (či jakéhokoliv jejího potomka), potom zavolání příslušné závislosti danou instanci nevrátí, ale tuto instanci vyhodí jako výjimku. V testu to vypadá takto:

public function testLoadNotFound()
{
    $id = 'idForTest';
    $expectedResult = 'There is no content yet.';

    $this->expectDependencyCall('storage', 'load', array($id), new ContentNotFoundException()); //volání $this->storage->load($id) v metodě load() vyhodí výjimku

    $returnedResult = $this->getTestObject()->load($id);

    $this->assertEquals($expectedResult, $returnedResult);
}

Mockování globálních funkcí

Nejenom v převzatém kódu, ale i v kódu, který si sami napíšeme, často používáme nativní PHP funkce. U některých není jejich volání v testu problém, ale například funkce pro práci se systémem souborů nebo funkce pro připojování ke vzdáleným prostředkům bychom v unit testech nejraději nevolali. Ale i části aplikace, které s těmito funkcemi přímo pracují (čili nejnižší vrstva aplikace, pokud máme tyto části správně zapouzdřené) by měly mít své unit testy.

TestIt nabízí poměrně jednoduchý systém, jak i tyto funkce mockovat. Využívá přitom „fintu“ se jmennými prostory. Pokud jmenné prostory používáme a zavoláme v některém nedefinovanou funkci, tak ta nakonec „probublá“ až do globálního prostoru a zavolá se globální PHP funkce. Pokud ale v daném jmenném prostoru funkci nadefinujeme, tak se zavolá ona nadefinovaná (nikoliv globální PHP funkce). Vyplývá z toho jedno zásadní omezení tohoto řešení. Funkce volané s plným názvem jmenného prostoru nepůjdou namockovat:

namespace SomeNamespace;
    $timestamp = time();//funkce time může být namockována

    $timestamp = \time();//funkce time NEmůže být namockována

V praxi to funguje takto. Mějme třídu, která je fyzickým úložištěm pro model z předchozího příkladu, a která ukládá data do souborů:

class ContentFileStorage implements IContentStorage
{
    //třída je zkrácena!

    protected $directory;

    public function save($id, $content)
    {
        $file = $this->getFilePath($id);
        $success = file_put_contents('safe://' . $file, $content);
        if ($success === FALSE) {
            throw new ContentIOException('Can not write content into file ' . $file);
        }
    }

    public function load($id)
    {
        $file = $this->getFilePath($id);
        if (file_exists($file)) {
            $content = file_get_contents('safe://' . $file);
            if ($content === FALSE) {
                throw new ContentIOException('Can not read content from file ' . $file);
            }
            return $content;
        }
        throw new ContentNotFoundException('There is no such content as ' . $file);
    }

    public function delete($id)
    {
        $fileName = $this->getFilePath($id);
        if (file_exists($fileName)) {
            if (!unlink($fileName)) {
                throw new ContentIOException('Can not delete file ' . $fileName);
            }
        }
    }
}

Unit test metody load by pak vypadal takto:

class ContentFileStorageTest extends \Arron\TestIt\TestCase
{
    protected function setUp()
    {
        $this->mockGlobalFunction('file_exists', 'SomeNamespace');
        $this->mockGlobalFunction('file_get_contents', 'SomeNamespace');
        parent::setUp();
    }

    /**
     * @return object
     */
    protected function createTestObject()
    {
        return new ContentFileStorage('testDir');
    }

    public function testLoad()
    {
        $id = 'testId';
        $expectedContent = 'some test content';

        $this->expectDependencyCall('global', 'file_exists', array('testDir/content/testId.content'), TRUE);
        $this->expectDependencyCall('global', 'file_get_contents', array('safe://testDir/content/testId.content'), $expectedContent);

        $returnedResult = $this->getTestObject()->load($id);

        $this->assertEquals($expectedContent, $returnedResult);
    }

    public function testLoadFileNotFound()
    {
        $id = 'testId';

        $this->expectDependencyCall('global', 'file_exists', array('testDir/content/testId.content'), FALSE);

        $this->setExpectedException('\Arron\Examples\ContentNotFoundException');

        $this->getTestObject()->load($id);
    }

    public function testLoadFileReadError()
    {
        $id = 'testId';

        $this->expectDependencyCall('global', 'file_exists', array('testDir/content/testId.content'), TRUE);
        $this->expectDependencyCall('global', 'file_get_contents', array('safe://testDir/content/testId.content'), FALSE);

        $this->setExpectedException('\Arron\Examples\ContentIOException');

        $this->getTestObject()->load($id);
    }
}

Volání metody mockGlobalFunction() vytvoří mock předané funkce v daném jmenném prostoru. Metodu je potřeba zavolat kdykoliv před prvním voláním mockované funkce. Metoda setUp je tím nejlepším místem, kde mít tyto mocky všechny pohromadě pro daný test case. Metodu mockGlobalFunction() můžete volat i vícekrát se stejným názvem funkce, TestIt si ohlídá, aby nedefinoval jednu funkci vícekrát ve stejném jmenném prostoru.

Je potřeba si uvědomit, že jakmile je nějaká funkce definovaná v určitém jmenném prostoru, tak zůstane definovaná po celou dobu běhu testů. Proto je potřeba do testů psát expecty na všechny její volání. To je cena za možnost testovat i globální PHP funkce.

Problém nastává u funkcí, které používají výstupní parametr (například funkce preg_*). Nepodařilo se mi přijít na způsob, kterým by se dalo v testu jednoduše definovat, co má funkce vrátit jako svoji návratovou hodnotu a co má vrátit v kterém výstupním parametru. Uvítám jakékoliv nápady na řešení.

Přístup k chráněným atributům a metodám testované třídy

Někteří to budou považovat za „prasárnu“, ale při psaní unit testů se často hodí mít přístup k chráněným atributům a metodám testovaného objektu. Nechám na každém, jestli v testech někdy takové věci využívá nebo ne, v každém případě TestIt nabízí pro tyto případy několik pomocných metod:

protected function setPropertyInTestSubject($name, $value)
protected function getPropertyFromTestSubject($name) //dobře se používá na asserty abychom zjistili, zda se objekt po volání testované metody nachází v očekávaném stavu
protected function callTestSubjectMethod($name, array $arguments = array())

Shrnutí

Knihovna TestIt je nadstavbou pro PHPUnit a umožňuje jednoduše mockovat třídy, globální PHP funkce a zkracuje zápis expectů. Umožňuje mockovat stejné střídy pod jinými názvy závislostí. Je restriktivní, co se neočekávaných volání závislostí týká, a ulehčuje testování selhání volání závislostí.

Kompletní příklady si můžete prohlédnout na GitHubu.

Knihovna TestIt je šířena pod licencí MIT.

Po zkušenostech s programováním v PHP a jako vedoucí vývojového oddělení ve firmě Actum jsem na chvíli zakotvil ve Skype, kde jsem psal unit testy na služby, které jsou napsané v PHP. Snažím se pracovat maximálně efektivně, psát čistý kód a k témuž směřovat i programátory, které vedu. Ve volném čase rád sportuji a fotím.

Komentáře: 12

Přehled komentářů

vladimir.polak85
pepca Moooc složitý
Petr Testování?
Jenda Re: Testování?
Tomáš Lembacher Re: Testování?
Podbor Pěkný nápad
zdenekmachek Mockery
Tomáš Lembacher Re: Mockery
zdenekmachek Re: Mockery
Tomáš Lembacher Re: Mockery
Marek Gach Phake je elegantnější
arron Re: Phake je elegantnější
Zdroj: https://www.zdrojak.cz/?p=10135