Пишемо Unit-тести на PHP: путівник PHPUnit та поради з досвіду

Привіт! Мене звати Євгеній Коваль, я PHP-розробник в компанії Wikr Group. Ми займаємось створенням та розвитком контент-проектів в усьому світі. Місячна унікальна аудиторія всіх наших ресурсів складає близько 100 млн користувачів. І тому поняття Highload та Big Data є для нас основними при розробці.

Для забезпечення якості та стабільності коду ми приділяємо увагу написанню юніт-тестів, які ще на етапі розробки можуть виявити баги та запобігти подальшим проблемам. Отже, в цій статті я хочу поділитись нашим досвідом роботи з юніт-тестами.

Disclaimer: Стаття не є повною інструкцією з написання тестів, адже таких матеріалів написано дуже багато. Я хотів зробити акцент саме на здобутках з власного досвіду.

Стаття розрахована на читачів, які вже знайомі з тестуванням, але для дуже кмітливих початківців вона теж буде корисною. Я намагався поверхнево описати і базові речі, з яких можна починати. Сподіваюсь, після прочитання матеріалу охочих писати юніт-тести стане більше, адже це найменш затратний за часом вид тестування, який до того ж є найефективнішим на етапі розробки.

Навіщо потрібні Unit-тести

Юнітом називається маленький самодостатній шматок коду, який реалізує певну поведінку. Дуже часто, але не завжди, він є класом.

Для чого писати юніт-тести? По-перше, таким чином ми автоматизуємо процес перевірки коду, по-друге — задумаємось над логічністю декомпозиції коду. Юніт-тести допомагають:

  • виявити конкретне місце проблеми і швидко її виправити;
  • під час рефакторингу бути впевненим у тому, що твої зміни не порушили поведінку методу;
  • завжди знати, що очікувати від коду;
  • покращувати код, виявляючи певні «недосконалості» і вносячи зміни.
Головна причина написання юніт-тестів — це розуміння того, що ваш код може і буде видавати помилки, але ви завжди будете на крок попереду і знатимете про це перед тим, як код побуває в продакшені.

Інсталюємо PHPUnit та Mockery

Для юніт-тестування я зазвичай використовую фреймворк PHPUnit. Це загальноприйнятий стандарт, який повністю покриває всю зону відповідальності. Багато інструментів заточені на роботу саме з ним.

Тож встановлюємо PHPUnit та підключаємо його в свій проект, використовуючи команду:

$ composer require --dev phpunit/phpunit

З PHPUnit зручно використовувати фреймворк Mockery — для мокання жорстких залежностей. Для того щоб підключити його в свій проект, достатньо виконати команду:

$ composer require --dev mockery/mockery

Прикладами жорстких залежностей, які важко мокати в PHPUnit, можуть бути функції створення нового екземпляра або статичний виклик всередині методу:


// Creation of new instance inside the method
public function getObject() 
{
    $object = new Object();
    // .. do something with $object
    
    return $object;
}

// Static call inside the method
public function getObject()  
{
    $object = Object::getInstance();
    // .. do something with $object

     return $object;
}

Запустити юніт-тести можна командою:

$ vendor/bin/phpunit path/to/tests/folder 

Але поки що нам нічого запускати, тож далі розглянемо, з чого почати писати ваш перший юніт-тест.

Ініціалізуємо базову структуру тесту

Перш за все, потрібно створити клас з суфіксом Test і успадкувати його від \PHPUnit_Framework_TestCase класу з пакету PHPUnit:

class MyServiceTest extends \PHPUnit_Framework_TestCase 
{
    // ... tests for service MyService
}

Для ініціалізування базової структури тесту використовуємо:

  • метод setUp(), в якому оголошуємо базові речі для ініціалізації класу, який будемо тестувати. Тут можна замокати вхідні параметри конструктора класу та створити як реальний об’єкт класу, який тестуємо, так і partial (частковий) мок;
  • метод tearDown(), в якому бажано очищати пам’ять, використану поточним тестом;
  • метод \Mockery::close(), який потрібно викликати в методі tearDown(), якщо ви використовували Mockery. Цей метод очищає контейнер, який Mockery створює для поточного тесту. Як альтернативу можна використовувати прослуховувач від Mockery: \Mockery\Adapter\Phpunit\TestListener, який потрібно оголосити в файлі конфігурацій phpunit.xml.
Код, який тестуємоТест для коду
namespace DemoBundle\Service;

/** 
* Class DataService
*
* @package DemoBundle\Service
*/
class DataService 
{
    /** @var HttpClient */
    private $httpClient;
                
    /**
    * UserGeneratorServiceTest constructor.
    *
    * @param HttpClient $httpClient
    */
    public function __construct(HttpClient $httpClient)
    {
        $this->httpClient = $httpClient;
    }
                
    /**
    * Get data.
    *
    * @param string $url
    * @return string*
    * @throws GetRequestException
    */
                
    public function getData(string $url): string
    {
        $response =  $this->httpClient->get($url);
                                
        if ($response->isSuccess()) {
        $content = $response->getBody();
        } else {
        $content = $response->getErrorMessage();
    }
                                
        return $content;
    }
}

namespace Tests\DemoBundle\Service\DataServiceTest;

/**
* Class GetDataTest
*
* @package Tests\DemoBundle\Service\DataServiceTest
*/
class GetDataTest extends \PHPUnit_Framework_TestCase
{
    /** @var \PHPUnit_Framework_MockObject_MockObject */
    public $httpClientMock;
                
    /** @var DataService */
    public $dataService;
                
    /**
    * {@inheritdoc}
    */
    public function setUp()
    {
        $this->httpClientMock = $this->createMock(HttpClient::class);
        $this->dataService = new DataService($this->httpClientMock);
    }
                
    // here is should be your test cases for getData method
                
    /**
    * {@inheritdoc}
    */
    public function tearDown()
    {
        // If you use Mockery in your tests you MUST use this method
        \Mockery::close();
                                
        // clean up the memory taken by your instance of service
        $this->dataService = null;
                                
        // Forces collection of any existing garbage cycles
        gc_collect_cycles();
    }
}

Визначаємо тест-кейси

Далі варто продумати, скільки тест-кейсів може бути і як їх правильно розбити.

Кожний тест-кейс повинен покривати конкретну зону, проте він не має враховувати всі можливі кейси.

Код, який тестуємоТест для коду
/**
* Get data.
*
* @param string $url
* @return string
* @throws GetRequestException
*/
public function getData(string $url) : string
{
    $response = $this->httpClient->get($url);
                
    if ($response->isSuccess()) {
    $content = $response->getBody();
    } else {
        $content = $response->getErrorMessage();
    }
                
    return $content;
}
/**
* Test getData success.
* 
* @covers \DemoBundle\Service\DataService::getData()
*/
public function testGetDataSuccess()
{
}

/**
* Test getData fails.
*
* @covers \DemoBundle\Service\DataService::getData()
*/
public function testGetDataFails()
{
}

/**
* Test getData throws exception.
*
* @covers \DemoBundle\Service\DataService::getData()
*/
public function testGetDataThrowsException()
{
}

Викликаємо метод з необхідними тестовими даними

Після того як ми вибрали конкретний кейс, який хочемо протестувати, необхідно прописати виклик методу, який тестуємо, з необхідними тестовими даними (параметрами):

Код, який тестуємоТест для коду
/**
* Get data.
*
* @param string $url
* @return string
* @throws GetRequestException
*/
public function getData(string $url) : string
{
    $response = $this->httpClient->get($url);
                
    if ($response->isSuccess()) {
        $content = $response->getBody();
    } else {
        $content = $response->getErrorMessage();
    }
                
        return $content;
}
/**
* Test getData success.
*
* @covers \DemoBundle\Service\DataService::getData()
*/
public function testGetDataSuccess()
{
    $testUrl = 'some/test/url';
                
    // RUN test
    $testResult = $this->dataService->getData($testUrl);
}

Наступні кроки — прописати очікування від методу та моки для усіх зовнішніх залежностей.

Визначаємо очікування

Уся суть юніт-тестування — перевірка поведінки методу залежно від вхідних даних. А отже, нам потрібно прописати те, що ми очікуємо від методу, який тестуємо, якщо викличемо його з певним набором параметрів, оголошених на попередньому кроці.

Код, який тестуємоТест для коду
/**
* Get data.
*
* @param string $url
* @return string
* @throws GetRequestException
*/
public function getData(string $url) : string
{
    $response = $this->httpClient->get($url);
                
    if ($response->isSuccess()) {
        $content = $response->getBody();
    } else {
        $content = $response->getErrorMessage();
    }
                
    return $content;
}
/**
* Test getData success.
* 
* @covers \DemoBundle\Service\DataService::getData()
*/
public function testGetDataSuccess()
{
    $testUrl = 'some/test/url';
    $testResponseBody = 'test response body';
                
    // RUN test
    $testResult = $this->dataService->getData($testUrl);
                
    // Check your expectations
    $expectedResult = 'test response body';
    $this->assertTrue(is_string($testResult));
    $this->assertEquals($expectedResult, $testResult);
}

Мокаємо всі зовнішні залежності

Для того щоб гарантувати, що наш тест ізольований від зовнішніх можливих збоїв, ми створюємо моки для усіх зовнішніх залежностей і задаємо, щоб всі залежності працювали саме так, як потрібно.

В прикладі показано, як створюється мок для відповіді GET-методу httpClient:

Код, який тестуємоТест для коду
/**
* Get data.
*
* @param string $url
* @return string
* @throws GetRequestException
*/
public function getData(string $url) : string
{
    $response = $this->httpClient->get($url);
                
    if ($response->isSuccess()) {
        $content = $response->getBody();
    } else {
        $content = $response->getErrorMessage();
    }
                
    return $content;
}
/**
* Test getData success.
*
* @covers \DemoBundle\Service\DataService::getData()
*/
public function testGetDataSuccess()
{
    $testUrl = 'some/test/url';
    $testResponseBody = 'test response body';
    $responseMock = $this->createMock(Response::class);
    $this->httpClientMock
        ->expects($this->any())
        ->method('get')
        ->with($this->equalTo($testUrl))
        ->willReturn($responseMock);
                
    // RUN test
    $testResult = $this->dataService->getData($testUrl);
                
    // Check your expectations
    $expectedResult = $testResponseBody;
    $this->assertTrue(is_string($testResult));
    $this->assertEquals($testResponseBody, $testResult);
}

Треба чітко розуміти, коли перевіряти очікування any(), коли once(), exactly(), atLeastOnce() тощо.

На попередньому етапі ми створили мок GET-методу для httpClient і вказали йому очікування any(). Це вказує на те, що нам не принципово, скільки разів буде викликатися цей метод в рамках тесту, адже це GET-метод. У цьому випадку ми впевнені, що будь-який виклик методу повинен повернути той самий результат.

А що буде, якщо замість GET-методу буде виконуватись POST? У такому випадку вже буде критично, скільки разів цей метод буде викликатися, і тут вже повинна бути жорстка перевірка — once().

Мокаємо всі методи, які впливають на результат

І останній крок — прописати очікування для всіх моків, які ми отримуємо в процесі тесту. Це дозволяє гарантувати, що наші моки ведуть себе, як реальні об’єкти. Якщо цього не зробити, за замовчуванням всі методи повертатимуть null.

Код, який тестуємоТест для коду
/**
* Get data.
*
* @param string $url
* @return string
* @throws GetRequestException
*/
public function getData(string $url) : string
{
    $response = $this->httpClient->get($url);

    if ($response->isSuccess()) {
        $content = $response->getBody();
    } else {
        $content = $response->getErrorMessage();
    }
                
    return $content;
}
/**
* Test getData success.
*
* @covers \DemoBundle\Service\DataService::getData()
*/
public function testGetDataSuccess()
{
    $testResponseIsSuccess = true;
    $testUrl = 'some/test/url';
    $testResponseBody = 'test response body';
                
    $responseMock = $this->createMock(Response::class);
                
    $this->httpClientMock
        ->expects($this->atLeastOnce())
        ->method('get')
        ->with($this->equalTo($testUrl))
        ->willReturn($responseMock);
                
    $responseMock
        ->expects($this->atLeastOnce())
        ->method('isSuccess')
        ->willReturn($testResponseIsSuccess);

    $responseMock
        ->expects($this->once())
        ->method('getBody')
        ->willReturn($testResponseBody);
                
    // RUN test
    $testResult = $this->dataService->getData($testUrl);
                
    // Check your expectations
    $expectedResult = $testResponseBody;
    $this->assertTrue(is_string($testResult));
    $this->assertEquals($testResponseBody, $testResult);
}

Варто розуміти, що неважливо, як метод прийшов до необхідного результату, адже ми перевіряємо, що метод повернув саме те, що нам потрібно. Ми тестуємо його поведінку, а не процес. Тому завжди акцентуйте увагу на результаті методу і на сервісах, на які він впливає, тобто на зовнішніх залежностях.

Чим більше тестів буде написано, тим краще. Не треба намагатись одним тестом покрити всі можливі кейси, адже тоді отримаємо кашу. Ліпше написати декілька окремих тестів, ніж один «універсальний».

Best Practices

Дам декілька порад при написанні тестів.

1. Не використовуйте в юніт-тестах ті самі константи, які оголошено в оригінальному класі. Це потрібно для того, щоб уникнути «живого» зв’язку константи з тестом, коли тест буде успішно виконуватися при зміні значення константи.

2. При тестуванні конкретних випадків використовуйте Data Providers. В одному Data Provider можна об’єднувати тестові данні з єдиного кейсу. Наприклад, коли об’єкт приймає поле типу INT або STRING і повинен за таких умов відпрацювати успішно. Якщо ж метод прийняв значення NULL і має відмінну поведінку, то це вже буде інший кейс, який слід описувати іншим тестом. Так само, як і той кейс, коли метод повинен кидати EXCEPTION.

3. Приватні методи також потребують тестування. Я вважаю, тестувати варто все, що може зламати логіку поведінки твого юніту.

Для запуску тестів приватних методів ми застосовуємо свій метод, який використовує рефлексію:

* Call protected/private method of a class.
*
* @param object $object Instantiated object that we will run method on.
* @param string $methodName Method name to call
* @param array $parameters Array of parameters to pass into method.
* @return mixed Method return.
*/
public function invokeMethod($object, $methodName, array $parameters = array())
{
    $reflection = new \ReflectionClass(get_class($object));
    $method = $reflection->getMethod($methodName);
    $method->setAccessible(true);
                
    return $method->invokeArgs($object, $parameters);
}

4. Для тестування трейтів та абстрактних класів в PHPUnit є свої інструменти. Їх можна реалізувати двома способами: напряму через створення мок-класу та через білдер, який зручно кастомізувати.

Абстрактні класи:

// Direct way
$abstractObject = $this->getMockForAbstractClass(AbstractObject::class);

// Builder way
$abstractObject = $this->getMockBuilder(AbstractObject::class)
    ->setConstructorArgs([$arg1, $arg2, ...])
    ->setMethods(['methodToMock', 'anotherMethodToMock']) // if you need to mock
    ->getMockForAbstractClass();

Трейти:

// Direct way
$someTrait = $this->getMockForTrait(SomeTrait::class);

// Builder way
$someTrait = $this->getMockBuilder(SomeTrait::class)
    ->setMethods(['methodToMock', 'anotherMethodToMock']) // if you need to mock
    ->getMockForTrait();

5. Якщо метод працює з файлом, то нам потрібно створити свою заглушку файлу в папці з тестами і мокати шлях до нього. Файл буде існувати виключно для цього тесту або декількох інших тестів (якщо файл на читання, а не на запис).

Використання одного файлу, в який пишуться дані з різних тестів, — погана практика, яка може призвести до того, що тест завалиться і буде не інформативним.

Аналізуємо покриття коду

Як би ми не намагались покривати тестами весь код, все одно знайдеться місце (окремий тест-кейс), яке залишилось не покритим. Для цього нам на допомогу приходять такі інструменти, як code coverage, який входить в пакет PHPUnit. Але треба розуміти: навіть якщо coverage каже, що метод покритий на 100 %, це не гарантія, що ми дійсно покрили всі можливі кейси. Справа в тому, що coverage маркує ті рядки коду, які були виконані під час тесту, а не ті, які гарантують, що код не зламається.

Щоб запускати генерацію звітів, вам потрібно PHP-розширення Xdebug. У файлі конфігурації phpunit.xml можна налаштувати безліч параметрів для запуску тестів. Більш детально з цим інструментом можна ознайомитись в документації PHPUnit.

Ось приклад того, як можна виключити з coverage-репорту папки з файлами, що не містять коду, який потрібно аналізувати і відображати в репорті:

// phpunit.xml
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi=“..” ..>
    <filter>
        <whitelist>
        <directory>src</directory>
            <exclude>
                <directory>src/*Bundle/Command</directory>
                <directory>src/*Bundle/Controller</directory>
                <directory>src/*Bundle/Entity</directory>
                <directory>src/*Bundle/Resources</directory>
            </exclude>
        </whitelist>
    </filter>
</phpunit>

Щоб побачити різнокольоровий графік рівня покриття, можна налаштувати нижній поріг, при якому код буде розцінюватись як не повністю покритий, та верхній поріг, при якому код буде розцінюватись як достатньо покритий. Ці два пороги задаються параметрами lowUpperBound та highLowerBound.

Також можна задати папку, куди через поле target буде за замовчуванням генеруватися звіт. Формат відображення звіту можна задати через поле type.

// phpunit.xml

<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi=“..” ..>
    <logging>
        <log type="coverage-html" target="web/coverage/"
        lowUpperBound="35" highLowerBound="70"/>
    </logging>
</phpunit>

Маркуйте конкретні методи, які непотрібно враховувати в звіті, анотацією @codeCoverageIgnore або конкретні моменти в коді за допомогою анотацій @codeCoverageIgnoreStart та @codeCoverageIgnoreEnd:

/**
* Do something.
* 
* @codeCoverageIgnore
*/

public function doSomething()
{
    // code of method
}

Щоб вказати, який саме метод ми тестуємо, застосовуйте анотацію @covers:

/**
* Test that method doSomething work as expected.
*
* @covers \DemoBundle\SomeClass::doSomething()
*/

public function testDoSomething()
{
    // test
}

Як результат усіх конфігурацій і простого запуску тестів ми отримаємо такий звіт:

У репорті є безліч цікавої інформації по вашим тестам. Один із параметрів, на який варто звернути увагу, — це CRAP index (Change risk anti-pattern). Чим складніший тест і менший процент покриття, тим більший індекс CRAP:

Червоним в репорті виділені рядки коду, які в процесі всіх тест-кейсів жодного разу не були виконані, тобто ви не написали тесту під цей кейс. Це є потенційною загрозою для вашого коду, так як саме в цьому випадку є шанси, що ваш код зламається.

Формуємо стиль коду

Як при написанні коду, так і при написанні тестів варто дотримуватися певного стилю коду — набору правил, за якими його написано. Адже все, що базується на правилах та принципах, зазвичай працює більш стабільно, ніж за хаотичних процесів.

Декілька прикладів, як ми наводимо красу в тестах:

1. Тести можуть бути маленькі та великі, і окремих варіантів для одного методу може бути багато. Тому ми розробили стратегію розподілення тестів за класами для зручності читання тестів:

namespace Tests\Service;

/** 
* Class contains constants and some useful info for test cases
*/
abstract class SalaryServiceTest extends \PHPUnit_Framework_TestCase
{
}

namespace Tests\Service\SalaryServiceTest;

/**
* Class contains specific method test case that is huge
*/
class GetSalaryChangeTest extends SalaryServiceTest
{
}

namespace Tests\Service\SalaryServiceTest\GetSalaryChangeTest;

/**
* Class contains specific test case for a specific method of tested class
*/
class EmptyCheckTest extends SalaryServiceTest
{
}

За неймспейсом створюється ієрархія тест-кейсів для методів. В класі SalaryServiceTest ми зберігаємо тільки базові конфігурації, такі як кастомізований білдер тесту setUp() та метод tearDown(), в якому проводимо необхідні базові очистки даних. Сам клас оголошено абстрактним, щоб PHPUnit його ігнорував.

2. Усі прості типи даних ми префіксуємо словом test, а всі моки класів суфіксуємо словом Mock:

$test + [original_variable_name]

public function testMethod()
{
    $testTemplateName = 'test response body';
    $testResult = 123;
    $testData = ['key' => 'val'];
}


$[original_class_name] + Moc

public function testMethod()
{
    $responseMock = $this->createMock(Response::class);
    $userMock = $this->createMock(User::class);
}

3. Написаний код повинен бути максимально простим, читабельним та зрозумілим. Він не має викликати дискомфорту і хаосу при першому погляді. Це дає мотивацію далі в ньому розбиратися.

Тому ми пишемо не так:

$userEventMock->expects($this->once())->method('getUser')->willReturn($testEventUser);
$this->tokenGeneratorMock->expects($this->once())->method('generateToken')->willReturn($testGeneratedToken);
$this->userManagerMock->expects($this->once()) ->method('updateUser')->with($this->callback($callback));

І не так:

userEventMock->expects($this->once())
    ->method('getUser')
    ->willReturn($testEventUser);

$this->tokenGeneratorMock->expects($this->once())
    ->method('generateToken')
    ->willReturn($testGeneratedToken);

$this->userManagerMock->expects($this->once())
    ->method('updateUser')
    ->with($this->callback($callback));

А так:

$userEventMock
    ->expects($this->once())
    ->method('getUser')
    ->willReturn($testEventUser);

$this->tokenGeneratorMock
    ->expects($this->once())
    ->method('generateToken')
    ->willReturn($testGeneratedToken);

$this->userManagerMock
    ->expects($this->once())
    ->method('updateUser')
    ->with($this->callback($callback));

Підсумок

Тестуйте поведінку методу, а не його внутрішню реалізацію. Внутрішня реалізація коду, який не впливає на зовнішні фактори, не повинна ламати тест.

Дотримуйтеся стилю коду в тестах. Стандарти — це завжди добре, до того ж правити стандартизовані тести людям буде зручніше і швидше. Згляньтеся на тих, хто буде підтримувати ці тести в майбутньому :)

Аналізуйте свої тести. Використовуйте різні інструменти, які допомагають зрозуміти, наскільки ефективні ваші тести.

Не стійте на місці — ідіть далі! Не пишіть тести «просто щоб написати, раз запустити і забути». Продумуйте автоматизацію запуску тестів.

Зрештою, Unit-тести — це зона відповідальності розробників. І постійне їх написання саме нам, розробникам, і спрощує життя. Пишіть тести, аналізуйте, розвивайтесь!

Корисні лінки:

Похожие статьи:
Анатолій Шара — військовий кореспондент і волонтер, який перебував у найгарячіших точках російсько-українського фронту у 2014–2015...
TL;DR: Корпоратив — как секс, должен быть по обоюдному согласию. И таки да, есть небанальные решения. В конце — апдейт по моему...
Удаленная работа и работа в распределенной команде на сегодняшний день стали нормой — очень редко можно встретить...
Всем привет! Я Тарас, Front-end developer в ЛУН. С первого своего дня в компании я выбрал работу над картой новостроек. В этой...
Представляем новую статью из цикла «Карьера в IT». Она посвящена должности DevOps engineer — такие специалисты работают...
Яндекс.Метрика