Unit-тестирование в PHP. Часть 3: углубление в PHPUnit

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

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

Содержание

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

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

Поставщики данных (data providers)

Возьмем пример сложнее (см. полную версию в [src/Strings.php] в архиве с исходниками). Приведенный ниже метод возвращает фразу в правильном склонении в зависимости от числа, идущего с текстом:

// src/Strings.php

/**
 * Склонение слов в зависимости от числа
 * @param int   $n     число
 * @param array $s     набор слов
 * @param bool  $glued объединить результат с числом? Объединение будет через пробел
 * @return string
 */
public static function declination($n, array $s, $glued = true)
{
    $n = $n % 100;
    $ln = $n % 10;
    $phrase = $s[(($n < 10 || $n > 20) && $ln >= 1 && $ln <= 4) ? (($ln == 1) ? 0 : 1) : 2];
    return $glued ? $n . ' ' . $phrase : $phrase;
}

Тестовый метод (см. [tests/unit/StringsTest.php]):

// tests/StringsTest.php

/**
 * Тест: склонение слов в зависимости от числа
 *
 * @dataProvider DeclinationProvider
 * @param int    $num    число
 * @param string $expect ожидаемый результат
 */
public function test_declination(int $num, string $expect)
{
    $words = ['комментарий', 'комментария', 'комментариев'];
    $result = Strings::declination($num, $words);
    $this->assertEquals($expect, $result, 'Неверное склонение');
}

/**
 * Данные: склонение слов в зависимости от числа
 * @return array
 */
public function DeclinationProvider()
{
    return [
        [1, '1 комментарий'],
        [3, '3 комментария'],
        [7, '7 комментариев'],
    ];
}

Data providers - это фишка PHPUnit, возможно есть аналоги в других фреймворках. К любому тест-методу можно присоединить поставщика данных через тег @dataProvider <methodName>. Требования в методу-поставщику: он должен возвращать двумерный массив значений. Каждый подмассив - это набор данных на один тест-случай. Подмассив должен содержать элементы в том же количестве и порядке, как как это требуется в параметрах тест-метода.

PHPUnit не требует использовать ассоциативные массивы, но для удобства чтения можно задавать ключи любому из них.

Если в вашем поставщике данных возникнет исключение, тогда PhpUnit закончит тестирование с сообщением "No tests executed!", что вообще ни о чем не говорит. Включайте отладчик и ищите, что у вас не так в DataProvider.

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

Прим.: в том же тестовом классе есть пример, как бы выглядел тест без использования DataProvider.

Еще один пример организации DataProvider см. в [tests/dummy_examples/ProductValidatorTest.php], метод test_checkAttrValueWithType().

Важно: во время выполнения теста сначала вызывается поставщик данных, потом setUpBeforeClass() и setUp() (о них в следующем разделе). Т.е. в dataProvider() нельзя полагаться на данные, которые могут быть заданы в setUpBeforeClass() | setup() тестового метода.

Фикстуры

Одной и наиболее времязатратных частей написания тестов является установка "мира" приложения в известное состоянии и откат к этому состоянию после теста. Это известное состояние называется фикстурой теста. (перевод из мануала PHPUnit).

PHPUnit позволяет организовывать тестовое окружение в отдельно взятом классе, а так же для каждого выполняемого теста в классе. Для этого есть группа методов, о которых подробно расписано в мануале PHPUnit: 4. Fixtures. Кратко расскажу.

Приведенные ниже методы (если они вам нужны для тестов) нужно реализовывать прямо в ваших тестовых классах:

  • сначала вызов всех методов поставщиков данных
  • setUpBeforeClass() статичный метод, выполняется на этапе создания тест-класса. Аналогичный ему статический метод tearDownAfterClass() выполняется после всех тестов
  • Динамический метод setUp() выполняется перед каждым тестом, tearDown() после каждого теста
  • Динамический метод assertPreConditions() выполняется перед первым утверждением в каждом тесте. assertPostConditions() выполняется, если тест успешно завершился.

Прим: найдите в мануале пример Example 4.2 и результат его выполнения. Этот пример показывает последовательность вызова всех методов фреймворка для установки фикстур.

Как использовать эти методы - решать вам. Идея простая: все, что можно инициализировать для всех/каждого тест-метода - выносят в методы setUpBeforeClass() и setUp() соответственно. Если после теста требуется уборка (например, очистка кеша), вызываются соответствующие методы отката.

Примеры использования в архиве исходников можно посмотреть в [tests/unit/FSTest.php] и [tests/dummy_examples/ProductValidatorTest.php]. Содержимое этих методов может быть пока непонятно, но можно уловить мысль, что в них писать. Коротко: что-угодно общее для всех тест-методов.

Еще такой интересный момент: в unit-тестах можно принебречь оптимизацией или производительностью в пользу контролируемого поведения. Поэтому часто, вместо однократной инициализации окружения для всего класса через метод setUpBeforeClass() используют setUp() для создания известной среды для каждого тест-метода.

Важно, повторю еще раз: во время выполнения теста поставщики данных (dataProvider) инициализируются даже раньше, чем setUpBeforeClass(). Т.е. в dataProvider() нельзя полагаться на данные, которые могут быть заданы в методах настройки тестового класса.

Подмена зависимостей

Подмена средствами PHPUnit

Тут мне трудно было выбрать, что рассказывать в статье, а за чем отправить в мануал PHPUnit: 9. Test Doubles. Там всего одна страница, и на примерах изложено вполне доступно. Я не хочу переписывать эту часть из документациии. Но все же скажу пару слов.

Как делается подмена зависимостей в PHPUnit 6.1. Напомню пример из теории (прим.: там класс назывался SomeClass):

// в исходниках: src/ClassDI.php

class ClassDI
{
    /**
     * подключение к БД
     * @var IDatabase $db
     */
    private $db;

    /**
     * Внедрение зависимости в класс
     * @param IDatabase $db подключение к БД
     */
    public function __construct(IDatabase $db)
    {
        $this->$db = $db;
    }

    /**
     * Какой-то боевой метод
     *
     * Внедрение еще одной зависимости, прямо в метод
     *
     * @param ILogger $logger объект логера
     * @return int
     */
    public function doIt(ILogger $logger):int
    {
        // тут только чистый код, без инициализации зависимых систем

        $id = $this->db->query('INSERT ...');
        $logger->add('Создана новая запись #' . $id);
        return $id;
    }
}

Тест с подменой зависимостей:

// в исходниках: tests/dummy_examples/ClassDITest.php

public function test_doIt()
{
    // Создаем поддельный объект зависимого класса
    $dbStub = $this->createMock(IDatabase::class);

    // Описываем ожидаемое поведение поддельного метода
    $dbStub->method('query')
        ->willReturn(10);

    // Подделываем другую зависимость, сразу указываем, какой метод подменяем.
    $loggerStub = $this
        ->getMockBuilder(ILogger::class)
        ->setMethods(['add'])
        ->getMock();

    $id = (new ClassDI($dbStub))->doIt($loggerStub);

    $this->assertEquals(10, $id);
}

Собственно все.

Как видно из кода выше, для подделки использовали разные методы. Дело в том, что метод createMock() - это обертка. На самом деле, в нем выполняется цепочка методов PHPUnit:

$stub = $this
    ->getMockBuilder($originalClassName)
    ->disableOriginalConstructor()
    ->disableOriginalClone()
    ->disableArgumentCloning()
    ->disallowMockingUnknownTypes()
    ->getMock();

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

Подделки могут возвращать не только скалярные значения. У PHPUnit есть методы на все возможные случаи подмены.

Еще один пример подделки зависимости [tests/dummy_examples/ProductValidatorTest.php], метод test_validateAttributes(), блок кода Act.

В тест-методах можно строить утверждения относительно подмененного объекта (понятие mock помните?). И вот тут еще большее поле для деятельности. У меня нет простого, но сколь-нибудь полезного примера, поэтому лучше смотрите примеры в мануале PHPUnit. Mock Objects, если у вас возникнет такая необходимость. Как было отмечено в теоретической части статьи, проверка взаимодействий - это самый крайний случай в модульном тестировании, когда иначе никак проверить метод не получается.

PHPUnit позволяет проверить, что подменяемый метод вызван ожидаемое количество раз, с определенными аргументами или их последовательностью (если вызовов несколько). Так же можно описать условия, при которых ожидаются определенные аргументы. Я сильно в эту тему не вдавался, обычно хватает тестирования в первых двух направлениях (результат или состояние).

Подмена с использованием Mockery

Документация

Mockery это PHP-фреймворк, запиленный специально для подмены объектов в unit-тестах. Он разработан, как альтернатива библиотеке подмены в PHPUnit, может использоваться в нем или как отдельный модуль, т.е. его можно подключить в другие тестовые движки. Основная фича - использование человекопонятного предметно-ориентированного языка (Domain-specific language, DSL).

Простым примером предметно-ориентированного языка является SQL для СУБД.

Приведенный выше тест (см. Подмена средствами PHPUnit) станет таким:

// tests/dummy_examples/ClassDIMockeryTest.php

use \Mockery;

class ClassDIMockeryTest extends TestCase
{
    public function test_doIt()
    {
        $dbStub = Mockery::mock(IDatabase::class)
            ->shouldReceive('query')
            ->times(1)
            ->andReturn(10);

        $loggerStub = Mockery::mock(ILogger::class)
            ->shouldReceive('add')
            ->times(1);

        $id = (new ClassDI($dbStub))->doIt($loggerStub);

        $this->assertEquals(10, $id);
    }
}

По ему мнению, в текущей версии PHPUnit удобная библиотека по созданию фейков и с этим фреймворком не требуется сторонний модуль подмены.

AspectMock

AspectMock на GitHub

AspectMock проксирует все вызовы всех методов и позволяет их налету подменить. Я попробовал - это действительно круто. Но в итоге с моим движком он работать не смог, конфликтнул где-то. Т.о. возьмите на вооружение, если сможете подружить его с вашим проектом.

Установка

composer require --dev codeception/aspect-mock

Настройка. В bootstrap.php тестов прописываем типа этого (в исходниках см. [tests/bootstrap.php]):

$kernel = \AspectMock\Kernel::getInstance();
$kernel->init([
    'debug' => true,
    'includePaths' => [__DIR__ . '/../src'],
    'excludePaths' => [__DIR__ . '/../tests/'],
    'cacheDir'     => __DIR__ . '/../temp/aspectMock/',
]);

Пример все того же теста, но теперь с подменой через AspectMock:

// tests/dummy_examples/ClassDIAspectTest.php

use AspectMock\Test as test;

class UserTest extends \PHPUnit_Framework_TestCase
{
    public function test_doIt()
    {
        $dbStub = test::double(IDatabase::class, ['query' => 10]);
        $loggerStub = test::double(ILogger::class, ['add' => null]);

        $id = (new ClassDI($dbStub))->doIt($loggerStub);

        $this->assertEquals(10, $id);

        //$dbStub->verifyInvokedOnce('query'); // Пример тестирования состояния
    }

    protected function tearDown()
    {
        test::clean(); // откат AspectMock
    }
}

"Killer feature" AspectMock именно в возможности подменить вообще любой класс или метод, независимо от того, был ли он как-либо внедрен или явно вызывается прямо в функции. Может оказаться полезно, если код изначально не был создан с оглядкой на тесты и представляет собой залипуху и кучки байт.

Тестирование с виртуальной файловой системой

Проблема: есть метод, взаимодействующий с реальной файловой системой. Нужно протестировать именно само взаимодействие, но при этом подменить ФС на виртуальную.

Ставим vfsStream и пользуемся :)

composer require --dev mikey179/vfsStream

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

use PHPUnit\Framework\TestCase;
use org\bovigo\vfs\vfsStream;

class VFSSimpleTest extends TestCase
{
    public function test_buildFile()
    {
        // Описание структуры виртуальной ФС
        $structure = [
            'one.js' => "var i = 1;\n//# sourceURL=one.js",
            'two.js' => 'var j = 2;',
        ];

        // Создание ФС с корнем "/www"
        $stream = vfsStream::setup('www', null, $structure);

        // Получим путь к корню виртуальной ФС
        $path = $stream->url() . '/';

        $files = [
            $path . 'one.js',
            $path . 'two.js',
        ];

        // Предположим, метод пришет содержимое всех $files[] в один файл.
        SomeClass::combine($path . 'combine.js', $files);

        // Читаем, что на самом деле собрал тестируемый метод
        $content = $stream->getChild('www/combine.js')->getContent();

        $expect = '... результат работы ...';
        $this->assertEquals($expect, $content, 'Собранный файл не соответствует ожиданиям');
    }
}

Более расширенный пример с кучей вариантов использования виртуальной ФС см. в архиве исходников [/tests/unit/FSTest.php]

Известные проблемы:

  • vfsStream вообще не любит слеши. Например, создание каталога с концевым слешем или без него - это два разных набора в vfsStream::getChildren(). За их использованием нужно внимательно следить во избежание проблем в тестовых утверждениях.
  • с виртуальной ФС не работает функция PHP glob(). Описание проблемы и вариант решения тут. Коротко: нужно придумать свою функцию, выполняющую ту же работу, что и glob() и использовать ее во всех местах, где возможно обращение к виртуальной ФС. Мое решение основано на PHP::DirectoryIterator, его можно увидеть в FS::dirList().

Тестирование исключений

Основная статья PHPUnit. Тестирование исключений.

В PHPUnit метод expectException(), а так же директива @expectedException используются в тестах для указания ядру фреймворка "ожидать такое-то исключение". В итоге тест считается пройденным если исключение возникло.

И тут есть ньюансик: после того, как PHPUnit поймает ожидаемое исключение, выполнение тест-метода прекратится! Т.е. expectException() - это аналог assert-метода, только с прерыванием. Есть так же методы на проверку кода и сообщения исключения.

Почему это важно: нельзя в одном тест-методе проверить нормальное поведение и проброс исключения. Т.е. какая-то из ситуаций не выполнится, тест будет провален.

Выходы:

  1. забить на проверку исключений в принципе;
  2. писать тест-методы на нормальное поведение и на каждое пробрасываемое исключение отдельно;
  3. в тест-методе оформлять блоки try...catch;
  4. использовать data provider.

Последний вариант мне представляется наиболее предпочтительным. Причем data provider позволяет описать вообще все тест-кейсы в одном методе.

См. скрипт архива исходников [tests/dummy_examples/ExceptionsTest.php]. Пример ExceptionsTest::test_normalizePriority() демонстрирует решение с data provider. Второй метод, test_getTargetFileName() для демонстрации исключения в одном методе вместе с нормальными ситуациями.

Пример отдельного теста только на проброс исключения см. в [unit_simple/tests/FSTest.php] метод test_fuse_removeDir(). Исключение ожидается всего одно, data provider там не нужен. Но чтобы создать исключительную ситуацию в этом методе, требуется большая подготовка, поэтому тест-метод оформлен отдельно.

Тестирование запросов в базу данных

Раздел мануала PHPUnit

Тестирование взаимодействия с БД - это скорее интеграционный тест, но все же стоит один раз напрячься и сделать. Это несложно. Хотя зависит от проекта, я не настаиваю :)

В мануле много всего расписано по этому вопросу, но я особо не занимался тестированием именно взаимодействия с БД. На практике написал несколько простых тестов с маленькой базой.

В общих чертах: вам нужна будет еще одна база данных помимо боевой, структура должна соответствовать рабочей БД. Если используете миграции, поддержка актуальной структуры - не проблема. В PHPUnit нужно добавить модуль:

composer require --dev phpunit/dbunit

Далее см. в архиве исходников [tests/dummy_examples/DBEmptyTest.php].

В тестовый класс нужно подключить трейт TestCaseTrait (до версии PHPUnit 6.1 был суперкласс для наследования, теперь трейт) и реализовать два абстрактных метода: подключение к базе и загрузка данных (фикстур) в таблицы.

// tests/dummy_examples/DBEmptyTest.php

use PHPUnit\Framework\TestCase;
use PHPUnit\DbUnit\TestCaseTrait;
use PHPUnit\DbUnit\DataSet\YamlDataSet;

class MyGuestbookTest extends TestCase
{
    use TestCaseTrait;

    /**
     * Соединение с тестовой базой
     * @return \PHPUnit\DbUnit\Database\DefaultConnection
     */
    public function getConnection()
    {
        $pdo = new PDO('sqlite::memory:');
        return $this->createDefaultDBConnection($pdo, ':memory:');
    }

    /**
     * Загрузка данных в таблицы
     * @return YamlDataSet
     */
    public function getDataSet()
    {
        return new YamlDataSet(__DIR__ . '/fixtures/dataset1.yml');
    }
}

Прим: реализация подключения к базе зависит от конкретного приложения. Методы getConnection() и getDataSet() выполняются перед каждым тест-методом.

Как это работает: PHPUnit подключается к базе, очищает таблицы и грузит в них данные, которые вы укажете. Ваш проверяемый класс должен уметь подключаться к тестовой БД. Далее обычная процедура тестирования - Arrange Act Assert. В конце - откат через tearDown(), если требуется.

PHPUnit предоставляет кучу форматов для загрузки данных: несколько XML форматов, YAML, CSV, arrays и какие-то велосипеды. Имхо, удобнее всего YAML.

См. в архиве исходников пример подготовленных данных - [tests/dummy_examples/fixtures/*.yml], тест в [tests/dummy_examples/LabelModelTest.php]

По моему мнению, этот модуль в PHPUnit еще сырой: слишком много возможностей, документация раскрывает не все, что есть. Видимо автор еще не определился.

[1oo%, EoF]
Остальные части:
Часть 1: теория
Часть 2: знакомство с PHPUnit

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

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

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


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

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