Часть 7: PHPUnit (Тестирование ПО)
Оглавление](/циклы/тестирование-по)
Продолжаем цикл статей по разработке веб-приложений с использованием методологии TDD.
Ранее утверждалось, что для понимания того, как функционирует тот или иной фреймворк или технология нужно сначала попытаться реализовать похожий функционал самостоятельно. И только затем пытаться использовать уже существующие наработки.
В предыдущей части мы попытались создать собственный минималистичный код, который осуществляет тестирование проекта. Если продолжать и дальше, то в конечном счете можно довести имеющиеся наработки до вида, годного для использования в маленьких или не очень проектах. Но так делать не стоит ибо современная индустрия разработки требует высокой скорости создания продуктов и высокого их качества. Тратить усилия на поддержание уже не раз придуманного и реализованного, но своего - это не совсем хорошая идея. Поэтому в этой главе мы познакомимся с PHPUnit и научимся правильно его применять вместе с yii.
Это немного удивительно, но предыдущие наработки можно легко использовать лишь изменив родительский класс для UserTest с \common\tests\TestCase на \PHPUnit\Framework\TestCase и несколько изменив порядок запуска тестов.
namespace common\tests\unit;
use common\models\User;
use \PHPUnit\Framework\TestCase;
class UserTest extends TestCase
{
// код без изменений
}
Помимо изменения родительского класса мы еще и убрали подключение файла _bootstrap.php. Запуск тестов будет выглядеть так, как показано ниже.
composer exec -v -- "phpunit --bootstrap common/tests/_bootstrap.php common/tests/unit"
Примечание : кавычки нужны из-за неправильной обработки передаваемых в команду аргументов. Эта ошибка уже исправлена, но ее портирование в composer будет выполнено только после того как разработчики проекта откажутся от поддержки версий php ниже 5.5.
Здесь мы вызываем исполняемый скрипт phpunit, который расположен в каталоге vendor/bin и передаем ему несколько аргументов.
- –bootstrap common/tests/_bootstrap.php - указывает, что перед запуском тестов фреймворк обязан запустить файл, переданный как часть опции. В нашем случае он отвечает за инициализацию окружения так же, как и в случае с unitSuite.php.
- common/tests/unit - этот аргумент обозначает каталог, в котором phpunit будет искать тесты
Результат выполнения это восемь точек, каждая из которых обозначает успешно выполненный тест. Если тест не выполнен, то вместо точки мы увидим букву F, а ниже будет следовать расшифровка, в которой написано, какой тест упал (во втором примере поломан первый тест).
[gallery ids=”2158,2157” type=”rectangular”]
Конечно же каждый раз писать подобную командную строку не очень удобно. И PHPUnit предусмотрена возможность конфигурирования. Один из этих способов - использование xml-файла настроек, с которым мы работали в рамках предыдущей лекции. О средствах интеграции phpunit и yii, которые существую мы поговорим на одной из следующих лекций. Создадим файл настроек phpunit.xml и разместим его в каталоге с конфигурацией тестового окружения environments/dev/. После этого нужно выполнить провизию машины или скопировать файл phpunit.xml в корень проекта (как описывалось ранее).
phpunit.xml
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="http://schema.phpunit.de/5.7/phpunit.xsd" bootstrap="common/tests/_bootstrap.php">
<testsuites>
<testsuite name="Core functionality">
<directory>common/tests/unit</directory>
</testsuite>
</testsuites>
<filter>
<whitelist processUncoveredFilesFromWhitelist="true">
<directory suffix=".php">models</directory>
</whitelist>
</filter>
</phpunit>
Файл конфигурации - это xml файл, который содержит в себе несколько секций, описывающих определенные аспекты тестирования. Атрибуты тега phpunit xmlns:xsi и xsi:noNamespaceSchemaLocation являются обязательными. Все остальные - это настройки. Самая важная настройка - bootstrap. Она показывает, что за файл нужно выполнить перед запуском тестов. Это тот самый _bootstrap.php, который мы писали выше.
Внутри phpunit вкладываются элементы testsuites и filter. Первый описывает группы тестов, а второй - список файлов и каталогов, на которых будет проводится анализ покрытия кода тестами. Не будем подробно на них останавливаться поскольку данные теги очень хорошо описаны в официальной документации.
Таким образом мы говорим фреймворку, что у нас есть только одна группа тестов, которая расположена в каталоге common/tests/unit, и файлы в только этом каталоге нужно анализировать на процент покрытия тестами (если указана соответствующая опция запуска).
Строка запуска теперь будет выглядеть гораздо лаконичнее. Зайдя через консоль в каталог проекта и выполнив команду ниже мы увидим тот же самый результат, который был получен на предыдущем шаге.
composer exec -v -- "phpunit -c phpunit.xml"
Строго говоря, все возможные опции, которые доступны через аргументы командной строки (посмотреть, какие опции вам доступны можно либо на официальном сайте проекта, либо выполнив composer exec -v – “phpunit –help”) мы можем указывать в конфигурационном файле. Никто не запрещает нам иметь несколько файлов конфигурации в одном проекте и использовать их поочередно, но делать так не стоит.
Отличия интеграционных и модульных тестов
Стоит сделать очень важное замечание, которое касается описанных выше тестов. Если следовать определениям, то это не совсем модульные тесты, а скорее интеграционные. Суть проста - в большинстве тестов мы используем реализацию модели пользователя, которая является наследником ActiveRecord, а уже она настолько тесно интегрирована с базой данных, что подменить ее объектом-заглушкой очень и очень сложно.
Смотрите, первый же вызываемый метод setUp() работает с базой, очищая ее и наполняя данными. Дальше идут тесты сохранения пользователя в базу и т.д. Если следовать букве определения, то мы должны разместить тесты модели не в подкаталоге unit, а в подкаталоге integration например, но, увы, это не будет отражать суть того, что же мы тестируем. Поэтому порой приходится идти на некоторые уступки.
Правильны же модульный тест тестирует только один класс в отрыве от всей остальной инфраструктуры, подменяя все внешние зависимости своими заглушками. Интеграционный же тест проверяет некоторую единицу, которая взаимодействует с различными частями системы.
Интеграционные тесты
Исторически сложилось, что интеграционное и блочное тестирование не разделяется в yii на два отдельных процесса и запускается одновременно (одна из причин описана в предыдущем разделе - их просто невозможно отделить). Изолировать их запуск можно самостоятельно. В раздел testsuites конфигурационного файла phpunit.xml мы добавим запись еще об одной группе тестов - интеграционной.
<testsuites>
<testsuite name="Core functionality">
<directory>common/tests/unit</directory>
</testsuite>
<testsuite name="Integration">
<directory>common/tests/integration</directory>
</testsuite>
</testsuites>
С помощью ключа –testsuite название_группы можно запустить конкретную группу тестов.
$ composer exec -v -- 'phpunit -c phpunit.xml'
$ composer exec -v -- 'phpunit -c phpunit.xml --testsuite "Core functionality"'
$ composer exec -v -- 'phpunit -c phpunit.xml --testsuite "Integration"'
Первая команда запустит все тестовые группы. Вторая и третья только группы с соответствующем именем.
Mock-объекты
Теперь нам нужно поговорить об очень важной составляющей любых тестов. Об объектах-заглушках. Суть этих элементов в том, чтобы подменять на время некоторую функциональность проекта.
Например, у нас есть \Yii::$app->user, который является текущим пользователем системы и содержит в себе процедуры авторизации (процедуры аутентификации происходят в модели LoginForm).
При тестировании формы одним из аспектов, который обязательно требуется проверить, является наличие вызова метода авторизации из объекта user (чаще всего используется \yii\web\user). Как мы можем это проверить простым способом?
Никак. Но что если мы сможем подменить объект user на свой? И эта сущность сможет сообщить о том был ли вызов нужно процедуры или нет. Кратко это и есть вся суть mock-объектов - объектов, которые подменяют оригинальный класс на специфический, подконтрольный разработчику. Историю появления и больше сведений можно прочесть в википедии.
Первое, что мы сделаем для тестирования LoginForm - создадим класс тестов.
common/tests/unit/LoginFormTest.php
namespace common\tests\unit;
use common\models\LoginForm;
use yii\web\User;
class LoginFormTest extends \PHPUnit_Framework_TestCase
{
protected const USER_EMAIL = 'test@test.test';
/**
* @var string Constant result of \Yii::$app->security->generatePasswordHash('test');
*/
protected const PASSWORD_HASH = '$2y$13$PP1EDCr7ujdhTxZT2DV96uM8e2rcdXHY1xAQINCIiB0gOck/VBwN6';
public static function setUpBeforeClass()
{
\Yii::$app->db->createCommand('truncate table ')->execute();
\Yii::$app->db->createCommand('insert into (id, password_hash, username, email, auth_key) values (:id, :password_hash, :username, :email, :auth_key)')
->bindValues([
'id' => 1,
'password_hash' => self::PASSWORD_HASH,
'username' => 'test',
'email' => self::USER_EMAIL,
'auth_key' => str_repeat('s', 32)
])->execute();
}
public static function tearDownAfterClass()
{
\Yii::$app->db->createCommand('truncate table ')->execute();
}
public function testOne()
{
$user = \common\models\User::findByEmail(self::USER_EMAIL);
$this->assertNotEmpty($user);
}
}
Разберем этот код подробно. Метод setUpBeforeClass() выполняется единожды при инициализации класса (аналогично setUp(), который выполняется перед каждым тестом), в нем мы создаем в базе тестового пользователя. tearDownAfterClass() запускается после прохождения всех тестов и в нем мы очищаем за собой базу. Для проверки того, что все идет хорошо мы используем testOne(). Он покажет все ли идет хорошо.
обратите внимание на то, что мы используем константный хеш. Это нужно для повторяемости тестов. Не используйте рандомные данные в своих кейсах - воспроизвести ошибку будет практически невозможно.
Запускаем через phpunit способом, который мы изучили ранее и убеждаемся, что ни один тест не упал.
composer exec -v -- "phpunit -c phpunit.xml"
Первый реальный тест, который мы напишем будет проверять валидаторы полей формы.
public function testValidationIsTrue()
{
$loginForm = new LoginForm([
'email' => 'test@test.test',
'password' => \Yii::$app->security->generatePasswordHash('test'),
]);
$this->assertTrue($loginForm->validate());
}
Тест не проходит и показывает, что есть ошибка. Это очевидно поскольку все шаблоны приложений Yii ориентируются на username, а мы в предыдущих частях условились использовать email как уникальный идентификатор пользователя.
Вашей задачей будет модифицировать LoginForm так, чтобы данный кейс прошел. Да, в процессе работы над проектом у вас будут самостоятельные задания ответы на которые вы сможете подсмотреть в исходном коде прилагаемой к статье.
А теперь мы хотим проверить, что после успешной аутентификации запускается механизм авторизации. Для этого нужно убедиться, что запускается метод \Yii\web\User::login(). Но как? Здесь нам помогут mock-объекты. На время теста мы подменим актуальный класс на наш, который укажет на то, выполнялся конкретный метод или нет.
//...
use yii\web\User;
class LoginFormTest extends \PHPUnit_Framework_TestCase
{
//...
protected static $_storedEntities = [
'user' => null,
];
/**
* Add default user to database,
* Save original components from engine to temporary storage
*/
public static function setUpBeforeClass()
{
//...
foreach (static::$_storedEntities as $entity => $value) {
static::$_storedEntities[$entity] = \Yii::$app->get($entity);
}
}
//...
/**
* Restore original components after every test
*/
protected function tearDown()
{
foreach (static::$_storedEntities as $entity => $value) {
\Yii::$app->set($entity, $value);
}
}
//...
public function testAuthorizationCall()
{
$mock = $this->getMockBuilder(User::class)
->setMethods(['login'])
->disableOriginalConstructor()
->getMock();
$mock->method('login')->withAnyParameters()->willReturn(true);
\Yii::$app->set('user', $mock);
$loginForm = new LoginForm([
'email' => self::USER_EMAIL,
'password' => 'test',
]);
$this->assertTrue($loginForm->login());
}
}
Рассмотрим метод testAuthorizationCall().
$this->getMockBuilder(User::class)
Создается mock на базе класса \Yii\web\user. Это значит, что будет использоваться оригинальный класс с сохранением всех его методов.
->setMethods(['login'])
Указываем какие методы будут заменены на новые.
->disableOriginalConstructor()
Отключаем конструктор (так как оригинальный выполняет слишком много действий, которые нам для этого теста не нужны.
->getMock()
Получаем итоговый объект.
$mock->method('login')->withAnyParameters()->willReturn(true)
Указываем, что метод login() будучи вызванный с любыми параметрами всегда вернет true.
\Yii::$app->set('user', $mock);
Подменяем оригинального пользователя на нашего.
А дальше идет простой тест, который будет искать ошибки кейса. И конечно же не стоит забывать о сохранении и восстановлении оригинальных значений. Это делают методы setUpBeforeClass() и teadDown() соответственно.
На этом мы заканчиваем знакомство с PHPUnit и дальше будем применять его на практике (конечно же заглядывая в документацию).
В качестве практики попробуйте реализовать еще несколько кейсов тестирования LoginForm. При этом вы можете заметить, что мы не покрыли тестами все пути исполнения кода. Об этой метрике мы поговорим в одной из следующих частей.
Исходный код
Литература
Категории: Разработка HowTo