Final countdown. DevOps Stage 2018. Book your ticket today.
×Закрыть

Пишемо 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-тести — це зона відповідальності розробників. І постійне їх написання саме нам, розробникам, і спрощує життя. Пишіть тести, аналізуйте, розвивайтесь!

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

LinkedIn

29 комментариев

Подписаться на комментарииОтписаться от комментариев Комментарии могут оставлять только пользователи с подтвержденными аккаунтами.

@Evgeniy Koval, а чи доводилось Вам писати тести не класів, а інших ’самостійних шматків коду’? Наприклад функцій з legacy-коду проекта.

Нажаль ніколи з таким не стикався) У вас якісь проблеми з цим виникли? Поділіться, цікаво. Я так відразу не бачу проблем. Якщо код можна виконати — значить його можна протестувати

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

Як написати тести до такої функціїї?
При цьому на те, щоб переписати її як public метод якогось класу (з рефакторингом іншого коду) не виділяється часу.

Завжди важливо усвідомлювати небезпеку моків. Коли ми починаємо застосовувати моки для всього підряд ми можемо сильно прив’язати тест до його технічної реалізації. Що в майбутньому приведе до нестабільних, непоказових тестів, які будуть просто палками в колесах. Крім того моки дуже ускладнюють читання... Складно відслідкувати логіку того, що перевіряється і навіщо, коли тест перегружений моками. Моки 100% потрібні для заглушок на асинхронні сервіси, сторонні сервіси, пошту, http, а от в інших випадках — хз, треба дивитись і розбирати наскільки вони ефективні.

Ось декілька моїх статей на цю тему:
codeception.com/...​on-vs-implementation.html
codeception.com/...​ding-the-command-bus.html

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

Так, тут повністю погоджуюсь! Мокати потрібно з розумом. Проте,

:) и все что до «проте» ничего не значит :)

Проте — тут для акценту на важливості ізоляції оточення юніт тесту. А от якщо взяти інші види тестів то там якщо мокати не думаючи — тести будуть безтолкові :)
Плюс потрібно розуміти що таке «оточення» юніт тесту.

тест же не виноват, что в коде может оказаться много зависимостей, не мокать какую-то из них не вариант, а то не юнит-тест получится

тест не винен :) мокати потрібно все)

Інструментами, які ви показали, можна тестувати більш-менш самостійні класи/утіліти.
А як ви підходите до тестування коду,
1. Зав’язонго на БД? Мокати ActiveRecord — коли в базі порядка 100 таблиць — якось багато. Чи розгортаєте «тестову» базу?
2. Контроллерів, що теж працюють з БД, з кешом (redis/memcache), видають view, пишуть/читають в $_SESSION?
3. Чи використовуєте codeception?

Ігор, доброго вечору!
Так, для функціональних тестів ми розгортали тестову базу. Також працював і з codeception в тому числі.
На даний момент ми(відділ розробки) пишемо тільки юніт тести, в рамках яких база і все інше ізолюється(мокається), — жодної живої зовнішньої залежності, в тому числі і база. Усими іншими видами тестів займаються наші QA Automation.
Нажаль не стикався з створенням моків для ActiveRecord, так як з ActiveRecord вже напевно роки 2 не працював, але там ніби ж нічого особливого немає, все через мок білдер конфіжиться.

Дякую за відповідь.
1. Цікаво, тобто в збірці (з базою) у вас ганяються тільки тести, скажем, через selenium?
2. Я вірно розумію, що роботу з БД ви огортаєте в якийсь рівень абстракції (аля DAO), який потім успішно мокаєте?
2.1. Якщо так, чи вірно те, що сам DAO тестами не покривається (а операції з БД можуть бути складними, особливо, якщо якийсь GridView з фільтрами і 10-ком джойнів показати)?
2.2. Якщо не ActiveRecord, то чи використовуєте ви в якомусь іншому вигляді якісь ORM? Чи є з ними нюанси? Чи використовуються їх можливості за межами DAO?

Добрий вечір!
1. Так, все вірно, але це було давно і вже деталей не памятаю. Для таких тестів зручно робити дамп продакшн бази щоб тести бігали по «реальним» данним. Так є певна ймовірність що тести заваляться — а це добре, так як суть тестів щоб вони завалились там де завалиться код :)
2. З приводу мокання бази — у нас все просто. Ми використовуємо Doctrine ORM. Є репозиторій викликів до бази який дуже просто мокається, є ентіті — які теж дуже просто мокаються. Ніяких складнощів при створенні моків для цих об"єктів взагалі не виникає. Вам просто потрібно знайти той рівень абстракції на якому ви можете керувати всими викликами і повертати необхідні вам результати в рамках юніт тестів, так як все що йде до бази повинно повертати вам данні які вам потрібні для тесту.

Добрый день. Спсибо за статью. Все никак руки не доходят, до тестирования, не понятно с чего начать. В интернете мало материалов, что касаются тестирования на PHP, в основном сферические кони в ваккууме, а вы в целом рассказали про основные инструменты и структурировали их предназначение. За что и спасибо.

Дуже приємно що чимось зміг допомогти :)

Как сделать из мухи слона. Объяснил бы кто, ЗАЧЕМ нужна детализация тестов далее зоны ответственности одного человека? Конечно это классно — знать детали. Но с точки зрения время-денег, всё должно определяться ПРЕДСКАЗАНИЕМ ошибки.

Объяснил бы кто, зачем в религии QA первая буква Q, если теории качества вы не применяете?

1) Не раскрыто почему использование констант из оригинального класса плохо, имхо, константа на то и константа, что при ее изменении в том числе не ломается тест...
2) Тестирование приватных методов? Really...?
3) Чем отличается «мы пишем так» от «І не так» от «а так»
4) PHP5...

Михайло, добрий вечір. Дякую за коментар. Відповім по пунктам:
1. На власному досвіді виникали такі кейси, що константа, яка визначає чіткий статус в логіці всієї системи, була змінена, і цю зміну не вдалось відслідкувати в повній мірі, через що зламалась деяка логіка в іншій частині системи (а тести не завалились), що дало нам знати про необхідність виконати додаткові кроки для повного переходу на нову логіку. З того моменту ми ввели тестові константи, щоб мати ще один етап контролю, і почали писати тести на відповідність тестових констант оригінальним, таким чином гарантуючи, що, якщо ми міняємо значення константи — це завжди буде під контролем, та буде давати нам хоч якийсь відсоток стабільності. Взагалі над цією темою ми ще працюємо, і, можливо, ще будуть якісь цікаві спостереження, якими я зможу в майбутньому ще поділитись. З приводу погано чи добре — тут немає конкретики, це просто рекомендація. У кожного свої підходи — у нас же підхід все що можна перевірити — краще перевірити, адже виявити проблему на ранньому етапі набагато краще, ніж коли вона вже буде в продакшені.
2. Суперечкам про те, чи доцільно тестувати приватні методи, вже багато років. І єдиної думки про це немає. Ми вирішили, що нам потрібно це тестувати, так як все, що може зламатись — обов’язково зламається, і ліпше його протестувати, ніж потім жалітись що не протестували. Така наша позиція.
3. В форматуванні коду. Останній варіант найбільш читабельний — в цьому і суть. Ми формуємо стиль написання коду, який дозволить людям швидше розуміти тест та, відповідно, менше часу витрачати на його підтримку.
4. Не зовсім зрозумів питання. Всі наші проекти написані на PHP 7.0 та 7.1

По поводу константы имхо, класс или интерфейс с константами, выделяется в отдельную библиотеку, подтягивается в зависимостях везде, где нужно значение, (даже в миграциях бд можно использовать) и все, проблемы нет. На счёт php-7 у вас в примерах кода нигде не встретились стрикт тайпы примитивов, поэтому подумал, что педалите на 5ом, + легче объявить аргумент string, тогда и assertTrue(is_string($value)) не понадобится.

assertTrue(is_string($value))

потрібен, так як при зміні результату методу тест повинен завалитись (тут я маю наувазі, якщо хтось руками змінить тип значення, що повертає функція). Взагалі чим більше ассертів над результатом можна виконати — тим краще, навіть якщо це типу «непотрібні» з першого погляду ассерти, — в майбутньому вони зіграють свою важливу роль.
Щодо php7, то ми використовуємо всі фічі цієї версії при необхідності. Можливо через погану табуляцію на сайті ви не помітили цього, але в кожному методі повертається конкретний результат там, де є конкретика. З приводу void, то там окрема тема, так як є певні моменти з symfony.

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

Цель юнит-тестирования — убедиться, что юнит возвращает ожидаемый результат при известных входных данных. У вас юнит — это класс, значит вход и выход определяется публичными методами — они и тестируются.

Тестирование приватных методов — это как обращение к приватным свойствам при живых геттерах.

Задайте себе вопрос: «мы пишем софт или тесты?»
rbcs-us.com/...​Unit-Testing-is-Waste.pdf

В ваших словах є сенс

Суперечкам про те, чи доцільно тестувати приватні методи, вже багато років. І єдиної думки про це немає

А можно тут поподробней, про отсутствие единой точки зрения. Есть какие-то авторитетные источники которые рекомендует тестирование приватных методов (напрямую)?
Как вы делаете рефакторинг, если тест завязан на реализацию?

Ми вирішили, що нам потрібно це тестувати, так як все, що може зламатись — обов’язково зламається

Почему вы считаете что тестирование публичного интерфейса — это не тестирование всего?

Познавательно, спасибо.

спасибо)
привет академистам Binary Studio!)

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

Подписаться на комментарии