В первой части я сделал краткий обзор теории. А теперь переходим к практике.

Для начала скажу о том, что должно быть установлено на сервере перед началом написания тестов. Версия Битрикс должна быть 14 или выше. Должен быть установлен Composer локально или глобально. Версия php желательно 5.6 иначе все перечисленные ниже инструменты надо подбирать индивидуально для имеющейся версии php.

Для создания тестов я использую следующие инструменты:

  • phpUnit - фреймворк, который не нуждается в представлении

  • Mockery - фреймворк для создания заглушек (stubs, mocks), с этой задачей может справиться и phpUnit, но Mockery делает этот процесс проще

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

Используем Composer для того, чтобы подтянуть последние стабильные версии. Не буду описывать этот процесс, т.к. считаю что это очевидно и любой php-разработчик должен знать как использовать Composer.

Первый файл, о котором я расскажу - конфигурационный файл phpUnit phpunit.xml.dist. Он не является обязательным, но позволяет упростить запуск phpUnit, т.к. нужные параметры будут указаны в конфиге. Файл следует поместить в корень проекта. Посмотреть все настройки конфига можно в документации. В данном случае конфиг нужен в первую очередь для указания пути до bootstrap-файла и, собственно, тестов.

<!-- Путь до bootstrap файла -->
<phpunit bootstrap="tests/bootstrap.php">
    <testsuites>
        <testsuite name="Test suit">
            <!-- Путь до папки, где будут храниться тесты -->
            <directory>tests/unit/</directory>
        </testsuite>
    </testsuites>
</phpunit>

Bootstrap-файл запускается перед тем как phpUnit запустит тест. Этим он позволяет инициализировать все, что потребуется для тестирования. В нашем случае это ядро Битрикс, автозагрузкчик Composer, подключение класса, расширяющего стандартный тест-класс phpUnit’а.

<?php
// Подключение ядра 1С-Битрикс
define ('NOT_CHECK_PERMISSIONS', true);
define ('NO_AGENT_CHECK', true);
$GLOBALS['DBType'] = 'mysql';
$_SERVER['DOCUMENT_ROOT'] = realpath(__DIR__ . '/..' );

require($_SERVER['DOCUMENT_ROOT'] . '/bitrix/modules/main/include/prolog_before.php');

// Искуственная авторизация в роли админа
$_SESSION['SESS_AUTH']['USER_ID'] = 1;
// Подключение автозаргрузки Composer
require_once $_SERVER['DOCUMENT_ROOT'] . '/vendor/autoload.php';

require_once 'BitrixTestCase.php';

Ниже представлен класс, расширяющий стандартный класс phpUnit для тестов. Все тесты должны наследоваться от этого класса. Очень важный момент заключается в том, что если не переопределить свойство $backupGlobals и присвоить ему значение false, то подключение ядра Битрикс будет приводить к фатальной ошибке.

<?php
/**
* Class BitrixTestCase
*/
class BitrixTestCase extends \PHPUnit_Framework_TestCase
{
    /**
     * @var bool
     */
    protected $backupGlobals = false;
    
    /**
     * @var \Generator
     */
    protected $faker;
    
    /**
     * этот метод phpUnit вызывает перед запуском текущего теста
     * @inheritdoc
     */
    public function setUp()
    {
        // создание экземпляра Faker, который будет создавать рандомные данные
        $this->faker = \Faker\Factory::create();
    }
    
    /**
     * этот метод phpUnit вызывает после исполнения текущего теста
     * @inheritdoc
     */
    public function tearDown()
    {
        // без этого вызова Mockery не будет работать
        \Mockery::close();
    }
}

Ниже я представил код упрощенного класса, который подвергнется тестированию. Это класс-репозиторий для получения данных о сущности книг, которые в свою очередь хранятся в инфоблоках Битрикс. Зависимость в виде экземпляра класса CIBlockElement передается через конструктор (Dependency Injection, о которой говорилось ранее). Для того, чтобы phpUnit распознал метод как тест, название метода должно начинаться с test. Тестировать будем метод getByAuthorId. Все максимально просто.

<?php
/**
* Class BooksRepository
*/
class BooksRepository
{
    /**
     * @var CIBlockElement
     */
    private $iblockElement;

    /**
     * @param CIBlockElement $iblockElement
     */
    public function __construct(\CIBlockElement $iblockElement)
    {
        $this->iblockElement = $iblockElement;
    }

    /**
     * @param $authorId
     * @return array
     */
    public function getByAuthorId($authorId)
    {
        if (! $authorId) {
            throw new \InvalidArgumentException('Author Id must be specified');
        }
        $selection = $this->iblockElement->GetList(
            [],
            [
                'PROPERTY_AUTHOR_ID' => $authorId,
                'IBLOCK_ID' => 1, // тут должен быть валидный идентификатор инфоблока
                'ACTIVE' => 'Y'
            ],
            false,
            false,
            ['NAME']
        );
        $books = [];
        while ($book = $selection->Fetch()) {
            $books[] = $book;
        }
        return $books;
    }
}

Пришло время рассмотреть пример теста. Тестирование произойдет для трех случаев: - Метод успешно отрабатывает и возвращает массив с данными одной записи. - Метод выбрасывает исключение из-за некорректного аргумента. - Метод успешно отрабатывает и возвращает пустой массив.

Все важные моменты я пометил комментариями в коде.

<?php
/**
* Class BooksRepositoryTest
*/
class BooksRepositoryTest extends BitrixTestCase
{
    /**
     * @var Mockery\MockInterface
     */
    protected $iblockElementMock;
    
    /**
     * @var Mockery\MockInterface
     */
    protected $iblockResultMock;
    
    /**
     * @var BooksRepository
     */
    protected $bookRepository;

    /**
     * @inheritdoc
     */
    public function setUp()
    {
        parent::setUp();
        // создание заглушки для класса CIBlockElement
        $this->iblockElementMock = \Mockery::mock('CIBlockElement');
        // создание заглушки для класса CIBlockResult
        // используется для имитации результата работы метода CIBlockElement::GetList
        $this->iblockResultMock = \Mockery::mock('CIBlockResult');
        $this->bookRepository = new \BooksRepository($this->iblockElementMock);
    }

    public function testGetByAuthorId()
    {
        // создание рандомных данных с помощью Faker
        $authorId = $this->faker->randomNumber;
        $bookData = ['NAME' => $this->faker->word];
        // описание поведение заглушки для CIBlockElement
        // должен быть вызван метод GetList
        // вызов производится только один раз
        // метод должен вернуть заглушку для CIBlockResult
        $this->iblockElementMock->shouldReceive('GetList')->once()
            ->andReturn($this->iblockResultMock);
            
        // описание поведение заглушки для CIBlockResult
        // должен быть вызван метод Fetch
        // вызов производится один раз или более
        // метод должен вернуть $bookData при первом вызове
        // метод должен вернуть пустой массив при втором вызове
        $this->iblockResultMock->shouldReceive('Fetch')->atLeast(1)
            ->andReturn($bookData, []);
        $expectedResult = [$bookData];
        $result = $this->bookRepository->getByAuthorId($authorId);
        
        // делаем предположение, что результат вызова BookRepository::getByAuthorId
        // соответствует ожидаемому
        $this->assertEquals($expectedResult, $result);
    }

    public function testGetByAuthorIdThrowsException()
    {
        // установка ожидаемого исключения и сообщения исключения
        $this->setExpectedException(
            'InvalidArgumentException',
            'Author Id must be specified'
        );
        // намеренно передается аргумент, который спровоцирует исключительную ситуацию
        $this->bookRepository->getByAuthorId(null);
    }

    public function testGetByAuthorIdReturnsEmptyArray()
    {
        // в этом тесте практически все аналогично первому
        $authorId = $this->faker->randomNumber;
        $this->iblockElementMock->shouldReceive('GetList')->once()
            ->andReturn($this->iblockResultMock);
        $this->iblockResultMock->shouldReceive('Fetch')->once()
            ->andReturn([]);
        $result = $this->bookRepository->getByAuthorId($authorId);
        // предположение, что результат вызова BookRepository::getByAuthorId
        // будет пустым
        $this->assertEmpty($result);
    }
}

А теперь запуск тестов. В простейшем случае следует запустить из корня проекта следующую команду:

php vendor/bin/phpunit

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

Итоги

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

Прикладываю список полезных ресурсов, которыми пользуюсь сам, для изучения модульного тестирования:

  • Мэт Зандстра. PHP. Объекты, шаблоны и методики программирования.
  • Roy Osherove. The Art of Unit Testing: with examples in C#.
  • Jeffrey Way. Laravel Testing Decoded.
  • Laracasts
  • tuts+

Refactoring to collections или как заменить foreach коллекциями

Этот пост будет целиком и полностью посвящен книге «Refactoring to collections», написанной Adam Wathan. Читать дальше