PHPUnit - jednotkové testování v PHP
Jednotkové testování
Motivace
Problém: změnou kódu programové jednotky mohou vzniknout chyby
Řešení: jednotkové (unit) testy
- verifikace
- automatické (programové) testování jednotek
- regresní testování
- nejblíže vývojáři
Jednotka
Jednotka je nejmenší testovatelná část software (procedura, objekt …)
Terminologie: testy
Unit testy se neliší mezi různými technologiemi a jazyky
- JUnit, PHPUnit, SimpleTest …
- Test fixtures (kontext)
- sada předpokladů nebo stavy, které jsou potřeba, aby test mohl uspět
- Test case (testovaný případ)
- sada podmínek – testovaná jednotka pracuje správně či nikoliv
- Test suites (sada testů)
- sada testů, které sdílejí tentýž kontext, na pořadí nezáleží
- Assertion (tvrzení, jednotková logická funkce)
- funkce, metoda, makro atp., ověřuje chování testované jednotky, chyba znamená ukončení prováděného testu a indikaci chyby.
Terminologie: dvojníci
- method_stub
- simuluje chování funkce
- mock object
- simuluje reálný objekt, kontrolovaný programátorem
- tyto objekty netestujeme, ale ony mohou testovat naše objekty
- fake object
- jednodušší mock objekt - pouze implementuje stejný interface jako reálný objekt
Struktura testu
// příprava prostředí pro testy
setup();
// tělo testu
testXY();
testXZ();
...
// úklid na konci (nehledě na kladný/záporný výsledek testu)
teardown();PHPUnit
Dokumentace, instalace
- Homepage
- Dokumentace
instalace
composer require --dev phpunit/phpunit
První test
- třída
…TestpotomekTestCase metoda
test…()use PHPUnit\Framework\TestCase; class StackTest extends TestCase { public function testPushAndPop() { $stack = array(); $this->assertEquals(0, count($stack)); } }
Psaní testů
Závislosti mezi testy
- producent, konzument
konfiguruje se pomocí atributů, dříve pomocí anotací
@dependsv dokumentačních komentáříchuse PHPUnit\Framework\TestCase; class StackTest extends TestCase { public function testEmpty() { // ... return $stack; } #[\PHPUnit\Framework\Attributes\Depends("testEmpty")] public function testPush(array $stack) { // ... return $stack; } #[\PHPUnit\Framework\Attributes\Depends("testPush")] public function testPop(array $stack) { // ... } }
Zdroje dat (data providers)
Upozornění:
Metoda data provideru musí být static a public. Zároveň její název nesmí začínat slovem test.
opakované volání pro různé sady dat
#[\PHPUnit\Framework\Attributes\DataProvider("provideAdd")] public function testAdd($a, $b, $c) { $this->assertEquals($c, $a + $b); } public static function provideAdd() { return [ [0, 0, 0], [0, 1, 1], // ... ]; }
Výjimky
očekává se výjimka, pokud k ní nedojde, test selže
class ExceptionTest extends TestCase { // tento test by selhal, protože žádnou výjimku nevyhazuje public function testException() { $this->expectException(Exception::class); } }
Chyby (PHP errors)
- PHPUnit registruje vlastní error handler
E_WARNING,E_NOTICE,E_DEPRECATED(aE_USER_*varianty) = issues- test neselže, výstup:
W,N,Da hláškaOK, but there were issues! - detail zobrazí
--display-warnings,--display-notices,--display-deprecations(nebo--display-all-issues) - selhání lze vynutit:
--fail-on-warning,--fail-on-notice,--fail-on-deprecation,--fail-on-all-issues(nebofailOnWarning="true"atd. vphpunit.xml)- Poznámka bokem: Správným použitím těchto parametrů automatické hodnocení úkolů reaguje na varování.
- test neselže, výstup:
E_USER_ERROR→ převede se na výjimku, test skončí jako errornelze zachytit přes
expectException()- třídaPHPUnit\Framework\Error\Errorbyla odstraněna v PHPUnit 10class WarningTest extends TestCase { public function testFailingInclude(): void { include 'not_existing_file.php'; // E_WARNING $this->assertTrue(true); } }$ vendor/bin/phpunit tests/WarningTest.php W 1 / 1 (100%) OK, but there were issues! Tests: 1, Assertions: 1, Warnings: 1.
- s
--fail-on-warningje výstup stejný, ale exit code je nenulový (CI selže)
Assertions (tvrzení)
- (některá) tvrzení
assertCount()assertEmpty()assertEquals()assertFalse()assertTrue()- → přehled
Fixtures
Přehled
- prostředí, stav světa
- příprava/úklid - před každou
test…()metodousetUp()tearDown()- typicky dealokace zdrojů
- sdílení zdrojů
setUpBeforeClass()tearDownAfterClass()
- pozor na globální a statické proměnné - mohou ovlivnit testy
Příklad
class StackTest extends TestCase {
protected $stack;
protected function setUp(): void {
$this->stack = [];
}
...
}Organizace testů
Adresářová struktura
Object Tests |-- Freezer |-- Freezer | |-- HashGenerator | |-- HashGenerator | | `-- NonRecursiveSHA1.php | | `-- NonRecursiveSHA1Test.php | |-- HashGenerator.php | | | |-- IdGenerator | |-- IdGenerator | | `-- UUID.php | | `-- UUIDTest.php | |-- IdGenerator.php | | | |-- LazyProxy.php | | | |-- Storage | |-- Storage | | `-- CouchDB.php | | `-- CouchDB | | | | |-- WithLazyLoadTest.php | | | | `-- WithoutLazyLoadTest.php | |-- Storage.php | |-- StorageTest.php | `-- Util.php | `-- UtilTest.php `-- Freezer.php `-- FreezerTest.php
Konfigurace (phpunit.xml)
<phpunit>
<testsuites>
<testsuite name="Object_Freezer">
<directory>Tests</directory>
</testsuite>
</testsuites>
</phpunit>Dvojníci (test doubles)
Stub vs. mock
Testovaná jednotka závisí na jiných komponentách, které nelze použít v testovacím prostředí - nejsou dostupné, nejsou funkční, mají vedlejší efekty.
- Stub - náhradní objekt se přednastavenými návratovými hodnotami (herec)
- Mock - náhradní objekt validující chování testované jednotky (kritik)
Příklad
Testujeme Registration. Reálná databáze ani SMTP server v testu nejsou k dispozici.
interface UserRepository {
public function findByEmail(string $email): ?User;
public function save(User $user): void;
}
interface Mailer {
public function send(string $to, string $subject): void;
}
class Registration {
public function __construct(
private UserRepository $repository,
private Mailer $mailer,
) {}
public function register(string $email): User {
if ($this->repository->findByEmail($email) !== null) {
throw new UserAlreadyExistsException($email);
}
$user = new User($email);
$this->repository->save($user);
$this->mailer->send($email, 'Vítejte');
return $user;
}
}Stub: řídí nepřímý vstup
- stub dodává data testované jednotce (co vrátí
findByEmail()?) - nic neověřuje - nezáleží, zda a kolikrát byl zavolán
createStub()+method()+willReturn()use PHPUnit\Framework\TestCase; final class RegistrationTest extends TestCase { public function testRejectsExistingEmail(): void { $repository = $this->createStub(UserRepository::class); $repository->method('findByEmail') ->willReturn(new User('jan@example.org')); $mailer = $this->createStub(Mailer::class); $registration = new Registration($repository, $mailer); $this->expectException(UserAlreadyExistsException::class); $registration->register('jan@example.org'); } }- nenakonfigurovaná metoda vrací výchozí hodnotu podle návratového typu (
null,0,'',[], prázdný dvojník …) - výjimka místo hodnoty:
→willThrowException(new RuntimeException())
Mock: ověřuje nepřímý výstup
register()vracíUser, ale to podstatné (uložení, e-mail) se děje voláním závislostí- mock = stub + očekávání: která metoda, kolikrát, s jakými argumenty
createMock()+expects()+method()+with()PHPUnit očekávání zkontroluje sám na konci testu
public function testRegistersNewUser(): void { $repository = $this->createMock(UserRepository::class); $repository->method('findByEmail')->willReturn(null); $repository->expects($this->once()) ->method('save') ->with($this->isInstanceOf(User::class)); $mailer = $this->createMock(Mailer::class); $mailer->expects($this->once()) ->method('send') ->with('nova@example.org', 'Vítejte'); $registration = new Registration($repository, $mailer); $user = $registration->register('nova@example.org'); $this->assertSame('nova@example.org', $user->email()); }
Mock: co se nesmí stát
never()- test selže, pokud metoda zavolána bylatypicky: e-mail se neposílá, když registrace selže
public function testDoesNotSendMailForExistingEmail(): void { $repository = $this->createStub(UserRepository::class); $repository->method('findByEmail') ->willReturn(new User('jan@example.org')); $mailer = $this->createMock(Mailer::class); $mailer->expects($this->never())->method('send'); $registration = new Registration($repository, $mailer); $this->expectException(UserAlreadyExistsException::class); $registration->register('jan@example.org'); }
Mock: očekávání (přehled)
- počet volání -
expects(…)$this→once(),$this→exactly(2),$this→atLeastOnce(),$this→never(),$this→any()
- argumenty -
with(…)- hodnota = porovnání na rovnost:
→with('a', 1) - constraint:
$this→isInstanceOf(User::class),$this→stringContains('@'),$this→greaterThan(0),$this→callback(fn ($x) ⇒ …) withAnyParameters()- argumenty nekontrolovat
- hodnota = porovnání na rovnost:
- návratová hodnota (stub i mock)
willReturn($value),willReturn($first, $second)- po sobě jdoucí voláníwillReturnMap([[$arg1, $arg2, $result], …])- podle argumentůwillReturnCallback(fn ($arg) ⇒ …)willThrowException(new Exception())
Časté chyby
- testujeme dvojníka místo jednotky -
$stub→method('x')→willReturn(1); $this→assertSame(1, $stub→x());neověřuje nic - mock bez
expects()- PHPUnit vypíše notice; buď doplnit očekávání, nebo použítcreateStub() with()bezexpects()- deprecated, v PHPUnit 14 přestane fungovatfinaltřídy,final/private/staticmetody aenumnelze doublovat - závislosti proto typujte na interface- dvojník musí být vložen do testované jednotky (dependency injection);
new Mailer()uvnitřregister()nahradit nejde - příliš mnoho očekávání = test „zamkne“ implementaci; mock jen tam, kde je komunikace tím, co testujeme, jinak stub