Часть 6: Самописный тестовый фреймворк (Тестирование ПО)

Оглавление

00-intro

Продолжаем цикл статей Тестирование ПО в котором рассказывается о разработке программного обеспечения с применением методологии TDD.

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

Стандарты кодирования

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

Поскольку мы пишем на Yii2, то и стандарт у нас будет соответствующим: Yii 2 Web Framework Coding Standard.

Чтобы иметь возможность проверять код нам потребуется дополнительный инструмент под названием PHP CodeSniffer.

Для установки нужных компонент добавляем в секцию require-dev файла composer.json следующие записи:

"yiisoft/yii2-coding-standards": "2.0.*",  
"squizlabs/php_codesniffer": "2.*"

Затем зайдя по ssh на виртуальную машину выполняем в каталоге /var/www команду

composer update

01-composer.json

После завершения нам будет доступен codesniffer. И не забывайте закоммитить изменения composer.lock в вашу систему контроля версий.

Для проверки кода на соблюдение стандартов в консоли виртуальной машины (в каталоге /var/www) запускаем команду

composer exec --verbose -- phpcs --extensions=php --standard=./vendor/yiisoft/yii2-coding-standards/Yii2 <путь к файлу или папке, который требуется проверить>

02-phpcs

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

Валидация модели пользователя

Мы уже разобрались с тем, как работают миграции. Теперь посмотрим на модель пользователя. Она расположена в файле common/models/User.php. Конечно же потребуется ее доработать.

Что какие свойства пользователя являются необходимыми для его существования?

  • уникальность почтового адреса
  • наличие пароля
  • наличие ника пользователя (username)
  • уникальность хеша для сброса пароля (password_reset_token)

Эти соблюдение этих правил говорит нам о том, что при работе с моделью (и внутри модели) нет ошибок.

Ошибка многих начинающих и не очень разработчиков в том, что они пытаются организовать уникальность пользователя через ник, что в корне неверно. Это заблуждение порождает шедевры вроде “Ромашка_227” и иные, не менее странные, вещи.

В шаблоне проекта уже есть папка common/tests. Ее содержимое нам не пригодится. Поэтому можем смело его удалять и оставлять папку пустой.

Напишем наш первый тест.

common/tests/unit/UserTest.php

namespace common\tests\unit;  
require_once(__DIR__ . '/../_bootstrap.php');  
use common\models\User;

class UserTest  
{  
 public function testValidateEmptyFields()  
 {  
 $user = new User();  
 echo 'Validate username, password, email (empty case): ';  
 if ($user->validate() === false) {  
 echo 'Ok!' . PHP_EOL;  
 } else {  
 echo 'Fail :(' . PHP_EOL;  
 }  
 echo 'Check for username errors: ';  
 if (array_key_exists('username', $user->getErrors())) {  
 echo 'Ok!' . PHP_EOL;  
 } else {  
 echo 'Fail :(' . PHP_EOL;  
 }  
 echo 'Check for email errors: ';  
 if (array_key_exists('email', $user->getErrors())) {  
 echo 'Ok!' . PHP_EOL;  
 } else {  
 echo 'Fail :(' . PHP_EOL;  
 }  
 echo 'Check for password errors: ';  
 if (array_key_exists('password_hash', $user->getErrors())) {  
 echo 'Ok!' . PHP_EOL;  
 } else {  
 echo 'Fail :(' . PHP_EOL;  
 }  
 }  
}

$test = new UserTest();  
$test->testValidateEmptyFields();

Код тривиален и в пояснениях не нуждается. Но мы видим, что в файле происходит подключение _bootstrap.php. Посмотрим на него.

common/tests/_bootstrap.php

defined('YII_DEBUG') or define('YII_DEBUG', true);  
defined('YII_ENV') or define('YII_ENV', 'test');  
require(__DIR__ . '/../../vendor/autoload.php');  
require(__DIR__ . '/../../vendor/yiisoft/yii2/Yii.php');  
require(__DIR__ . '/../config/bootstrap.php');  
require(__DIR__ . '/../../console/config/bootstrap.php');

$config = yii\helpers\ArrayHelper::merge(  
 require(__DIR__ . '/../config/test-local.php'),  
 require(__DIR__ . '/../../console/config/main.php'),  
 require(__DIR__ . '/../../console/config/main-local.php')  
);

$application = new yii\console\Application($config);

Видим, что это не что иное как точка входа для инициализации фреймворка. Подключаются и инициализируются автозагрузка и тестовая конфигурация. Обратите внимание на то, что код подключает конфиг test-local.php, который мы правили ранее. Последней строкой происходит инициализация фреймворка без запуска. В нашем случае нет необходимости выполнять фактический запуск поскольку все происходит внутри теста.

Код первого теста готов. Теперь заходим на виртуальную машину по ssh и в каталоге /var/www запускаем файл кейса на исполнение.

php common/tests/unit/UserTest.php

03-usertest-fail

Что это?! Почему мы видим ошибку о том, что какая-то таблица не создана? Все верно, выше было замечено, что используется подключение к тестовой бд. А миграции для тестовой базы мы не применяли, только для базы разработчика.

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

php yii_test migrate

Система спросит вас о том, согласны ли вы на применение миграции к бд, а после покажет сообщение об успешном выполнении.

Теперь можно запускать тесты.

04-usertest-ran

Тесты не прошли. Да оно и понятно - отсутствует валидация внутри модели. Поэтому добавим несколько правил в метод User::rules().

public function rules()  
{  
 return [  
 [['username', 'email', 'password_hash'], 'required'],  
 [['email'], 'unique'],  
 [['username', 'email', 'password_hash'], 'string', 'max' => 255],  
 [['username'], 'match', 'pattern' => '#^[a-z0-9_-]+$#i'],  
 [['email'], 'email'],  
 ['status', 'default', 'value' => self::STATUS_ACTIVE],  
 ['status', 'in', 'range' => [self::STATUS_ACTIVE, self::STATUS_DELETED]],  
 ];  
}

Максимальную длину строк мы проверяем на тот случай если база не включена в так называемый “строгий режим”, который вызывает исключение всякий раз, когда происходит что-то невообразимое (например мы пытаемся в поле varchar размером 255 записать значение больше 255 символов). Крайне рекомендуется держать базу в строгом режиме и препятствовать его отключению.

Очередной запуск проходит успешно и никаких ошибок мы не нашли.

05-usertest-success

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

common/tests/TestCase.php

namespace common\tests;  
class TestCase  
{  
 protected function assertTrue($value, $testName = '')  
 {  
 $this->assertEquals(true, $value, $testName);  
 }  
protected function assertFalse($value, $testName = '')  
 {  
 $this->assertEquals(false, $value, $testName);  
 }

protected function assertArrayHasKey($needle, array $haystack, $testName = '')  
 {  
 $this->assertTrue(array_key_exists($needle, $haystack), $testName);  
 }

protected function assertEquals($expected, $value, $testName = '')

{  
 printf('%s: ', $testName);  
 if ($value === $expected) {  
 echo 'ok' . PHP_EOL;  
 } else {  
 echo 'fail' . PHP_EOL;  
 }  
 }  
}

Соответствующим образом рефакторим код UserTest.php дабы он использовал новый функционал.

common/tests/UserTest.php

namespace common\tests\unit;

require_once(__DIR__ . '/../_bootstrap.php');

use common\models\User;  
use common\tests\TestCase;

class UserTest extends TestCase  
{  
 public function testValidateEmptyFields()  
 {  
 $user = new User();  
 $this->assertFalse($user->validate(), 'Validate username, password, email (empty)');  
 $this->assertArrayHasKey('password_hash', $user->getErrors(), 'Check for password errors');  
 $this->assertArrayHasKey('email', $user->getErrors(), 'Check for email errors');  
 $this->assertArrayHasKey('username', $user->getErrors(), 'Check for username errors');  
 }  
}

$test = new UserTest();  
$test->testValidateEmptyFields();

Теперь уже лучше. И, как вы можете заметить, тесты писать стало проще.

06-usertest-refactoring-1

Закономерный вопрос: “а нужны ли такие простые тесты?”. Да. Безусловно нужны. Вы можете забыть создать уникальный ключ по полю email. Может случится такая ситуация, что на новой форме вы сделали одно из полей по требованию заказчика обязательным для ввода, но при этом забыли поставить на него признак unique в модели. Как было отмечено ранее - простые тесты в первую очередь дают возможность выявлять ошибки инфраструктуры на ранней стадии. Добавим еще несколько тестов дабы быть уверенными, что все идет хорошо.

public function testEmailFormat()  
{  
 $user = new User(['email' => 'sdfsdfsdfsdf', 'username' => 'username', 'password' => 123]);  
 $this->assertFalse($user->validate(), 'Validate username, password, email (incorrect)');  
 $this->assertArrayNotHasKey('password', $user->getErrors(), 'Check for password errors');  
 $this->assertArrayHasKey('email', $user->getErrors(), 'Check for email errors');  
 $this->assertArrayNotHasKey('username', $user->getErrors(), 'Check for username errors');  
}

Чтобы запустить данный тест нам потребуется добавить строку запуска в файле UserTest.php и реализовать метод assertArrayNotHasKey() в классе TestCase. Эти действия вы должны будете сделать самостоятельно.

07-usertest-emailformat

Прежде чем перейти к следующему шагу, сделаем небольшой рефакторинг и преобразуем код запуска тестов, вынеся его в файл common/tests/unitSuite.php.

common/tests/unitSuite.php

namespace common\tests;

require __DIR__ . DIRECTORY_SEPARATOR . '_bootstrap.php';

foreach (scandir(__DIR__ . DIRECTORY_SEPARATOR . 'unit') as $item) {  
 if (substr($item, -8, 8) == 'Test.php') {  
 $className = pathinfo($item, PATHINFO_FILENAME);  
 $reflection = new \ReflectionClass('common\tests\unit\\' . $className);  
 foreach ($reflection->getMethods() as $method) {  
 if (substr($method->name, 0, 4) === 'test') {  
 $test = new $method->class;  
 printf('%s::%s%s', $method->class, $method->name, PHP_EOL);  
 $test->{$method->name}();  
 }  
 }  
 }  
}

И не забываем удалить строчки с подключением файла _bootstrap.php из файла TestCase.php, а из файла UserTest.php удаляем строки запуска самих тестов. Сделав такую своеобразную точку входа нам можно больше не заботится о том, чтобы запускать тесты вручную. Система все сделает за нас: проверить наличие файлов с тестами, найдет в них все кейсы и запустит их, а в процессе работы будет сообщать о том, прошел кейс или нет.

08-unitsuite

Добавление пользователя в базу

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

public function testAddUser()  
{  
 $user = new User(['email' => 'test@test.test', 'username' => 'admin', 'password' => 'admin']);  
 $user->generateAuthKey();  
 $this->assertTrue($user->validate());  
 $user->save();  
 $this->assertTrue(1 == User::find()->where(['email' => 'test@test.test'])->count());  
}

Запускаем. И оно нашло нам первую ошибку.

Exception 'yii\db\Exception' with message 'SQLSTATE[22007]: Invalid datetime format: 1292 Incorrect datetime value: '1494530847' for column 'created_at' at row 1

09-timestamp-fail

Происходить это из-за того, что в одной из предыдущих частей проектируя базу данных мы задали поля created_at и updated_at как поля типа timestamp. Этот тип данных хранит временную отметку с таймзоной, которая установлена на машине по-умолчанию.

Немного о датах и типах полей

D mysql существует несколько типов календарных полей (типы, которые позволяют хранить временные отметки).

Спецификация типа Диапазон Примечания
DATE От ‘1000-01-01’ до ‘9999-12-31’ Хранит дату в формате YYYY-MM-DD “как есть” без сохранения сведений о часовом поясе
TIME От ‘-838:59:59’ до ‘838:59:59’ Хранит смещение от 00:00. Этим и объясняется столь широкий диапазон хранимых данных.
DATETIME От ‘1000-01-01 00:00:00’ до ‘9999-12-31 00:00:00’ Аналогично DATE, но только дополнительно хранит временную отметку в формате hh:mm:ss
TIMESTAMP([M]) От ‘1970-01-01 00:00:00’ до неопределенной даты в 2037 году Поле хранит время в часовом поясе UTC. При записи поле конвертируется из часового пояса подключившегося клиента (или сервера) в UTC и при чтении конвертируется из UTC в часовой пояс соответственно настройкам клиента.
YEAR([M]) От 1901 до 2155 для YEAR(4) и от 1970 до 2069 до YEAR(2) Хранение данных аналогично DATE. без привязки к часовому поясу.

В соответствии со спецификацией ISO 8601 все даты представляются в формате YYYY-MM-DD [hh:mm:ss].

При работе с полями типа DATE, DATETIME и YEAR стоит быть очень внимательным так как информация о часовом поясе отсутствует. В современных приложения хорошей практикой является хранить в этих полях данные в часовом поясе UTC, а на клиенте отображать данные в соответствии с его часовым поясом.

Тип поля TIMESTAMP не рекомендуется использовать для хранения данных клиента. Этот тип чаще всего является служебным и хранит автоматически проставляемые временные отметки. Такие как время создания и обновления записи. Старайтесь никогда не писать в подобные поля из клиентского кода, любое их изменение должно проводится триггерами ON UPDATE и ON INSERT. Помимо прочего это поле обладает самым малым диапазоном хранимых данных и уже совсем скоро (на дворе 2017й года) принесет кучу проблем для поставщиков программного обеспечения (проблема 2037 года). Более подробно вы можете ознакомиться с данными типа в книге MySQL от Поля Дюбуа.

Отлично, мы немного прояснили для себя как устроено хранение календарных данных в MySQL, но как это поможет нам исправить ошибку? Это довольно просто - нужно удалить TimestampBehavior из класса User.

Компоненты в Yii2 имеют возможность подключать так называемые “поведения”. Иными словами это операции, которые выполняются с объектом при наступлении определенных событий. Модель ActiveRecord - это тоже компонент и она имеет возможность работать с этим аспектом. Одной из таких надстроек является TimestampBehavior, которая обновляет содержимое полей updated_at и created_at при операциях создания или обновления записи. Мы же условились работать с календарными типами данных. Поэтому это поведение следует исключить из метода behaviors().

public function behaviors()  
{  
 return [];  
}

Теперь тест проходит вполне успешно.

10-timestamp-ok

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

11-adduser-fail

Очевидно, что нужно предусмотреть какой-то способ настройки окружения перед запуском тестов. Назовем его setUp() и исправим код запуска тестов в файле unitSuite.php и код класса TestCase.

unitSuite.php

if (substr($method->name, 0, 4) === 'test') {  
 /**  
 * @var TestCase  
 */  
 $test = new $method->class;  
 printf('%s::%s%s', $method->class, $method->name, PHP_EOL);  
 $test->setUp();  
 $test->{$method->name}();  
}

Обратите внимание на то, что мы добавили doc-комментарий, указывающий тип переменной $test - это позволяет различным ide правильно автодополнять методы этого класса.

TestCase

class TestCase  
{  
 // other code  
/**  
 * Execute before every test  
 */  
 public function setUp()  
 {  
 // pass  
 }  
}

Теперь можно реализовать очистку таблицы user перед запуском каждого теста.

UserTest

public function setUp()  
{  
 \Yii::$app->db->createCommand()->truncateTable('')->execute();  
}

Конечно не все тесты были реализованы. Попробуйте в качестве упражнения реализовать тесты на поиск и удаление пользователей. Отметим, что в базе не происходит физического удаления записи о пользователе. Просто поле status устанавливается в значение User::STATUS_DELETED. Помните этот момент при реализации ваших тестов.

Обеспечение безопасности

Прежде чем перейти к изучению других аспектов тестирования немного отвлечемся и подумаем: а что мы должны сделать с паролями пользователя? Хранить их в открытом виде противопоказано, использовать простые методы хеширования вроде md5 без соли - тоже. Так как мы работаем с yii, то рекомендованным методом обеспечения безопасности является применение методов \yii\base\Security.

public function testPasswordHashing()  
{  
 $password = 'test';  
 $user = new User(['email' => 'test@test.test', 'username' => 'test', 'password' => $password]);  
 $this->assertTrue(\Yii::$app->security->validatePassword($password, $user->password_hash));  
}

Также обязательно потребуются тесты на попытку задания пустого пароля. Зачем? Просто взгляните на реализацию User::setPassword(). Данная реализация пропускает любые данные в функцию хеширования. В том числе и пустую строку, а это в дальнейшем может негативно отразиться на безопасности приложения.

public function testEmptyPassword()  
{  
 $user = new User(['email' => 'test@test.test', 'username' => 'username', 'password' => '']);  
 $this->assertFalse($user->validate());  
 $this->assertArrayNotHasKey('username', $user->getErrors());  
 $this->assertArrayNotHasKey('email', $user->getErrors());  
 $this->assertArrayHasKey('password_hash', $user->getErrors());  
}

public function testEmptyPasswordDirectSet()  
{  
 $user = new User(['email' => 'test@test.test', 'username' => 'username']);  
 $user->password = '';  
 $this->assertFalse($user->validate());  
 $this->assertArrayNotHasKey('username', $user->getErrors());  
 $this->assertArrayNotHasKey('email', $user->getErrors());  
 $this->assertArrayHasKey('password_hash', $user->getErrors());  
}

При взгляде не этот код у вас закономерно может возникнуть вопрос о том почему мы устанавливаем свойство password, а проверяем password_hash. Разгадка проста: password - это виртуальное write-only свойство, которое обрабатывается сеттером User::setPassword(), а уже сам этот метод из пароля генерирует хеш. Подробнее можно об это можно прочесть в руководстве по Yii2.

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

  • правило должно быть установлено на свойство password_hash
  • необходимо модифицировать метод setPassword()

Если на password_hash уже установлено правило required, то метод задания пароля требует модификации.

public function setPassword($password)  
{  
 if ($password) {  
 $this->password_hash = Yii::$app->security->generatePasswordHash($password);  
 }  
}

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

После запуска тестов в очередной раз мы видим лишь успешное выполнение.

12-password-validation

Последнее, что мы хотим покрыть тестами - это корректность сохранения пароля в базу и метод User::validatePassword().

public function testPasswordValidation()  
{  
 $password = \Yii::$app->security->generateRandomString(32);  
 $user = new User(['email' => 'test@test.test', 'username' => 'test', 'password' => $password]);  
 $this->assertTrue($user->validatePassword($password));  
}

public function testPasswordValidationAfterSave()  
{  
 $password = 'SOME_FAKE_PASSWORD';  
 $email = 'test@test.test';  
 $user = new User(['email' => $email, 'username' => 'test', 'password' => $password]);  
 $user->generateAuthKey();  
 $this->assertTrue($user->validatePassword($password));  
 $user->save();  
 $user2 = User::findByEmail($email);  
 $this->assertTrue($user2->validatePassword($password));  
}

Последний тест падает с ошибкой. Все потому что мы не реализовали метод User::findByEmail().

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

13-validation-after-save

Литература

Исходный код

Категории: Разработка HowTo