Блог CREATIVE
Статьи Разработка Интересное

Symfony 4 + Codeception: авторизация клиента в процессе тестирования




Автоматизированное тестирование при разработке приложений — чрезвычайно важная практика. В процессе коммерческой разработки сайтов с бэкендом на PHP, автотесты — скорее добрая воля со стороны программиста и понимание процессов со стороны менеджера, чем общепринятая практика: заказчику зачастую неясно, на что программист тратит 20% рабочего времени и что это за автотесты вообще такие.


— … отладка, тестирование, исправление ошибок.

— Пиши сразу без ошибок!



В последнее время, однако, эта ситуация поменялась, и мы уже не натыкаемся на недоуменные взгляды при фразе «покрытие кода тестами» — уровень заказчиков (а вместе с ним и понимание процессов со стороны заказчиков) растет.

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

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


Проблема, с которой столкнулся один из наших программистов


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


Каким образом в Symfony 4 авторизовать не живого пользователя, а клиента, который действует внутри функционального теста codeception?



Официальное руководство Symfony говорит:

  • создайте отдельный механизм http-аутентификации для тестов, или
  • создайте внутри теста отдельный метод, который будет реализовывать подстановку авторизационного токена для сессии Symfony\Component\BrowserKit\Client


В целом сложностей быть не должно — модуль Symfony для Codeception реализует именно этот компонент, то есть добравшись внутри теста до свойств клиента мы легко подставляем ему UsernamePasswordToken.



$token = new UsernamePasswordToken('user', null, $firewallName, array('ROLE_USER'));
$session->set('_security_'.$firewallContext, serialize($token));
$session->save();
$cookie = new Cookie($session->getName(), $session->getId());
$this->client->getCookieJar()->set($cookie);


Сложность в том, что внутри функционального теста экземпляр клиента недоступен, поэтому и подставить ему cookie нельзя. HTTP-авторизацию использовать тоже нельзя — запись клиента в БД содержит множество нужных ему свойств, просто http-заголовок не пойдет, а идею «Авторизоваться тестом через форму, сграбить сессию и cookies и потом использовать» мы, по понятным причинам, отбросили сразу же.


Решение проблемы


оказалось довольно тривиальным: нужно использовать встроенный в Codeception механизм хелперов, внутри которых доступны модули и их свойства, объявить метод, который будет давать клиенту авторизацию и будет использоваться внутри теста.

В файле tests/_support/Helper/Functional.php объявляем метод:

/**
* Create user or administrator and set auth cookie to client
*/
public function setAuth(bool $admin = false)
{
try {
$symfony = $this->getModule('Symfony');
} catch (ModuleException $e) {
$this->fail('Unable to get module \'Symfony\'');
}
try {
$doctrine = $this->getModule('Doctrine2');
} catch (ModuleException $e) {
$this->fail('Unable to get module \'Doctrine2\'');
}
$encoder = $symfony->grabService('security.password_encoder');
$uuid = $doctrine->haveInRepository('App\Entity\User', [
'title' => 'Test User Title',
'email' => 'testemail@example.com',
'isActive' => true,
'roles' => $admin ? ['ROLE_ADMIN'] : ['ROLE_USER'],
'password' => $encoder->encodePassword(new \App\Entity\User(), 'user_passWord'),
]);
$user = $doctrine->grabEntityFromRepository('App\Entity\User', [
'id' => $uuid->toString(),
]);
$token = new UsernamePasswordToken($user, null, 'main', $user->getRoles());
$symfony->grabService('security.token_storage')->setToken($token);
$session = $symfony->grabService('session');
$session->set('_security_main', serialize($token));
$session->save();
$cookie = new Cookie($session->getName(), $session->getId());
$symfony->client->getCookieJar()->set($cookie);
}


Как видно из кода, мы практически полностью следуем официальному руководству в части установки токена, но предварительно создаем в БД запись для тестового пользователя и получаем, собственно, объект пользователя. Все сервисы, нужные для шифрования пароля и формирования токена, могут быть получены через метод
grabServiceкомпонента Symfony.

В результате, внутри теста:


public function tryToSeeMainPage(FunctionalTester $I)
{
$I->setAuth(); // Make client authenticated
$I->amOnRoute('main_page');
$I->seeResponseCodeIs(200);
$I->seeElement('nav');
$I->see($I->grabService('translator')->trans('Personal Area'));
}


Возможные проблемы, которые могут возникнуть при использовании этого способа:


  • в вашей базе данных уже может быть пользователь с такими реквизитами. Это ваша ошибка — создавайте базу данных для тестов отдельно, пустую, и накатывайте туда миграции, чтобы не нарушать чистоту тестовой среды;
  • может (по различным причинам) не пройти создание пользователя в БД но — если причина в классе пользователя или репозитории, это должны показать unit-тесты, другие причины (падение БД, интерпретатора, контейнера) — вопрос отдельного исследования;
  • этот способ, конечно, занимает значительное (по сравнению с http-авторизацией) время, но такого рода потери неизбежны, если свойства пользователя включают в себя нечто большее, чем имя и пароль. В качестве решения можно предложить задействовать метод _beforeSuite хелпера, и выполнять создание пользователя перед началом всего комплекта тестов, а не каждый раз

Надеюсь, эта статья поможет кому-то сэкономить пару часов рабочего времени :)