Я принял участие в проекте с двухлетней кодовой базой и Symfony 3.4 в качестве веб-фреймворка. Это был не самый новый и блестящий проект, но у него было большое достоинство: тесты покрывали самые важные действия приложения.
Впервые в своей карьере я увидел, как тесты могут придать уверенности в кодовой базе, начинают экономить твоё время и помогают соблюсти требования бизнеса.
Этого достигли с помощью многочисленных функциональных тестов Symfony и некоторых модульных тестов, которые заполнили некоторые пустоты. Общее покрытие было около 50-52 %, но покрытие критически важной функциональности было гораздо выше. Это придавало достаточно уверенности, чтобы добавлять новые функции без ручного тестирования.
Тем, кто пришёл из других экосистем, поясню, что означает термин «функциональные тесты» в Symfony. В документации дано такое определение:
Функциональные тесты проверяют интеграцию разных уровней приложения (от маршрутизации до представлений (views).
По сути, это сквозные тесты. Вы пишете код, который отправляет приложению HTTP-запросы. Вы получаете HTTP-ответ и на его основе делаете предположения. Однако вы можете обратиться к базе и внести изменения уровне хранения данных, что иногда даёт дополнительные возможности по проверке состояния.
Хочу дать несколько советов на основании моего опыта написания функциональных тестов Symfony.
Тестирование вместе с уровнем хранения данных
Вероятно, первое, что вы хотите сделать при прогоне функциональных тестов, — это отделить тестовую базу от базы разработки. Это делается для создания чистого стенда для прогона тестов и позволяет создавать и управлять нужным состоянием приложения. К тому же нежелательно, чтобы тесты записывали случайные данные в копию базы разработки.
Обычно при прогоне тестов Symfony-приложение подключают к другой базе. Если у вас Symfony 4 или 5, то вы можете определить в файле
.env.test
переменные окружения, которые будут использоваться при тестировании. Также сконфигурируйте PHPUnit, чтобы поменять переменную окружения APP_ENV
на test
. К счастью, это происходит по умолчанию, когда вы устанавливаете компонент Symfony PHPUnit Bridge.Для версий ниже 4 можно применять загрузку ядра (kernel booting) в режиме тестирования, когда прогоняешь функциональные тесты. С помощью файлов
config_test.yml
можно определять свою тестовую конфигурацию.LiipFunctionalTestBundle
Этот пакет содержит некоторые важные вспомогательные инструменты для написания тестов Symfony. Иногда он пытается сделать слишком много и может мешать, но в целом облегчает работу.
Например, при тестировании вы можете эмулировать вход пользователя, загружать фикстуры (fixture) данных, подсчитывать запросы к БД для проверки производительности регрессий, и т.д. Рекомендую установить этот пакет в начале тестирования нового Symfony-приложения.
Очистка БД после каждого теста
Когда нужно очищать базу при тестировании? Инструментарий Symfony не даёт подсказки. Я предпочитаю обновлять базу после каждого тестового метода. Тестовый набор выглядит так:
<?php
namespace Tests;
use Tests\BaseTestCase;
class SomeControllerTest extends TestCase
{
public function test_a_registered_user_can_login()
{
// Clean slate. Database is empty.
// Create your world. Create users, roles and data.
// Execute logic.
// Assert the outcome.
// Database is reset.
}
}
Я обнаружил, что прекрасный способ очистки БД — загрузить пустые фикстуры в особый метод
setUp
в PHPUnit. Вы можете сделать это, если у вас установлен LiipFunctionalTestBundle.<?php
namespace Tests;
class BaseTestCase extends PHPUnit_Test_Case
{
public function setUp()
{
$this->loadFixtures([]);
}
}
Создание данных
Если начинать каждый тест с пустой БД, то нужно иметь несколько утилит для создания тестовых данных. Это могут быть модели создания БД или объекты сущностей.
В Laravel есть очень простой метод с фабриками моделей. Я стараюсь следовать такому же подходу и делаю интерфейсы, создающие объекты, которые я часто использую в тестах. Вот пример простого интерфейса, который создаёт сущности
User
:<?php
namespace Tests\Helpers;
use AppBundle\Entity\User;
trait CreatesUsers
{
public function makeUser(): User
{
$user = new User();
$user->setEmail($this->faker->email);
$user->setFirstName($this->faker->firstName);
$user->setLastName($this->faker->lastName);
$user->setRoles([User::ROLE_USER]);
$user->setBio($this->faker->paragraph);
return $user;
}
Я могу добавлять такие интерфейсы в желаемый тестовый набор:
<?php
namespace Tests;
use Tests\BaseTestCase;
use Tests\Helpers\CreatesUsers;
class SomeControllerTest extends TestCase
{
use CreatesUsers;
public function test_a_registered_user_can_login()
{
$user = $this->createUser();
// Login as user. Do some tests.
}
}
Замена местами сервисов в контейнере
В Laravel-приложении очень легко поменять в контейнере сервисы местами, а в Symfony-проектах это сложнее. В версиях Symfony 3.4 — 4.1 сервисы в контейнере помечаются как приватные. Это означает, что при написании тестов вы просто не можете обращаться к сервису в контейнере и не можете задать другой сервис (заглушку).
Хотя некоторые утверждают, что при функциональных тестах не нужно использовать заглушки, возможны ситуации, когда у вас нет песочницы для сторонних сервисов, а вы не хотите давать им случайные тестовые данные.
К счастью, в Symfony 4.1 можно обращаться к контейнеру и менять сервисы по своему желанию. Например:
<?php
namespace Tests\AppBundle;
use AppBundle\Payment\PaymentProcessorClient;
use Tests\BaseTestCase;
class PaymentControllerTest extends BaseTestCase
{
public function test_a_user_can_purchase_product()
{
$paymentProcessorClient = $this->createMock(PaymentProcessorClient::class);
$paymentProcessorClient->expects($this->once())
->method('purchase')
->willReturn($successResponse);
// this is a hack to make the container use the mocked instance after the redirects
$client->disableReboot();
$client->getContainer()->set(PaymentProcessorClient::class, $paymentProcessorClient)
}
}
Но обратите внимание, что при функциональном тестировании ядро Symfony может загрузиться пару раз в течение теста, пересобирая все зависимости и отбрасывая вашу заглушку. Сначала это сложно заметить, и я пока не нашёл элегантного решения.
Выработанный мной обходной путь заключается в том, чтобы отключить перезагрузку ядра и убедиться, что в течение жизни тестового метода используется один и тот же экземпляр ядра. Тогда ядро не будет перекомпилировано, а заглушка не будет потеряна. Убедитесь, что это не приводит к побочным эффектам.
Исполнение SQLite в памяти
SQLite очень часто используют в качестве уровня хранения данных при тестировании, потому что он очень компактен и очень легко настраивается. Эти качества также делают его очень удобным для использования в CI/CD-окружении.
SQLite является бессерверным, то есть программа будет писать и читать все данные из файла. Наверняка это станет узким местом с точки зрения производительности, потому что добавляются операции ввода-вывода, завершения которых придётся ждать. Поэтому можете использовать опцию in-memory. Тогда данные будут писаться и читаться из памяти, что может ускорить операции.
При конфигурировании БД в Symfony-приложении не надо указывать файл
database.sqlite
, достаточно передать его с ключевым словом :memory:
.Исполнение SQLite в памяти с tmpfs
Работать в памяти — это здорово, но мне было очень трудно сконфигурировать этот режим со старой версией LiipFunctionalTestBundle. Если вы тоже с этим сталкивались, то есть такая хитрость.
На Linux-системах можно выделить часть оперативной памяти, которая будет вести себя как обычное хранилище. Это называется tmpfs. По сути, вы создаёте tmpfs-папку, кладёте в неё файл SQLite и используете для прогона тестов.
Можете использовать тот же подход и с MySQL, однако настройка будет посложнее.
Тестирование уровня Elasticsearch
Как и в случае с подключением к тестовому экземпляру базы данных, вы можете подключиться к тестовому экземпляру Elasticsearch. Или ещё лучше: можете использовать другие имена индексов, чтобы таким образом разделить окружения тестирования и разработки.
Тестирование Elasticsearch выглядит простым, но на практике может доставить трудностей. У нас есть мощные инструменты для генерирования схем базы данных, создания фикстур и наполнения баз тестовыми данными. Этих инструментов может и не быть, когда дойдёт дело до Elasticsearch, и вам придётся создавать собственные решения. То есть может быть непросто взять и начать тестировать.
Также есть проблема индексирования новых данных и обеспечения доступности информации. Частая ошибка — интервал обновления Elasticsearch. Обычно индексированные документы становятся доступными для поиска спустя заданный в конфигурации промежуток времени. По умолчанию это 1 секунда, это может стать затыком для ваших тестов, если вы будете невнимательны.
Использование фильтров Xdebug для ускорения отчёта о покрытии
Покрытие — важный аспект тестирования. Не нужно относиться к нему как к простому числу, это помогает найти не протестированные ветки и потоки в коде.
Обычно оценкой покрытия занимается Xdebug.
Вы увидите, что запуск анализа покрытия ухудшает скорость тестов. Это может стать проблемой в CI/CD-окружении, где каждая минута стоит денег.
К счастью, можно сделать некоторые оптимизации. Когда Xdebug генерирует отчёт о покрытии исполняющихся тестов, он делает это для каждого PHP-файла в тесте. Сюда входят и файлы, находящиеся в вендорской папке — код, который нам не принадлежит.
Настроив фильтры покрытия кода в Xdebug, мы можем заставить его не генерировать отчёты по файлам, которые нам не нужны, сэкономив немало времени.
Как создать фильтры? Это может сделать PHPUnit. Нужна только лишь одна команда для создания файла конфигурации фильтра:
phpunit --dump-xdebug-filter build/xdebug-filter.php
А затем передаём этот конфиг при прогоне тестов:
phpunit --prepend build/xdebug-filter.php --coverage-html build/coverage-report
Инструменты распараллеливания
Прогон функциональных тестов может занимать немало времени. Например, полный прогон набора из 77 тестов и 524 предположений может занять 3-4 минут. Это нормально, учитывая, что каждый тест делает кучу запросов к базе данных, генерирует грозди шаблонов, запускает по этим шаблонам краулеры и делает определённые предположения.
Если вы откроете монитор активности, то увидите, что тесты задействуют только одно ядро вашего компьютера. Задействовать другие ядра можно с помощью инструментов распараллеливания вроде paratest и fastest. Их легко настроить для прогона модульных тестов, а если нужны подключения к базе данных, то может потребоваться повозиться.
Я смог использовать 6 ядер и 12 потоков на своём компьютере, и вместо 4 минут прогонял тесты за 25-30 секунд.
Дополнение: Некоторые полезные комментарии к этой статье можно почитать на r/php. А здесь вы найдёте другие советы и информацию об инструментах.
Rosh1ck
Как удачно! У меня как раз есть задачи по написанию тестов для Symfony. Спасибо за статью.