Продуктивне юніт-тестування веб-додатків на прикладі 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 user login', $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.75 Mb
OK (test 1, 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 user login', $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.25 Mb
OK (test 1, 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.00 Mb
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.50 Mb
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.50 Mb
ДОБРЕ (5 tests, 5 assertions)

Результат x10 кількості всіх 5 тестів:

Time: 1.62 seconds, Memory: 15.75 Mb
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

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.