Jdi na navigaci předmětu

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

První test

  • třída …​Test potomek TestCase
  • 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í @depends v dokumentačních komentářích

    use 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 (a E_USER_* varianty) = issues
    • test neselže, výstup: W, N, D a hláška OK, 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 (nebo failOnWarning="true" atd. v phpunit.xml)
      • Poznámka bokem: Správným použitím těchto parametrů automatické hodnocení úkolů reaguje na varování.
  • E_USER_ERROR → převede se na výjimku, test skončí jako error
  • nelze zachytit přes expectException() - třída PHPUnit\Framework\Error\Error byla odstraněna v PHPUnit 10

    class 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-warning je 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…​() metodou
    • setUp()
    • 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 byla
  • typicky: 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
  • 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žít createStub()
  • with() bez expects() - deprecated, v PHPUnit 14 přestane fungovat
  • final třídy, final/private/static metody a enum nelze 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