[Из песочницы] Производительное юнит-тестирование веб-приложений на примере yii2 и codeception
Задача данной статьи — показать самый производительный путь написания тестов в контексте разработки веб-приложений.
Здесь и дальше под термином тесты будут подразумеваться юнит-тесты.
Разработка веб-приложений сопровождается постоянным использованием в коде базы данных. Если код работы с базой данных и код работы с результатом взаимодействия с базой данных не разделен, нам потребуется база данных в подавляющем большинстве тестов проекта. Также, если код использует методы фреймворка, нам для тестов потребуется подключить фреймворк. Пока тестов мало, всё отлично. Когда тестов становится больше, замечается проблема: скорость выполнения тестов немного напрягает. Когда время выполнения всех юнит-тестов становится больше чем минута, становится невозможным постоянно запускать все тесты. Разработчик начинает запускать только часть тестов, пытаясь уменьшить негативное влияние длительного времени работы тестов, но проблема снижения эффективности тестирования со временем будет только возрастать.
Источник проблемы находится в отсутствии четкого разделения кода работы с базой данных, кода, которому необходим фреймворк, и кода, для работы которого не нужна ни база данных, ни фреймворк.
Наша цель будет разобраться, каким образом необходимо писать тесты и код для обеспечения максимальной скорости выполнения тестов.
Код, в первую очередь, должен преследовать цель соответствовать как можно более простой, а значит лучшей, архитектуре. В контексте данной статьи, лучшей архитектурой будет считаться:
- Код, содержащий тесты, которые покрывают все варианты использования публичных методов, которые могут не сработать.
- Код, тестирование которого выполняется с максимально возможной скоростью.
Таким образом, если мы ставим перед собой цель писать тесты, которые будут максимально быстро тестировать код, то мы приближаемся к наиболее хорошей архитектуре проекта.
Рассмотрим данный пример кода:
/**
* @return bool
*/
public function login()
{
if ($this->validate()) {
$user = User::findByUsername($this->username);
return Yii::$app->user->login($user);
} else {
return false;
}
}
И теста:
public function setUp()
{
parent::setUp();
Yii::configure(Yii::$app, [
'components' => [
'user' => [
'class' => 'yii\web\User',
'identityClass' => 'common\models\User',
],
],
]);
}
protected function tearDown()
{
Yii::$app->user->logout();
parent::tearDown();
}
public function testLoginCorrect()
{
$model = new LoginForm([
'username' => 'User',
'password' => 'CorrectPassword',
]);
expect('Model should login user', $model->login())->true();
}
public function fixtures()
{
return [
'user' => [
'class' => UserFixture::className(),
'dataFile' => '@tests/codeception/common/unit/fixtures/data/models/user.php'
],
];
}
Результат:
$ codecept run
Codeception PHP Testing Framework v2.1.2
Powered by PHPUnit 4.8.10-5-g4ecd63c by Sebastian Bergmann and contributors.
Tests\codeception\common.unit Tests (1) -------------------------------
Test login correct (unit\models\LoginFormTest::testLoginCorrect) Ok
-----------------------------------------------------------------------
Time: 1.41 seconds, Memory: 9.75Mb
OK (1 test, 1 assertion)
Метод login использует фреймворк и базу данных. Он имеет две ответственности: валидацию и осуществление входа на сайт. Но в целом, всё отлично. Кода мало, он понятен и его легко поддерживать. Однако, в данном случае перед нами не юнит-тест, а интеграционный тест. Мы зависим от фреймворка и базы данных. В базу данных мы должны занести пользователя User с паролем CorrectPassword перед началом теста. Если мы сочтем данный код как приемлемый, то большинство наших тестов станут интеграционными, что скажется на их быстродействии. Попробуем отвязать тестирование метода login от использования базы данных и фреймворка:
/**
* @param \yii\web\User $webUserComponent
* @param array $config
*/
public function __construct(\yii\web\User $webUserComponent, $config = [])
{
$this->setWebUserComponent($webUserComponent);
parent::__construct($config);
}
/**
* @param \yii\web\User $model
*/
private function setWebUserComponent($model)
{
$this->webUserComponent = $model;
}
/**
* @return \yii\web\User
*/
protected function getWebUserComponent()
{
return $this->webUserComponent;
}
/**
* @return bool
*/
public function login()
{
if ($this->validate()) {
return $this->getWebUserComponent()->login($this->getUser());
} else {
return false;
}
}
/**
* @return \common\models\User
* @throws \yii\base\Exception
*/
protected function getUser()
{
$user = User::findByUsername($this->username);
if (!$user) {
throw new Exception('В статье данный случай не рассматриваем');
}
return $user;
}
Тест тоже изменился:
public function testLoginCorrect()
{
$webUserComponentMock = \Mockery::mock(\yii\web\User::className())
->shouldReceive('login')->once()->andReturn(true)->getMock();
$userModelMock = \Mockery::mock(\common\models\User::className());
$loginFormPartialMock = \Mockery::mock(LoginForm::className())
->shouldAllowMockingProtectedMethods()
->makePartial()
->shouldReceive('getWebUserComponent')->once()->andReturn($webUserComponentMock)->getMock()
->shouldReceive('validate')->once()->andReturn('true')->getMock()
->shouldReceive('getUser')->once()->andReturn($userModelMock)->getMock();
/** @var LoginForm $loginFormPartialMock */
expect('Model should login user', $loginFormPartialMock->login())->true();
}
Результат:
$ codecept run
Codeception PHP Testing Framework v2.1.2
Powered by PHPUnit 4.8.10-5-g4ecd63c by Sebastian Bergmann and contributors.
Tests\codeception\common.unit Tests (1) -------------------------------
Test login correct (unit\models\LoginFormTest::testLoginCorrect) Ok
-----------------------------------------------------------------------
Time: 895 ms, Memory: 8.25Mb
OK (1 test, 1 assertion)
Нам удалось избавиться от зависимости с базой данных и фреймворка для тестирования, и мы получили преимущество 895 ms вместо 1.41 seconds. Однако, это не совсем корректное сравнение. Мы запустили только один тест, и большую часть времени потратили на инициализацию Codeception. Что будет если запустить их 10 раз подряд?
$ codecept run
Codeception PHP Testing Framework v2.1.2
Powered by PHPUnit 4.8.10-5-g4ecd63c by Sebastian Bergmann and contributors.
Tests\codeception\common.unit Tests (10) -------------------------------------------------------------------------
Test login correct (unit\models\LoginFormTest::testLoginCorrect) Ok
Test login correct2 (unit\models\LoginFormTest::testLoginCorrect2) Ok
Test login correct3 (unit\models\LoginFormTest::testLoginCorrect3) Ok
Test login correct4 (unit\models\LoginFormTest::testLoginCorrect4) Ok
Test login correct5 (unit\models\LoginFormTest::testLoginCorrect5) Ok
Test login correct6 (unit\models\LoginFormTest::testLoginCorrect6) Ok
Test login correct7 (unit\models\LoginFormTest::testLoginCorrect7) Ok
Test login correct8 (unit\models\LoginFormTest::testLoginCorrect8) Ok
Test login correct9 (unit\models\LoginFormTest::testLoginCorrect9) Ok
Test login correct10 (unit\models\LoginFormTest::testLoginCorrect10) Ok
-------------------------------------------------------------------------
Time: 6.09 seconds, Memory: 15.00Mb
OK (10 tests, 10 assertions)
$ codecept run
Codeception PHP Testing Framework v2.1.2
Powered by PHPUnit 4.8.10-5-g4ecd63c by Sebastian Bergmann and contributors.
Tests\codeception\common.unit Tests (10) -------------------------------------------------------------------------
Test login correct (unit\models\LoginFormTest::testLoginCorrect) Ok
Test login correct2 (unit\models\LoginFormTest::testLoginCorrect2) Ok
Test login correct3 (unit\models\LoginFormTest::testLoginCorrect3) Ok
Test login correct4 (unit\models\LoginFormTest::testLoginCorrect4) Ok
Test login correct5 (unit\models\LoginFormTest::testLoginCorrect5) Ok
Test login correct6 (unit\models\LoginFormTest::testLoginCorrect6) Ok
Test login correct7 (unit\models\LoginFormTest::testLoginCorrect7) Ok
Test login correct8 (unit\models\LoginFormTest::testLoginCorrect8) Ok
Test login correct9 (unit\models\LoginFormTest::testLoginCorrect9) Ok
Test login correct10 (unit\models\LoginFormTest::testLoginCorrect10) Ok
-------------------------------------------------------------------------
Time: 1.05 seconds, Memory: 8.50Mb
OK (10 tests, 10 assertions)
От 895 ms до 1.05 seconds против 1.41 seconds до 6.09 seconds. Используя моки мы добились того что тесты стали проходить почти мгновенно. Это позволит писать много тестов. Очень много тестов. И запускать их когда захотим, спустя несколько секунд получая результат.
В случае с тестами, которые используют фреймворк и базу данных, мы не можем запускать их постоянно, это долго. И если напишем очень много тестов нам придется играть в mortal combat, в процессе их выполнения. Разумеется, это скажется на эффективности разработки. Оптимизировать процесс заполнением базы данных только один раз при старте тестов мы не можем — тесты должны быть независимы друг от друга.
Однако, у нас появилась другая проблема. Тесты выполняются быстро, но что стало с кодом? Простоты и читабельности явно не прибавилось, особенно в тесте. Тест не покрывает все варианты использования, надо написать ещё несколько тестов в таком же многословном стиле. И есть один очень опасный фактор, на которое следует обратить внимание в первую очередь:
protected function getUser()
Для того чтобы иметь возможность замочить getUser и getWebUserComponent нам пришлось использовать область видимости protected. Мы пошли на нарушение инкапсуляции.
Мы хотели показать что быстрые тесты ведут к хорошей архитектуре, а получилось так что мы нарушили инкапсуляцию, снизили читаемость кода и тем самым сделали его сложнее. Почему мы пришли к такому результату? Мы писали тесты после написания кода, и мы считали что тестам не должно быть никакого дела до того что происходит внутри LoginForm.
Если мы хотим иметь быстрые тесты вместе с отличной читаемостью кода, надо руководствоваться принципом: тесты важны. Попробуем начать заново, с применением этого принципа.
В первую очередь выкинем весь код и напишем тест. Моя задача: авторизовать пользователя, в случае если введенный им логин или пароль корректен. Думаю, для начала мне нужен тест валидирующий данные.
public function testSuccessValidation()
{
$loginForm = new LoginForm([
'username' => 'User',
'password' => 'CorrectPassword'
]);
expect('Validation should be success', $loginForm->validate())->true();
}
Кода проверки пароля нет, валидация просто возвращает true. Теперь мне нужен тест который будет определять что пароль неверный.
public function testFailedValidation()
{
$loginForm = new LoginForm([
'username' => 'User',
'password' => 'INCORRECT-Password'
]);
expect('Validation should be failed', $loginForm->validate())->false();
}
Последний тест падает, время реализовать проверку пароля. Для этого мне нужно достать пользователя по его username из базы данных и сравнить пароль.
public function testGetUserByUsername()
{
$userMock = \Mockery::mock(User::className())
->shouldReceive('findByUsername')->once()->andReturn(\Mockery::self())->getMock();
$userName = 'User';
$loginForm = new LoginForm($userMock, [
'username' => $userName
]);
expect('getUser method should return User', $loginForm->getUser() instanceof User)->true();
$userMock->shouldHaveReceived('findByUsername', [$userName]);
}
В модели уже появляется логика:
/**
* @param \common\models\User $user
* @param array $config
*/
public function __construct(User $user, $config = [])
{
$this->user = $user;
parent::__construct($config);
}
/**
* @return \common\models\User
* @throws \yii\base\Exception
*/
public function getUser()
{
$user = $this->user->findByUsername($this->username);
if (!$user) {
throw new Exception('Не будем рассматривать данную ситуацию');
}
return $user;
}
Сейчас мы можем обратить внимание на три момента:
- getUser публичный.
- getUser не вызывает статический метод класса User, а использует объект, получаемый в качестве зависимости.
- Мы не получаем пользователя из базы данных, мы проверяем только то что был вызван метод findByUsername с аргументом $this->username и возвращен результат выполнения данного метода.
Почему getUser имеет публичную область видимости? Потому что тесты важны. Может лучше скрыть получение пользователя в LoginForm? Если, как в первом примере, оставить только один публичный метод login, мы через этот метод будем тестировать корректность работы метода getUser. Только вот получим такой же сложный код теста как в первом примере с моками. Почему в одном случае расширение области видимости это нарушение инкапсуляции, а в другом нет? Сейчас мы тестируем этот конкретный публичный метод, мы сразу сделали его публичным, так как он нам нужен для теста. Нарушение инкапсуляции произошло когда мы дали тесту знание, которое по интерфейсу изначально ему не полагалось, в попытке ускорить его. Такой проблемы не возникает, если писать тесты перед реализацией.
Инкапсуляция важна, но читаемость тестов и кода важнее. Может сложиться впечатление что ради тестов вы должны открыть все методы, чтобы их было удобно тестировать, но у вас не получится. Всегда в коде будут места, расширенная видимость которых не нужна для тестирования. Тесты необходимо рассматривать как полноправный клиентский код. Также, как если контроллеру нужен доступ к методу и вы его открываете, также вы должны открывать видимость и для теста. Тесты это полноценная часть вашего приложения, на них распространяются те же стандарты качества и право требования необходимых для их работы методов.
Почему мы создаем и передаем объект User в конструктор, хотя нам нужен только статичный метод класса? Для того чтобы иметь возможность его замочить. Не жирно ли это для LoginForm? Нет. Тесты важны. Плюс у нас есть в распоряжении паттерн Depency Injection.
Почему мы не проверяем что мы действительно получили пользователя из метода findByUsername, а только факт его вызова? Корректность работы findByUsername протестирована другим интеграционным тестом, для которого требуется база данных. Нам же сейчас достаточно быть уверенными что в findByUsername был передан нужный параметр и функция getUser вернёт результат выполнения этого метода.
Добавляем тест проверки пароля, и изменяем другие тесты соответствия новому коду:
public function testSuccessValidation()
{
$userMock = \Mockery::mock(User::className())
->shouldReceive('validatePassword')->once()->andReturn(true)->getMock()
->shouldReceive('findByUsername')->once()->andReturn(\Mockery::self())->getMock();
$loginForm = new LoginForm($userMock, [
'username' => 'User',
'password' => 'CorrectPassword'
]);
expect('Validation should be success', $loginForm->validate())->true();
}
public function testFailedValidation()
{
$userMock = \Mockery::mock(User::className())
->shouldReceive('validatePassword')->once()->andReturn(false)->getMock()
->shouldReceive('findByUsername')->once()->andReturn(\Mockery::self())->getMock();
$loginForm = new LoginForm($userMock, [
'username' => 'User',
'password' => 'INCORRECT-Password'
]);
expect('Validation should be failed', $loginForm->validate())->false();
}
public function testGetUserByUsername()
{
$userMock = \Mockery::mock(User::className())
->shouldReceive('findByUsername')->once()->andReturn(\Mockery::self())->getMock();
$userName = 'User';
$loginForm = new LoginForm($userMock, [
'username' => $userName
]);
expect('getUser method should return User', $loginForm->getUser() instanceof User)->true();
$userMock->shouldHaveReceived('findByUsername', [$userName]);
}
public function testValidatePassword()
{
$userMock = \Mockery::mock(User::className())
->shouldReceive('validatePassword')->once()->andReturn(true)->getMock()
->shouldReceive('findByUsername')->once()->andReturn(\Mockery::self())->getMock();
$password = 'RightPassword';
$loginForm = new LoginForm($userMock, [
'password' => $password
]);
$loginForm->validatePassword('password');
expect('validate password should be success', $loginForm->getErrors())->isEmpty();
$userMock->shouldHaveReceived('validatePassword', [$password]);
}
Модель теперь выглядит так:
/**
* @param \common\models\User $user
* @param array $config
*/
public function __construct(User $user, $config = [])
{
$this->user = $user;
parent::__construct($config);
}
public function rules()
{
return [
[['username', 'password'], 'required'],
['password', 'validatePassword']
];
}
/**
* @param string $attribute
*/
public function validatePassword($attribute)
{
$user = $this->getUser();
if (!$user->validatePassword($this->$attribute)) {
$this->addError($attribute, 'Incorrect password.');
}
}
/**
* @return \common\models\User
* @throws \yii\base\Exception
*/
public function getUser()
{
$user = $this->user->findByUsername($this->username);
if (!$user) {
throw new Exception('Не будем рассматривать данную ситуацию');
}
return $user;
}
Внутри метода $user->validatePassword находится обращение к фреймворку, и данный код должен быть покрыт интеграционным тестом. Для определения того что метод вызывается с нужными параметрами нам не нужна его реализация, и мы мочим его. Сейчас у нас класс полностью покрыт тестами, осталось только реализовать авторизацию.
Тест:
public function testLogin()
{
$userComponentMock = \Mockery::mock(\yii\web\User::className())
->shouldReceive('login')->once()->andReturn(true)->getMock();
Yii::$app->set('user', $userComponentMock);
$userMock = \Mockery::mock(User::className())
->shouldReceive('findByUsername')->once()->andReturn(\Mockery::self())->getMock();
$loginForm = new LoginForm($userMock);
expect('login should be success', $loginForm->login())->true();
$userComponentMock->shouldHaveReceived('login', [$userMock]);
}
В модели:
/**
* @return bool
*/
public function login()
{
return Yii::$app->user->login($this->getUser());
}
Данный тест является интеграционным и он находится отдельно от юнит-тестов. Здесь достаточно спорный момент, допустим ли сервис-локатор в классе LoginForm. С одной стороны тестам он не мешает, нам в любом случае пришлось бы тестировать факт авторизации. С другой стороны теперь наша моделька менее реюзабельна. Я считаю что это наиболее очевидное место после контроллера, поэтому пока нет никакой дополнительной логики, данный код по хорошему должен находится в контроллере.
Результат:
$ codecept run
Codeception PHP Testing Framework v2.1.2
Powered by PHPUnit 4.8.10-5-g4ecd63c by Sebastian Bergmann and contributors.
Tests\codeception\common.unit Tests (5) ---------------------------------------------------------------------------------
Test login (LoginFormTest::testLogin) Ok
Test success validation (LoginFormTestWithoutDbTest::testSuccessValidation) Ok
Test failed validation (LoginFormTestWithoutDbTest::testFailedValidation) Ok
Test get user by username (LoginFormTestWithoutDbTest::testGetUserByUsername) Ok
Test validate password (LoginFormTestWithoutDbTest::testValidatePassword) Ok
---------------------------------------------------------------------------------
Time: 973 ms, Memory: 10.50Mb
OK (5 tests, 5 assertions)
Результат x10 количества всех 5 тестов:
Time: 1.62 seconds, Memory: 15.75Mb
OK (50 tests, 50 assertions)
Используя моки и разделяя логику работы с базой данных, фреймворком и обычными классами мы смогли значительно сократить время работы тестов. Конечно, тесты которые проверяют корректность работы с базой данных никуда не делись, их необходимо будет реализовывать. Но они должны быть отделены от юнит-тестов и тестировать только методы, взаимодействующие с базой данных. Их запуск будет необходим только тогда когда будет меняться логика работы с базой данных.
Может возникнуть вопрос, а стоило ли оно того? В самом начале у нас был небольшой метод и маленький тест. Код был простым и отлично читаем. Сейчас мы получили большое количество тестов, кучу моков и зависимость в конструкторе. Всё ради того чтобы быстро протестировать валидацию пароля и авторизацию. Ответ зависит от длительности разработки и поддержки веб-приложения.
Если над небольшим проектом работаете вы один, вам будет достаточно минимального набора максимально простых тестов, которые вы будете писать просто потому что вы с ними будете быстрее писать код. В случае если вы знаете что будете вести разработку и поддержку достаточно долгое время, внимательно оцените ситуацию. Вы вряд ли будете переписывать тесты и выигранное время вначале вы отдадите с процентами в процессе поддержки кода с медленными тестами.
В случе если над проектом работает более одного программиста ответ очевиден. Медленный тест будет забирать не только ваше время, но и время каждого члена команды. Вы должны приложить все усилия чтобы сделать ваши тесты максимально быстрыми.
Заключение:
- Тесты необходимо писать перед написанием кода. Это позволит естественней отделить методы, которым требуются внешние зависимости, от методов которые можно тестировать автономно.
- Тесты, не требующие базы данных и фремворка, работают очень быстро. Это позволяет писать очень много тестов и запускать их постоянно.
- Тесты являются полноценной частью приложения и на них распространяются те же стандарты качества и право получать доступ к необходимым им методам.
- Отделять моками тесты от базы данных и фреймворка необходимо в случае разработки кода, поддержка которого будет вестись на протяжении длительного времени, а также если над проектом работает более одного программиста.
Ресурсы, рекомендуемые к ознакомлению:
Robert C. Martin blog
Robert C. Martin: The Three Rules Of Tdd
Robert C. Martin: The little singleton
Robert C. Martin: The little mocker
Robert C. Martin: The Next Big Thing
Robert C. Martin: Just Ten Minutes Without A test
Agile Book: Test Driven Development