Unit-тестирование в PHP. Часть 2: знакомство с PHPUnit

версия для печати

Продолжение статьи о модульных тестах в PHP. В этой части займемся практикой тестирования и разберемся с базовым использованием PHPUnit.

Содержание

Статья получилась огромная, потому разделена на 3 части. Для удобства содержание продублированно в каждой части, переходы на текущей странице выделены жирным шрифтом.

Исходники с примерами тестов

Практика

Тестирование средствами PHP

Тестировать можно обычным PHP кодом, не используя дополнительные инструменты. Писать if...else, в таком духе, проверять результат выполнения метода. В PHP так же есть функция assert(), чье поведение настраивается через assert_options(). Ее тоже можно использовать для написания тестов на нативном PHP, хотя честно я не понял на примерах аля "Hello, world", как бы это реально выглядело.

Приведу пример из книги, переведенный на PHP. Допустим, у нас есть класс SimpleParser (см. ниже), и мы хотим его протестировать. В классе есть метод ParseAndSum, который принимает строку, состоящую из нуля или более чисел, разделенных запятыми. Если чисел в строке нет, метод возвращает 0. Если есть только одно число, оно возвращается в виде int. Если чисел несколько, они складываются и возвращается сумма (хотя в настоящий момент код умеет обрабатывать только случаи нуля или одного числа).

// файл SimpleParser.php

public class SimpleParser
{
    public function ParseAndSum(string $numbers):int
    {
        if (strlen($numbers) == 0) {
            return 0;
        }

        if (false === strpos($numbers, ',')) {
            return intval($numbers);
        }

        throw new InvalidArgumentException('Пока умею обрабатывать 0 или одно число');
    }
}

Тест напишем в отдельном скрипте, для вызова в консоли. Выглядит он так:

require 'SimpleParser.php';

try
{
    $parser = new SimpleParser;
    $result = parser->ParseAndSum('');
    if($result != 0) {
        echo 'Ошибка. ParseAndSum должен вернуть 0 для пустой строки' . PHP_EOL;
    }
} catch (InvalidArgumentException $e) {
    echo $e->getMessage();
}

Это тест всего одного возможного результата метода. Как я говорил выше, нужно писать тесты на проверку всех принципиально разных результатов. Тут не делаю, надеюсь, идея понятна.

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

Фреймворки для unit-тестов

Я рекомендую PHPUnit. Признаю, я с другими не работал, но конкретно с этим разобрался быстро и он почти полностью обеспечил мои требования к unit-тестированию. Этот фреймворк можно расширять установкой дополнительных модулей, сейчас это не составляет трудностей, Composer в помощь. Какие именно модули - я расскажу ниже.

PHPUnit - это просто удобный инструмент для тестирования по сравнении с написанием кода с нуля. Но на нем свет клином не сошелся, есть еще масса других тестовых фреймворков. Для интересующихся тут статья на английском. В тройке лидеров: PHPUnit, Codeception и Behat.

Для информации: PHPUnit назван подобно тестовым движкам для других языков - JUnit (Java), NUnit (.NET), CUnit (C).

Тестирование непубличных методов в PHP

Если есть необходимость протестировать приватный метод, вероятно у вас проблема в архитектуре приложения. Однако, бывают исключения. Опять пример сборщика статики: там нет проблем в архитектуре (ну или я так думаю :)), есть всего один рабочий метод, который смотрит наружу, все остальные - непубличные, их много и все их надо протестировать. Но как?

Решений тут несколько. Самое очевидное - объявить все тестируемые методы публичными. Это нехорошо, нарушение принципа инкапсуляции (сокрытие деталей реализации).

Объявить их protected методами, потом от них унаследовать спец.классы с публичными методами-обертками, которые в свою очередь будут вызывать эти protected-методы. И тестировать при этом методы-обертки в промежуточных классах. Мне это решение не нравится, потому что это еще больше кода, какие-то загадочные костыли и вообще лишняя унылая писанина.

Третий варик: использовать рефлексию, она доступна в современных версиях PHP. В процессе теста берем класс, меняем проверямому методу доступ на публичный и вызываем его. Этот вариант мне нравится. Если разработчик смотрит исходный код, он перед ним правильная видимость каждого метода, и не возникает вопросов, что можно использовать и где. Если он смотрит в тесты, максимум, что ему нужно разобрать, это вызов непубличного метода, все остальное опять же остается понятным и не вызывает лишних вопросов.

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

Моя реализация:

trait CallAsPublic
{
    /**
     * Вызов непубличного метода класса используя рефлексию класса
     *
     * Эта функция вернет то, что должен вернуть вызываемый метод.
     *
     * @param mixed  $class      FQN класса, в котором находится вызываемый метод ИЛИ объект этого класса
     * @param string $methodName имя вызываемого метода
     * @param array  ...$args    параметры для передачи в метод
     * @return mixed
     */
    protected function callMethod($class, string $methodName, ...$args)
    {
        $method = new \ReflectionMethod($class, $methodName);
        $method->setAccessible(true);
        $classObject = $method->isStatic() ? null : (is_object($class) ? $class : new $class);
        return $method->invoke($classObject, ...$args);
    }
}

Пример использования (PHPUnit):

class SomeTest extends TestCase
{
    use CallAsPublic;

    public function test_SomeAction() {
        $result = $this->callMethod(SUTClass::class, 'someAction', $param1, $param2 ... $paramN);
        $this->assert...
    }
}

SUTClass - тестируемый класс
someAction - непубличный метод в нем
$param1 ... $paramN - параметры метода, если ему нужны параметры.

Недостаток: этот трейт не может работать с методами, которые принимают параметры по ссылке. Я не придумал красивого решения. Придется все-таки открывать такие методы в публичную видимость и ставить тег @ignore или @internal. Желающим попробовать найти решение могу предоставить свои наработки.

PHPUnit

Важно: PHPUnit развивается, причем без обратной соместимости. Сегодня (21.04.2017) - это версия 6.1. Приведенные тут примеры кода могут уже не соответствовать текущей версии фреймворка. Тогда вам придется лезть в документацию, чтобы найти разницу.

Я предполагаю, что вы умеете пользоваться Composer и он есть в вашей системе.

Установка PHPUnit:

// сам тестовый фреймворк
composer require --dev phpunit/phpunit

// расширение для тестов с базой данных
composer require --dev phpunit/dbunit

Обратите внимание, зависимости устанавливаем в dev-окружение, потому что на боевом сайте unit-тесты, как правило, не выполняют.

Для дальнейшей работы создадим минимальную видимость сайта. Исходники в архиве. После скачивания, запустите в каталоге сайта composer install.

Иерархия каталогов примерно выглядит так:

/www/site/
    src/
        ...
    tests/
        dummy_examples/
            ... [нерабочие примеры] ...
        unit/
            ... [рабочие примеры] ...
        bootstrap.php
        config.xml
    vendor/
        ...
    composer.json
    index.php

Что стоит пояснения: корень сайта - какой угодно, можно даже домен не привязывать, сейчас это не важно. bootstrap.php - это файл инициализации специально для тестов, расскажу о нем позже; config.xml - конфиг PHPUnit, о нем так же расскажу чуть ниже.

Тесты лежат в каталоге [tests/]. В подкаталоге [unit/] - примеры, которые можно выполнить и увидеть в живую, как они отработают. В [dummy_examples/] - примеры оформления тестов, к ним будут отсылки ниже по ходу изложения. Такие примеры выполнить не получится, потому что для них нет реального кода. Я не стал его переносить, т.к. он сильно завязан на проекты, откуда взяты тесты.

Важно: тесты в каталоге [dummy_examples/] взяты из реальных проектов, поэтому они могут оказаться сложными для понимания, если вы новичок в модульном тестировании. В них разбираться необязательно, вы можете игнорировать отсылки в этот каталог.

Существующее соглашение (которое в принципе можно настроить): имена тест-классов заканчиваются на слово Test, тест-методы содержат слово test. Как именно называть методы - дело ваше. Для удобства используют те же имена, что у проверяемых методов, в случае когда боевому методу соответствует всего один тест-метод.

Простой тест в PHPUnit

Я уже привел пример теста ранее (см. Тестирование средствами PHP), там он выполнен без использования фреймворков. В PHPUnit тот же тест выглядит так:

// см. tests/unit/SimpleParserTest.php

use PHPUnit\Framework\TestCase;

class SimpleParserTest extends TestCase
{
    public function test_ParseAndSum()
    {
        // Arrange
        $num = 12;

        // Act
        $result = SimpleParser::ParseAndSum($num);

        // Assert
        $this->assertEquals(12, $result, 'Ошибка вычисления суммы одного числа');
    }
}

Правило трех "A" - Arrange Act Assert (Подготовка Действие Утверждение) - можно рассматривать, как общую рекомендацию к описанию тест-метода. И эта рекомендация будет работать, если боевые методы пишутся с оглядкой на их тестирование. Вспомните, ранее была речь про подмену зависимостей и чистые функции.

Хороший пример см. в [tests/dummy_examples/ProductValidatorTest.php]. Бегло взгляните на метод test_validateAttributes(). Необязательно понимать, зачем все это, важно увидеть суть оформления Arrange Act Assert. Там еще подмена зависимости в блоке Act, о ней расскажу ниже.

В PHPUnit есть группа методов-оберток для составления утвержденией assert*(). Все они составлены по одному принципу, с полным списком можно ознакомиться в мануале.

Новая проблема - как запустить такой тест на выполнение? Об этом несколько следующих разделов.

Минимальная настройка тестовой среды

Запуск без конфигурации и bootstrap (выполняем в консоли):

cd /www/site
php vendor/bin/phpunit $(pwd)/tests/unit/SimpleParserTest.php

Инструкция $(pwd) работает только в Linux. Используется для удобства, для PhpUnit требуется указывать абсолютный путь к файлу теста. Можно заменить эту инструкцию на хардкодный путь [/www/site/].

Чтобы такой запуск работал, придется прямо в тест-классе подключить autoload.php для автозагрузки классов через Composer. Любопытно, но как-то автозагрузчик Composer работает, даже если нигде явно его не подключить. Возможно он в phpunit подключается.

Расширенная настройка тестовой среды

bootstrap.php

В архиве исходников откройте [tests/bootstrap.php] и [/index.php] для сравнения.

Файл [tests/bootstrap.php] - это файл инициализации тестов. Он вызывается перед выполнением любого теста, если вы попросите это делать. Имя файла может быть другим, "bootsrtap" - это общепринятое название. В этот файл обычно пишут почти тоже самое, что вашем главном index.php или его аналоге. Т.е. код, который выполняется первым на вашем сайте, можно разместить в этом файле (с некоторыми изменениями).

Необязательно полностью копировать в bootstrap.php какой-то скрипт боевой части сайта. Только то, без чего ваши тесты работать не смогут. Например, если у вас используются какие-то глобальные константы (описанные в index.php), вероятно они могут потребоваться в тестах. Пишем их в bootstrap.php. Подключения сторонних библиотек, настройки даты/времени, кодировка - короче все то, что обычно требуется для любой страницы вашего сайта - можно продублировать в bootstrap.php.

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

Запуск теста с использованием скрипта инициализации:

cd /www/site
php vendor/bin/phpunit --bootstrap $(pwd)/tests/bootstrap.php \
$(pwd)/tests/unit/SimpleParserTest.php

Конкретно в текущем простом примере мы могли бы даже напрямую подключать index.php и не описывать отдельно bootstrap.php. Но на практике все-таки в них есть некоторая разница, поэтому так не делают.

config.xml

Мануал по описанию конфигурации.

Файл [tests/config.xml] - это конфигурация фреймфорка PHPUnit. Имя файла может быть любым. Как вы уже видели выше, тесты прекрасно работают и без конфигурации. Данный файл позволяет более гибко управлять процессом тестирования. На примере:

<phpunit bootstrap="bootstrap.php" colors="true" verbose="true">
    <testsuites>
        <testsuite name="All unit tests">
            <directory suffix="Test.php">unit</directory>
        </testsuite>
    </testsuites>
</phpunit>

В приведенном примере конфигурации: указан файл инициализации, его уже не нужно передавать в ключе --bootstrap; значение colors="true" включает цветную выдачу лога тестирования в консоли; verbose ограничивает подробности выдаваемой информации в консоль.

Элемент testsuite описывает конкретный набор тестов. У него больше необязательных параметров, чем в примере, подробности см. в мануале. Я обычно использую этот элемент для подключения тестов из разных каталогов. Так же можно вызвать конкретный набор тестов, используя ключ --testsuite <pattern>.

Мануал по ключам вызова.

Пример вызова с конфигом:

cd /www/site
php vendor/bin/phpunit --configuration $(pwd)/tests/config.xml \
$(pwd)/tests/unit/SimpleParserTest.php

Можно описывать сколько угодно конфигураций и подключать их по ситуации.

Интеграция PHPUnit в PhpStrom

Так подключается тестовый движок к проекту

подключение PHPUnit в PHPStorm

Обратите внимание, установив PHPUnit через Composer, указывать нужно путь к автозагрузчику, а не к самому PHPUnit.

Добавляем тестовую конфигурацию

добавление тестовой кофигурации

Описываем детали конфигурации

детали конфигурации

Обратите внимание, какую строку вызова собирает IDE:

выполнение теста в IDE

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

Собственно, эта вся настройка. PHPStorm позволяет через свои описания конфигов тестировать: каталог, класс, метод, использовать альтернативные конфигурации.

[1oo%, EoF]
Остальные части:
Часть 1: теория
Часть 3: углубление в PHPUnit

Понравилась статья? Расскажите о ней друзьям:

Метки: PHP, кодинг


Комментарии
Для работы модуля комментариев включите javaScript


Показать/скрыть правила
Имя
[i] [b] [u] [s] [url]
:-) ;-) :D *lol* 8-) :-* :-| :-( *cry* :o :-? *unsure* *oops* :-x *shocked* *zzz* :P *evil*

Осталось 1000 символов.
Код защиты от спама Обновить код
Каждый комментарий проходит ручную модерацию. 100% фильтрация спама.
Продвижение
Время
Метки
Щелкни мышей, чтобы закрыть