Testování v PHP: praktický příklad

Dnes si na praktickém příkladu ukážeme, jak pokrýt třídu testy a následně se pustíme do refaktoringu podle pravidel test driven development.

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

Na základně vašich reakcí pod posledním dílem seriálu jsem se rozhodl vložit jeden mimořádný díl s praktickou ukázkou přínosu testování kódu. Ukážeme si, jak pokrýt testy jednoduchou třídu, a následně ji budeme refaktorovat podle požadavků pomyslného zadavatele. Refaktoring budeme provádět metodou test driven development. Předlohou budiž známý příklad od autora frameworku PHPUnit, Sebastiana Bergmanna, s názvem BankAccount, který si pro naše potřeby malinko upravíme. Předem bych ještě rád upozornil, že účelem příkladu je seznámit čtenáře s problematikou testování kódu, ne s principy dokonalého objektového návrhu. I proto jsou některé části příkladu značně zjednodušeny!

Zadání

Našim úkolem je napsat třídu pro správu bankovního účtu. Je požadováno rozhraní umožňující:

  • vložení částky na účet
  • výběr částky z účtu
  • zjištění zůstatku
  • získání seznamu pohybů (změn zůstatku) na účtu

Každý účet má jednoho majitele, který je reprezentován jménem a příjmením. Implementace musí také pamatovat na základní chybové stavy:

  • pokus o „vložení“ záporné částky
  • pokus o výběr částky, která přesahuje zůstatek

Na tyto chybové stavy implementace reaguje vyvoláním výjimky třídy InvalidArgumentException. Posledním požadavkem je, aby implementace pamatovala na udržování seznamu změn na účtu, např. pro budoucí sestavení výpisu z účtu. Pro zjednodušení bude účet pracovat pouze se dvěma desetinnými místy a další bez zaokrouhlení ignorovat.

Implementace

Naše prvotní implementace může vypadat zhruba takto:

class BankAccount
{
    protected $name;
    protected $surname;

    protected $balance = 0.0;
    protected $changes = array();

    /**
     * @param string $name Owner's name
     * @param string $surname Owner's surname
     */
    public function __construct($name, $surname)
    {
        $this->name = $name;
        $this->surname = $surname;
    }

    /**
     * @return string
     */
    public function getOwnerName()
    {
        return trim($this->name . " " . $this->surname);
    }

    /**
     * @param float $amount
     * @return float
     * @throws InvalidArgumentException
     */
    public function depositMoney($amount)
    {
        $amount = (double)sprintf("%.02f", (double)$amount);
        if ($amount <= 0) {
            throw new InvalidArgumentException(
                "Cannot deposit negative amount of money");
        }

        return $this->addAmount($amount,
            "deposit: " . sprintf("%.02f", $amount));
    }

    /**
     * @param float $amount
     * @return float
     * @throws InvalidArgumentException
     */
    public function withdrawMoney($amount)
    {
        $amount = (double)sprintf("%.02f", (double)$amount);
        if ($this->getBalance() < $amount) {
            throw new InvalidArgumentException("Insufficient balance");
        }

        return $this->subAmount($amount,
            "withdraw: " . sprintf("%.02f", $amount));
    }

    /**
     * @return float
     */
    public function getBalance()
    {
        return (double)sprintf("%.02f", $this->balance);
    }

    /**
     * @return array
     */
    public function getChanges()
    {
        return $this->changes;
    }

    /**
     * @param float $amount
     * @param string $reason
     * @return float
     */
    protected function addAmount($amount, $reason)
    {
        $this->balance += $amount;
        $this->changes[] = $reason;

        return $this->balance;
    }

    /**
     * @param float $amount
     * @param string $reason
     * @return float
     */
    protected function subAmount($amount, $reason)
    {
        $this->balance -= $amount;
        $this->changes[] = $reason;

        return $this->balance;
    }
}

Testování

Nyní přistoupíme k otestování. Prvotní implementaci pokryjeme sadou testů, která bude ověřovat základní funkčnost jednotlivých metod a bude také pamatovat na některé nestandardní vstupy, které popisuje zadání. Připravíme si testovací sadu, kterou pojmenujeme BankAccountTest a pomocí metody setUp si nastavíme fixture, což v našem případě bude pouze instance testované třídy: BankAccount

class BankAccountTest extends PHPUnit_Framework_TestCase
{
    /**
     * @var BankAccount
     */
    protected $account;

    protected function setUp()
    {
        $this->account = new BankAccount("John", "Doe");
    }
}

Nezapomeňme na include souboru s testovanou třídou! Prvním test case bude ověření, zda se správně skládá jméno a příjmení majitele účtu. Jakkoli se může tento test case zdát triviální až zbytečný, může nám v budoucnu signalizovat problém, pokud se například rozhodneme pracovat i s prostředními jmény majitele, jeho akademickými tituly apod.

public function testGetOwnerName()
{
    $this->assertSame("John Doe", $this->account->getOwnerName());
}

Dále otestujeme korektní chování metody depositMoney pro vklad částky na účet, prozatím se standardními vstupy.

public function testDepositMoney()
{
    // check initial balance
    $this->assertSame(0.0, $this->account->getBalance());

    // deposit some money
    $this->account->depositMoney(100);
    $this->assertEquals(100, $this->account->getBalance());

    // deposit again
    $this->account->depositMoney(12.99);
    $this->assertSame(112.99, $this->account->getBalance());
}

Protože metoda depositMoney nesmí přijmout nulový nebo záporný vklad, otestujeme i tuto možnost. Pro tento test case použijeme anotaci @expectedException, o které bude řeč v příštím díle seriálu. Anotace vyžaduje aby v následujícím test case byla vyvolána výjimka uvedené třídy. Pokud k jejímu vyvolání nedojde, test case selže.

/**
 * @expectedException InvalidArgumentException
 */
public function testDepositeNegativeAmountOfMoney()
{
    $this->account->depositMoney(-1);
}

Stejný postup použijeme i v případě metody withdrawMoney, tedy výběr částky z účtu. Nejprve testujeme chování s korektními vstupy, potom nestandardní.

public function testWithdrawMoney()
{
    // deposit some money first
    $this->account->depositMoney(1000);

    $this->account->withdrawMoney(899.99);
    $this->assertSame(100.01, $this->account->getBalance());

    $this->account->withdrawMoney(10.01);
    $this->assertSame(90.0, $this->account->getBalance());

    // withdraw rest of money
    $this->account->withdrawMoney(90.0);
    $this->assertSame(0.0, $this->account->getBalance());
}

/**
 * @expectedException InvalidArgumentException
 */
public function testWithdrawMoneyWithoutSufficientBalance()
{
    // make sure that initial balance is zero
    $this->assertSame(0.0, $this->account->getBalance());

    // try to withdraw 100
    $this->account->withdrawMoney(100);
}

Tím bychom měli otestovány metody pro vložení i výběr částky z účtu. Zbývá nám testem ověřit poslední část zadání – je-li správně udržován seznam změn na účtu. Seznam změn je implementován pomocí pole – po každém korektním výběru je do seznamu přidán text: „withdraw: [amount]“ a po každém korektním vkladu: „deposit: [amount]“. Zkusme tedy provést několik operací a ověřit, zda seznam změn odpovídá našemu předpokladu.

public function testGetChanges()
{
    // check initial state
    $this->assertSame(array(), $this->account->getChanges());

    $this->account->depositMoney(100);
    $this->assertSame(1, count($this->account->getChanges()));
    $this->assertContains(
        "deposit: 100.00",
        $this->account->getChanges());

    $this->account->withdrawMoney(99.99);
    $this->assertSame(2, count($this->account->getChanges()));
    $this->assertContains(
        "withdraw: 99.99",
        $this->account->getChanges());

    $this->assertSame(
        array("deposit: 100.00", "withdraw: 99.99"),
        $this->account->getChanges());
}

Ani zde nesmíme zapomenout na možnosti nekorektních vstupů. Tentokrát budeme případné výjimky ignorovat, zajímá nás pouze obsah seznamu (pole).

public function testGetChangesAfterBadDeposit()
{
    $this->assertSame(array(), $this->account->getChanges());
    try {
        $this->account->depositMoney(-1);
    } catch(InvalidArgumentException $e) {/* ignore exception */}

    $this->assertSame(array(), $this->account->getChanges());
}

public function testGetChangesAfterBadWithdraw()
{
    $this->assertSame(array(), $this->account->getChanges());
    try {
        $this->account->withdrawMoney(100);
    } catch(InvalidArgumentException $e) {/* ignore exception */}

    $this->assertSame(array(), $this->account->getChanges());
}

Máme hotovo, spustíme PHPUnit.

$ phpunit .
PHPUnit 3.6.11 by Sebastian Bergmann.

........

Time: 0 seconds, Memory: 3.25Mb

OK (8 tests, 20 assertions)

Změny, změny a další změny

Už už se chystáme odevzdat svou práci, když zjistíme, že management banky přišel s novým požadavkem: jeden účet musí být schopen převést částku na jiný účet. Aby toho nebylo málo, architekt systému požaduje, aby toto bylo implementováno metodou, jejímž prvním parametrem bude instance účtu příjemce a druhým parametrem bude převáděná částka. Do seznamu změn na dotčených účtech mají přibýt texty: „sent [amount]“ u účtu odesílatele a „received [amount]“ u účtu příjemce. Nikdo bohužel nezaručí, že nemůže dojít k případu, kdy je příjemcem částky jiná instance účtu stejného majitele a jeden majitel by tedy převáděl částku sám sobě. Což je samozřejmě nesmysl a musíme na to pamatovat. Zadání vyžaduje, abychom před převodem ověřili, zda celé jméno příjemce není stejné jako celé jméno odesílatele. Pokud se jména rovnají, pak je vyžadována výjimka InvalidArgumentException. Stejně jako ve všech předchozích případech platí ochrany proti nekorektním vstupům.

Ač se na první pohled jedná o složité zadání, implementovat tento požadavek nebude těžké. Pojďme si ukázat, jak postupovat podle metodiky Test driven development. Nejprve si na nově požadovanou funčnost napišme testy a postupujme podle známých faktů. Na účet odesílatele vložíme prvotní vklad 200 a odešleme částku 99.99 na účet příjemce, jehož počáteční zůstatek je nula a nemá žádné záznamy o změnách. Zůstatky obou účtů po převodu by měly být: 100.01 (odesílatel) a 99.99 (příjemce). Oba účty by také měly mít záznamy o provedené transakci.

public function testTransfer()
{
    $recipient = new BankAccount("Jane", "Dawn");

    $this->account->depositMoney(200);
    $this->account->transfer($recipient, 99.99);

    $this->assertSame(100.01, $this->account->getBalance());
    $this->assertSame(
        array("deposit: 200.00", "sent: 99.99"),
        $this->account->getChanges());

    $this->assertSame(99.99, $recipient->getBalance());
    $this->assertSame(
        array("received: 99.99"),
        $recipient->getChanges());
}

Tím máme připravený test pro případ korektního převodu. Přistoupíme k testu pro případ převodu na účet stejného majitele:

public function testTransferToSameAccount()
{
    $recipient = new BankAccount("John", "Doe");

    $this->account->depositMoney(200);

    try {
        $this->account->transfer($recipient, 99.99);
        $this->fail("Cannot transfer money to the same account");
    } catch(InvalidArgumentException $e) {/* ignore exception */}

    $this->assertSame(200.00, $this->account->getBalance());
    $this->assertSame(
        array("deposit: 200.00"),
        $this->account->getChanges());

    $this->assertSame(0.0, $recipient->getBalance());
    $this->assertSame(array(), $recipient->getChanges());
}

Opět vložíme nějakou počáteční částku a pokusíme se něco převést na účet stejného majitele. Podle zadání by mělo v takovém případě dojít k vyvolání výjimky. My ale potřebujeme kromě tohoto faktu ještě otestovat, zda odpovídají zůstatky a seznamy změn. Tedy zda nedošlo před vyvoláním výjimky k nějakým změnám na dotčených účtech. Použijeme jednoduchý trik: volání metody transfer umístíme do bloku try/catch a ihned za ní zavoláme metodu frameworku: fail, která způsobí selhání testu. Pokud je v metodě transfer vyvolána víjimka, pak je volání fail přeskočeno do bloku catch. V opačném případě test správně selže. Stejně tak, je-li výjimka jiné třídy než InvalidArgumentException. Nakonec ověříme, zda zůstatky a seznamy změn odpovídají předpokladu.

Nyní už zbývá jen napsat testy pro případy nekorektních převodů – převod záporné nebo nulové částky, převod částky vyšší než zůstatek. Pro odchycení a test výjimky použijeme stejný trik jako v předchozím případě.

public function testTransferBadAmount()
{
    $recipient = new BankAccount("Jane", "Dawn");

    try {
        $this->account->transfer($recipient, -100);
    } catch(InvalidArgumentException $e) {/* ignore exception */}

    $this->assertSame(0.0, $this->account->getBalance());
    $this->assertSame(array(), $this->account->getChanges());
    $this->assertSame(0.0, $recipient->getBalance());
    $this->assertSame(array(), $recipient->getChanges());
}

public function testTransferWithInsufficientBalance()
{
    $this->account->depositMoney(100);
    $recipient = new BankAccount("Jane", "Dawn");

    try {
        $this->account->transfer($recipient, 100.01);
    } catch(InvalidArgumentException $e) {/* ignore exception */}

    $this->assertSame(100.00, $this->account->getBalance());
    $this->assertSame(
        array("deposit: 100.00"),
        $this->account->getChanges());

    $this->assertSame(0.0, $recipient->getBalance());
    $this->assertSame(array(), $recipient->getChanges());
}

Tím jsme připraveni na vlastní implementaci požadované změny. Pokud nyní pustíme testy, pak nás asi moc nepřekvapí, že dojde k chybě:

$ phpunit .
PHPUnit 3.6.11 by Sebastian Bergmann.

........PHP Fatal error:  Call to undefined method BankAccount::transfer()

Metoda transfer samořejmě ještě neexistuje a díky tomu není možné testy spustit. Implementujeme tedy minimální možný kód, abychom testy zprovoznili. Zbytek zatím neřešíme, jde nám pouze o zprovoznění testů.

/**
 * @param BankAccount $recipient
 * @param float $amount
 * @return float
 * @throws InvalidArgumentException
 */
public function transfer(BankAccount $recipient, $amount) {}

Nyní už testy spustit jdou, ale v kódu třídy BankAccount máme chyby. Což je logické, metoda není implementována.

$ phpunit .
PHPUnit 3.6.11 by Sebastian Bergmann.

........FF..

Time: 0 seconds, Memory: 3.50Mb

There were 2 failures:

1) BankAccountTest::testTransfer
Failed asserting that 200.0 is identical to 100.01.

~/test/BankAccountTest.php:118

2) BankAccountTest::testTransferToSameAccount
Cannot transfer money to the same account

~/test/BankAccountTest.php:135

FAILURES!
Tests: 12, Assertions: 29, Failures: 2.

V prvním případě nesouhlasí zůstatky, to budeme řešit jako první. Opět napíšeme minimální kód ke splnění testu – z účtu odesílatele odečteme částku a příjemci ji připočítáme.

public function transfer(BankAccount $recipient, $amount)
{
    $amount = (double)sprintf("%.02f", (double)$amount);
    $this->subAmount($amount, "sent: " . sprintf("%.02f", $amount));

    $recipient->depositMoney($amount);
}

Chybu jsme opravili, ale stále to není vše. Jako v předchozím případě – vezmeme první z nich:

$ phpunit .
PHPUnit 3.6.11 by Sebastian Bergmann.

........FFFF

Time: 0 seconds, Memory: 3.50Mb

There were 4 failures:

1) BankAccountTest::testTransfer
Failed asserting that Array (
    0 => 'deposit: 99.99'
) is identical to Array (
    0 => 'received: 99.99'
).

Tím, že jsme pro připsání částky na účet příjemce použili metodu depositMoney, jsme způsobili, že se sice částka přičetla, ale zpráva v seznamu změn neodpovídá požadavku. Ke splnění tohoto testu máme minimálně dvě možnosti: buď přidáme metodu pro příjem částek „jinak než vkladem“, a nebo prostě přidáme metodě depositMoney druhý parametr, nesoucí řetězec s „důvodem“ příjmu částky. Využijeme druhou možnost. Splníme tím požadavek na minimální kód, který projde testy a pokud přijde v budoucnu další podobný požadavek, kód refaktorujeme. Tato změna způsobí selhání dřívějších testů, proto připojíme jeho defaultní hodnotu: „deposit“:

public function depositMoney($amount, $reason = "deposit")
{
    $amount = (double)sprintf("%.02f", (double)$amount);
    if ($amount <= 0) {
        throw new InvalidArgumentException(
            "Cannot deposit negative amount of money");
    }

    return $this->addAmount($amount,
        $reason . ": " . sprintf("%.02f", $amount));
}

public function transfer(BankAccount $recipient, $amount)
{
    $amount = (double)sprintf("%.02f", (double)$amount);
    $this->subAmount($amount, "sent: " . sprintf("%.02f", $amount));

    $recipient->depositMoney($amount, "received");
}

Spustíme testy, seznam změn je ok, další chybou je:

1) BankAccountTest::testTransferToSameAccount
Cannot transfer money to the same account

Napíšeme minimální kód pro splnění testu – ověření, zda nepřevádíme částku na ten samý účet.

public function transfer(BankAccount $recipient, $amount)
{
    if ($this->getOwnerName() === $recipient->getOwnerName()) {
        throw new InvalidArgumentException(
            "Cannot transfer money to the same account");
    }

    $amount = (double)sprintf("%.02f", (double)$amount);
    $this->subAmount($amount, "sent: " . sprintf("%.02f", $amount));

    $recipient->depositMoney($amount, "received");
}

Po spuštění testů zbývají dvě chyby. Jedna se týká neplatného vstupu a druhá nedostatečného zůstatku. Vezmeme obě najednou a napíšeme minimum pro splnění – ověření vstupu, zda je kladný a ověření, zda převáděná částka není větší než zůstatek na účtu.

public function transfer(BankAccount $recipient, $amount)
{
    if ($this->getOwnerName() === $recipient->getOwnerName()) {
        throw new InvalidArgumentException(
            "Cannot transfer money to the same account");
    }

    if ($amount <= 0) {
        throw new InvalidArgumentException(
            "Cannot deposit negative amount of money");
    }

    if ($this->getBalance() < $amount) {
        throw new InvalidArgumentException("Insufficient balance");
    }

    $amount = (double)sprintf("%.02f", (double)$amount);
    $this->subAmount($amount, "sent: " . sprintf("%.02f", $amount));

    $recipient->depositMoney($amount, "received");
}

Že by nyní?

$ phpunit .
PHPUnit 3.6.11 by Sebastian Bergmann.

............

Time: 0 seconds, Memory: 3.50Mb

OK (12 tests, 36 assertions)

Ano! Všechny naše předpoklady byly splněny. Pokud jsme do testů zanesli všechny kritické body zadání, můžeme refactoring prohlásit za hotový. V opačném případě pokračujeme stejným způsobem – napíšeme testy, přidáme nejkratší možný kód ke splnění testů, a je-li třeba, refaktorujeme.

Jestli vás tento dnešní příklad zaujal, tak si můžete zkusit pokračování – management banky se rozhodl otevřít pobočku v České republice a protože zjistil, že na tamnějším trhu velice dobře fungují poplatky, tak zadal změnový požadavek: každá transakce bude zpoplatněna. Poplatky musí být zaneseny do seznamu pohybů na účtu a ke stržení poplatku může dojít i v případě, že klient nemá dostatačný zůstatek.

To je pro dnešek vše, v dalším díle se pustíme do těch, už dvakrát odložených, anotací. Příklady budou spíše „inline“, na další větší praktický příklad se budete moci těšit přes-příště. Všechny uvedené zdrojové kódy jsou dostupné na mém Githubu:

https://github.com/josefzamrzla/serial-testovani-v-php

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: 48

Přehled komentářů

michal Testování
arron Re: Testování
Martin Hassman Re: Testování
michal Re: Testování
Aleš Roubíček Re: Testování
Josef Zamrzla Re: Testování
michal Re: Testování
Josef Zamrzla Re: Testování
Martin Hassman Re: Testování
Diskobolos Re: Testování
lenochware Re: Testování v PHP: praktický příklad
Čelo Re: Testování v PHP: praktický příklad
arron Re: Testování v PHP: praktický příklad
Budela Re: Testování v PHP: praktický příklad
Josef Zamrzla Re: Testování v PHP: praktický příklad
arron Re: Testování v PHP: praktický příklad
Budela Re: Testování v PHP: praktický příklad
arron Re: Testování v PHP: praktický příklad
anonym Re: Testování v PHP: praktický příklad
Clary Re: Testování v PHP: praktický příklad
j Re: Testování v PHP: praktický příklad
Michal Re: Testování v PHP: praktický příklad
RDPanek Testování znamená zajišťování určité míry kvality
none_ Re: Testování znamená zajišťování určité míry kvality
arron Re: Testování znamená zajišťování určité míry kvality
RDPanek Re: Testování znamená zajišťování určité míry kvality
Honza Testování odhaluje chyby
arron Re: Testování odhaluje chyby
Honza Re: Testování odhaluje chyby
j Re: Testování odhaluje chyby
Honza Re: Testování odhaluje chyby
Jiří Knesl Re: Testování odhaluje chyby
Honza Re: Testování odhaluje chyby
Jiří Knesl Re: Testování odhaluje chyby
Honza Re: Testování odhaluje chyby
Josef Zamrzla Re: Testování odhaluje chyby
Honza Re: Testování odhaluje chyby
Josef Zamrzla Re: Testování odhaluje chyby
Honza Re: Testování odhaluje chyby
mino Re: Testování odhaluje chyby
Jiří Knesl Re: Testování odhaluje chyby
Honza Re: Testování odhaluje chyby
https://vladimirpilny.mojeid.cz/#2oFvdzWbch Líbí se mi TDD, ale připadá mi to jen jako teorie
jos Re: Líbí se mi TDD, ale připadá mi to jen jako teorie
Michal Re: Líbí se mi TDD, ale připadá mi to jen jako teorie
https://vladimirpilny.mojeid.cz/#2oFvdzWbch Re: Líbí se mi TDD, ale připadá mi to jen jako teorie
jos Re: Líbí se mi TDD, ale připadá mi to jen jako teorie
https://vladimirpilny.mojeid.cz/#2oFvdzWbch Re: Líbí se mi TDD, ale připadá mi to jen jako teorie
Zdroj: https://www.zdrojak.cz/?p=3718