Unit-тестирование в PHP. Часть 3: углубление в PHPUnit
версия для печатиПродолжение статьи о модульных тестах в PHP. В этой части рассмотрим поставщики данных, фикстуры, подмену зависимостей, тесты с виртуальной файловой системой, тесты исключений и взаимодействия с базой данных.
Содержание
Статья получилась огромная, потому разделена на 3 части. Для удобства содержание продублированно в каждой части, переходы на текущей странице выделены жирным шрифтом.
- Введение
- Теория
- Практика
- PHPUnit
Поставщики данных (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 проксирует все вызовы всех методов и позволяет их налету подменить. Я попробовал - это действительно круто. Но в итоге с моим движком он работать не смог, конфликтнул где-то. Т.о. возьмите на вооружение, если сможете подружить его с вашим проектом.
Установка
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-метода, только с прерыванием. Есть так же методы на проверку кода и сообщения исключения.
Почему это важно: нельзя в одном тест-методе проверить нормальное поведение и проброс исключения. Т.е. какая-то из ситуаций не выполнится, тест будет провален.
Выходы:
- забить на проверку исключений в принципе;
- писать тест-методы на нормальное поведение и на каждое пробрасываемое исключение отдельно;
- в тест-методе оформлять блоки try...catch;
- использовать 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 нужно добавить модуль:
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
Понравилась статья? Расскажите о ней друзьям: