Kuba Werłos


Wprowadzenie do

PHPUnit


Matic Services
Hubranch

Po co piszemy testy automatyczne?


  • Ułatwiają utrzymywanie kodu.
  • Ułatwiają tworzenie nowych funkcjonalności.
  • Jesteśmy pewni czy nasz kod działa tak jak tego oczekujemy.
  • Stworzone przed implementacją pozwalają na szybsze uzyskanie poprawnej implementacji.
  • Pozwalają na bezpieczne optymalizowanie i refaktoryzację kodu.
  • Ułatwiają wdrożenie nowej osoby do zespołu.

Given–When–Then

to styl pisania przypadków testowych.

  • Given: aplikacja jest w danym kontekście.
  • When: gdy następuje akcja testowa.
  • Then: wtedy powinny wystąpić pewne obserwowalne rezultaty.

Przykład:

  • Given there are 20 cucumbers
  • When I eat 5 cucumbers
  • Then I should have 15 cucumbers

PHPUnit

to narzędzie do pisania testów jednostkowych automatycznych.


Autorem jest Sebastian Bergmann i kontrybutorzy.


Pierwsza wersja została wydana w 2004 roku.


Według packagist.org został zainstalowany 117 milionów razy.

Instalacja

Jako zależność projektu:

composer require --dev phpunit/phpunit
./vendor/bin/phpunit --version

(lokalnie lub globalnie)


Ściągając plik PHAR:

wget https://phar.phpunit.de/phpunit-8.0.phar
php phpunit-8.0.phar --version

Wersje


PHPUnitPHP
^4.05.3+
^5.05.6+
^6.07.0+
^7.07.1+
^8.07.2+

Statystyki

PHPUnit repo stats

Konfiguracja

phpunit.xml[.dist]
<?xml version='1.0' encoding='UTF-8'?>
<phpunit xmlns:xsi='http://www.w3.org/2001/XMLSchema-instance'
xsi:noNamespaceSchemaLocation='vendor/phpunit/phpunit/phpunit.xsd'
verbose='true'
>

<testsuites>
<testsuite name='all'>
<directory>tests</directory>
</testsuite>
</testsuites>

</phpunit>

Konfiguracja


<?xml version='1.0' encoding='UTF-8'?>
<phpunit backupGlobals='false'
bootstrap='./vendor/autoload.php'
cacheResult='true'
cacheResultFile='.phpunit.result.cache'
columns='80'
enforceTimeLimit='false'
executionOrder='default'
forceCoversAnnotation='false'
processIsolation='false'
stopOnFailure='false'
>
</phpunit>

Korzystanie

  • Uruchomienie z podaniem pliku konfiguracyjnego:
    phpunit -c tests/configuration-for-ci-server.xml
  • Tylko jeden zestawu testów:
    phpunit --testsuite Unit
  • Testy z danej ścieżki:
    phpunit tests/MyModule/MyFeatureTest.php
  • Pojedynczy test:
    phpunit tests/MyFeatureTest.php --filter testSomething

Budowa testu

Nazwa musi zaczynać się od „test” lub posiadać adnotację „@test”.


final class MyService
{
public function getName() { /* ... */ }
public function getCalculation() { /* ... */ }
}

Budowa testu

final class MyServiceTest extends TestCase
{
public function testGetName() { /* ... */ }
public function testGetCalculation() { /* ... */ }
}
final class MyServiceTest extends TestCase
{
/**
* @test
*/
public function getName() { /* ... */ }

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

Budowa testu


  • Klasa z testami musi dziedziczyć po PHPUnit\Framework\TestCase.
  • Funkcja testu musi być publiczna.
  • Przydatne metody klasy PHPUnit\Framework\TestCase:
    • setUpBeforeClass – przed pierwszym testem w klasie,
    • setUp – przed każdym testem w klasie,
    • tearDown – po każdym testem w klasie,
    • tearDownAfterClass – po ostatnim teście w klasie.

Data providers


final class MyConverterTest extends TestCase
{
public function testEmptyInput() { /* ... */ }

public function testNumberInput() { /* ... */ }

public function testSingleCharacterInput() { /* ... */ }

public function testLongStringInput() { /* ... */ }
}

Data providers

final class MyConverterTest extends TestCase
{
/**
* @dataProvider provideConversionCases
*/
public function testConversion($expected, $input) { /* ... */ }

public function provideConversionCases()
{
return [
'empty input' => [ /* ... */ ],
'number input' => [ /* ... */ ],
'single character input' => [ /* ... */ ],
'long string input' => [ /* ... */ ],
];
}
}

Asercje


PHPUnit posiada ponad 100 asercji, między innymi:

  • assertTrue
  • assertFalse
  • assertSame
  • assertGreaterThanOrEqual
  • assertContainsOnlyInstancesOf
  • assertJsonStringEqualsJsonFile
  • ...

Asercje


final class MyServiceTest extends TestCase
{
public function testSomething()
{
/* ... */

$this->assertTrue($value);
static::assertTrue($value);
Assert::assertTrue($value);
assertTrue($value);
}
}

Asercje

final class MyServiceTest extends TestCase
{
public function testSomething1()
{
/* ... */

$this->assertSame($expected, $actual, 'Values are different');
}

public function testSomething2()
{
/* ... */

$this->assertJsonStringEqualsJsonFile($expected, $actual);
}
}

Testowanie wyjątków

final class MyServiceTest extends TestCase
{
public function testInvalidType()
{
$service = new MyService();

$this->expectException(CustomException::class);
$this->expectExceptionCode(418);
$this->expectExceptionMessage('Not allowed type');

$service->convert(-10);
}
}

Makiety

(ang. test doubles)


Używamy ich zamiast prawdziwego obiektu podczas testów.


Wyróżniamy 5 typów.

Dummy


Ma zastosowanie gdy od obiektu nie oczekujemy niczego.
Zazwyczaj jest używany do uzupełnienia list argumentów.


$this->createMock(LoggerInterface::class);

Stub


Używany jest wtedy gdy potrzebujemy konkretnych wartości dla testu.


$animal = $this->createMock(Animal::class);
$animal
->method('numberOfLegs')
->willReturn(100);

Fake

Posiada działającą implementację, zwykle uproszczoną w celu uniknięcia efektów ubocznych (np. InMemoryTestDatabase).

$fruit = $this->createMock(FruitInterface::class);
$fruit->method('getColour')
->willReturnCallback(function ($fruit) {
$colours = ['apple' => 'red', 'banana' => 'yellow'];
if (isset($colours[$fruit])) {
return $colours[$fruit];
}
return 'unknown';
});

Spy

Używany gdy potrzebujemy zebrać informacje co się dzieje
z makietą podczas testu.

$sentMessages = [];

$logger = $this->createMock(MessageSender::class);
$logger->method('send')
->willReturnCallback(function ($message) use (&$sentMessages) {
$sentMessages[] = $message;
});

Mock

Oprócz tego co robią poprzednie makiety dodatkowo określa oczekiwania przed wykonaniem testu.


$animal = $this->createMock(Animal::class);
$animal
->expects($this->once())
->method('feed')
->with('Meat');

Makiety


Makieta zwracająca makietę najprawdopodobniej wskazuje,
że zależności nie są prawidłowo zdefiniowane
(Prawo Demeter).


Słowo „mock” w kontekście PHPUnit oznacza makietę
i tylko od nas zależy jak jej użyjemy.

Co testujemy?


Poprawne rezultaty

$validator = new Validator();

assertTrue($validator->isValid('fineValue'));
assertFalse($validator->isValid('invalidValue'));

Co testujemy?


Niezmienniki – struktura

$shoppingCart = new ShoppingCart();

$shoppingCart->addProduct(new Product('book'), 1);
$shoppingCart->addProduct(new Product('game'), 1);

$productsList = $shoppingCart->getProductsList();
assertEquals(new Product('book'), productsList->getAtPosition(1));
assertEquals(new Product('game'), $productsList->getAtPosition(2));

Co testujemy?


Niezmienniki – logika

$shoppingCart = new ShoppingCart();

$shoppingCart->addProduct(new Product('book'), 2);
$shoppingCart->addProduct(new Product('book'), 4);

assertSame(6, $shoppingCart->getProductQuantity(new Product('book'));

Co testujemy?


Warunki brzegowe

assertFalse(DateService::isWorkingDay(false));

Co testujemy?


Warunki brzegowe – zgodność z oczekiwanym formatem

$nameForEmailExtractor = new NameForEmailExtractor();

expectException(InvalidEmailException::class);

$nameForEmailExtractor->extract('jan.kowalski!example.com');

Co testujemy?


Warunki brzegowe – czy wartość należy do określonego przedziału

expectException(InvalidLatitudeException::class);

$latitude = new Latitude(90.5);

Pokrycie kodu


  • Pokrycie kodu (ang. code coverage) to raport który pokazuje,
    co zostało przetestowane.
  • Aby stworzyć raport pokrycia kodu należy mieć skonfigurowany Xdebug, PHPDBG lub PCOV.
  • Należy pamiętać, że liczenie pokrycia kodu jest kosztowne – potrafi kilkukrotnie wydłużyć czas wykonywania się testów.

Pokrycie kodu

<?xml version='1.0' encoding='UTF-8'?>
<phpunit>
<filter>
<whitelist>
<directory>./src</directory>
</whitelist>
</filter>
<testsuites>
<testsuite name='coverage'>
<directory>./tests</directory>
<exclude>./tests/Functional</exclude>
</testsuite>
</testsuites>
<logging>
<log type='coverage-html' target='var/test-report' />
<log type='coverage-text' target='php://stdout' showOnlySummary='true' />
</logging>
</phpunit>

Pokrycie kodu

$ ./vendor/bin/phpunit
PHPUnit 8.0.5 by Sebastian Bergmann and contributors.
Runtime: PHP 7.3.3-1+ubuntu16.04.1+deb.sury.org+1
Time: 42.38 seconds, Memory: 206.50 MB
Tests: 13849, Assertions: 398464.
$ ./vendor/bin/phpunit
Runtime: PHP 7.3.3-1+ubuntu16.04.1+deb.sury.org+1 with Xdebug 2.7.0
Time: 39.61 minutes, Memory: 318.50 MB
Code Coverage: 79.61%
$ phpdbg -qrr ./vendor/bin/phpunit
Runtime: PHPDBG 7.3.3-1+ubuntu16.04.1+deb.sury.org+1
Time: 4.54 minutes, Memory: 432.50 MB
Code Coverage: 79.16%
$ ./vendor/bin/phpunit
Runtime: PHP 7.3.3-1+ubuntu16.04.1+deb.sury.org+1 with PCOV 1.0.0
Time: 1.46 minutes, Memory: 342.50 MB
Code Coverage: 89.73%

Dobre praktyki


  • Używajmy najbardziej szczegółowych asercji
  • zamiast:
    assertTrue(empty($value));
    assertTrue(file_exists($file));
  • czytelniej jest zastosować:
    assertEmpty($value);
    assertFileExists($file);

Dobre praktyki


  • Używajmy assertSame zamiast assertEquals.
  • Bądźmy świadomi bezużytecznych testów (beStrictAboutTestsThatDoNotTestAnything).
  • Poprawnie nazywajmy klasy testowe (Foo.php → FooTest.php).
  • Utrzymujmy tą samą strukturę katalogów i plików dla testów oraz testowanego kodu.

Dobre praktyki

  • Używajmy adnotacji @covers przy wyliczaniu pokrycia kodu.
/**
* @covers \MyProject\MyService
*/
final class MyServiceTest extends TestCase
{
/* ... */
}
/**
* @coversNothing
*/
final class MyFunctionalTest extends TestCase
{
/* ... */
}

Dobre praktyki


  • Unikajmy – trudnej do powtórzenia – nieprzewidywalności:
    public function testSomethingWithCurrentTime()
    {
    /* poniższa wartość jest przypadkowa */
    $timestamp = time();
    }
    public function testSomethingWithFixedDate() {
    /* poniżej będzie zawsze ten sam dzień */
    $date = date_create_from_format('j-M-Y', '26-Mar-2019');
    };

Piszmy testowalny kod!


Pomocne narzędzia


  • Prophecy (“If I were to create a new mocking framework today it would probably look a lot like Prophecy”).
  • ParaTest – pozwala zrównoleglić uruchamianie testów.
  • phpunit-speedtrap – pozwala migrować testy do nowszej wersji PHPUnit-a.
  • php-invoker – pozwala wymusić limit czasu na test.
  • PHP-Mock – pomocne gdy musimy mockować natywne funkcję PHP.
  • PHP CS Fixer – pozwala migrować testy do nowszej wersji PHPUnit-a.

Pytania?

Dziękuję za uwagę


werlos@gmail.com


kubawerlos

https://kubawerlos.github.io/slides