
Автоматизированное тестирование при разработке приложений — чрезвычайно важная практика. В процессе коммерческой разработки сайтов с бэкендом на 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 хелпера, и выполнять создание пользователя перед началом всего комплекта тестов, а не каждый раз
Надеюсь, эта статья поможет кому-то сэкономить пару часов рабочего времени :)