Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Наша команда отвечает за продажи в Skyeng, личный кабинет и CJM пользователя до оплаты. Изначально проект был написан на Symfony 4.4 и представлял собой набор слабо связанных компонентов, которые были ответственны за правила работы для фронтенда.
Например, можно было получить или сохранить данные из базы и построить правильный редирект в зависимости от состояния пользователя при входе на главную страницу. Состояние определяется действиями студента: только что зарегистрировался, записался на вводный урок, оплатил занятия и так далее.
У нас были лишь юнит-тесты: каждый покрывал логику одного класса. Все тесты вместе давали покрытие основной логики кода и гарантию, что все работает правильно. Но 100% покрытие кода тесты не обеспечивали. И сейчас не обеспечивают.
Код был сравнительно простой, простые были и тесты. В то время мы еще полностью не управляли CJM пользователя. Но когда это понадобилось, существовавших юнит-тестов оказалось недостаточно. И мы обратились к функциональным.
Немного об оркестрации
CJM пользователя мы управляем с помощью оркестрации продаж. Это система, которая позволяет программно выстраивать автоматическую воронку так, чтобы инструменты продаж последовательно сменяли друг друга, начиная от самых дешевых.
Самые дешевые инструменты — это лендинги. Они дают пользователю информацию и возможность сделать покупку. Далее идут демо-уроки, которые знакомят человека с платформой без привлечения методиста. Самый дорогой инструмент продаж — вводный урок. Он позволяет пройти полноценное занятие вместе с методистом, задать вопросы. Помимо этого есть продажа через операторов, которая тоже довольно дорогая, ведь операторам нужно платить.
В итоге, мы выстраиваем воронку продаж для каждого продукта и должны гарантировать, чтобы флоу работал, как было задумано, и не было провисаний, когда продажа не происходит. Суть оркестрации — в подключении нужных инструментов продаж в определенной последовательности.
У оркестрации есть проблемы. Их несколько:
Нужно контролировать логику флоу. Разные флоу могут настраивать отдельные инструменты продаж и то, что инструмент в целом работает верно, не гарантирует, что будет правильно работать отдельно взятое флоу с этим инструментом.
Флоу работает растянуто во времени. Через определенные промежутки времени нам надо делать те или иные действия. Например, подключить звонок оператором, если пользователь не отвечает на сообщение в WhatsApp, и тому подобное. Юнит-тестом это не проверить.
Технически флоу работает асинхронно, выделяя отдельные задачи в потоки, которые могут выполняться как сиюминутно, так и отложенно, в свою очередь порождая новые задачи.
На помощь нам пришли функциональные тесты на базе Codeception.
Добавляем функциональные тесты
Во-первых, мы получили возможность прокручивать весь жизненный путь юзера — от регистрации до оплаты. А также эмулировать возникновение любых событий, которые могут с ним произойти до оплаты: прошел демо-урок, оставил заявку на вводный урок, отменил вводный урок, прошел вводный урок и запросил другую услугу, ничего вообще не делал и так далее.
Но это не все. Теперь мы также можем:
Проводить сложные рефакторинги без опасения сломать уже существующий код.
Тестировать изменения бэкенда без привлечения тестировщиков. Здесь остановлюсь подробней.
В конце 2021 года в команду занесли специфический проект: часть коммуникаций с пользователем требовалось автоматизировать и вместо подключения операторов слать пользователю сообщения в WhatsApp. Был предложен довольно сложный флоу, для реализации которого, пришлось изменить и добавить много кода.
В общем случае мы бы сделали задачу, отдали на тестирование, лишь после этого влили бы ее в мастер и выкатили на прод. Причем за время разработки у нас накопилось бы множество конфликтов, а их решение — время. Но мы делали не так: мы писали код небольшими блоками, покрывали функциональным тестами и сразу катили на прод.
Таким образом, мы гарантировали и работоспособность кода, и обошли возможные конфликты. Тестировщиков для регресса не привлекали, так как проект выкатывался без запуска на боевых юзерах — нам было достаточно убедиться в том, что не происходит поломка существующего кода (регресс), с чем тесты хорошо справились.
Эмулировать баги во флоу, повторяя шаги воспроизведения в тесте. Об этом расскажу подробнее в части про TDD.
TDD
Итак, последний блок флоу готов, вся задача оказалась на проде. После были подключены тестировщики, которые нашли немало багов.
Тестировщик при нахождении бага формулирует задачу, в которой в том числе указывает шаги воспроизведения. Здесь оказалось удобно идти по TDD: сразу писать тест, повторяя указанные шаги, чтобы возникла ошибка (тест должен падать), после чего фиксить баг и проверять, что тест теперь проходит. Такие фиксы безопасно можно катить на прод, привлекая тестировщика уже там, минуя тестинг.
Вот шаги из реальной задачи от тестировщика:
Подать заявку.
Пройти демо-урок (ДУ).
Получить первый триггер через 15 минут, не читать его более 2 часов.
Дождаться задачи на оператора.
Выполнить задачу с решением «перезвонить» на следующий день.
Прочитать первое сообщение.
Подождать исполнения таски на отправку сообщения после завершенного ДУ.
Ожидаемый результат: Задача на оператора первой линии не отменяется.
Фактический результат: Задача отменяется.
Соответственно был написан тест и баг исправлен. Такой процесс ускоряет фикс багов и гарантирует, что баг не возникнет в дальнейшем. По TDD можно работать и при разработке фич, но мы так не делаем — фичи крупные и предварительное написание тестов не ускорит разработку. Мы сначала пилим фичи, а после покрываем их тестами.
Хрупкость тестов
Хрупкие тесты — это тесты, которые падают при изменении внутренней реализации логики и сохранении внешнего интерфейса. При разработке функциональных тестов этого важно избегать.
Приведу пример: если есть API с определенным контрактом (запрос/ответ) — тест не должен падать при изменении реализации этого API с сохранением контракта, то есть при рефакторинге. Тест не должен ничего знать про внутреннюю реализацию API. То же самое касается более сложных бизнес-процессов: тест всегда должен работать с кодом как с черным ящиком, не зная, как процесс устроен внутри.
Пара слов про пирамиду тестов…У нас ее нет :) Иначе огромное число юнит-тестов получится хрупкими. При большом рефакторинге затрагивается много классов — неизбежно упадут тесты. Каждая команда должна самостоятельно найти баланс между количеством хрупких юнит-тестов и желанием не тратить на них слишком много времени при изменении кода. В нашем случаем мы покрываем тестами только какие-то локальные классы со сложной бизнес-логикой.
Кстати, про пирамиду тестов советую вот эту статью.
Проблема внешних зависимостей функциональных тестов
У нас много внешних зависимостей. В определенные моменты код делает обращения к внешним сервисам и нужно уметь мокать подобные вещи. Здесь есть два подхода.
Первый: мы создаем отдельную версию класса (dummy), которая подсовывается в контейнер для тестового окружения.
Второй: используем мок-сервера.
Первый подход не очень удобен. Приходится писать кучу кода для создания дамми-сервисов, но стоит признать, что выполняются такие тесты быстрее, чем тесты на базе мок-сервера.
Для тестов мы используем фреймворк Codeception. Вместе с ним используем мок-сервер mcustiel/phiremock в связке с mcustiel/phiremock-codeception-extension и mcustiel/phiremock-codeception-module. Идея мок-сервера, в том, что на неком локальном адресе появляется сервер, который может ответить на любой запрос мокнутым ответом, что позволяет эмулировать любые возможные ситуации.
Хелперы + моки = DSL
Хелперы и моки — это отдельные классы, которые единообразно подключаются средствами Codeception и, с его точки зрения, ничем не отличаются. Для нас они отличаются семантикой: в классах моков мы мокаем интеграции при помощи Phiremock, а в хелперах делаем методы, скрывающие технические детали прохождения по флоу. Все эти классы вместе дают нам наш DSL.
Приведу пример хелпера (полный листинг файла здесь):
<?php
namespace Cbm\Tests\Helper;
use Cbm\Controller\Amqp\MessengerMessage\StatusCreatedConsumer;
use PhpAmqpLib\Message\AMQPMessage;
class MessengerFlowHelper extends BaseModule
{
public function haveActiveMessengerFlow(?SalesToolId $salesToolId = null): void
{
// подготавливает флоу, инициализирует и сохраняет контекст в хелпер
// сохраняет просто в память для последующих обращений к контексту из тест-кейсов
}
public function sendMessageToUser(): UuidInterface
{
// создает рандомное сообщение и отправляет его текущему юзеру
// текущий юзер берется из сохраненного в хелпере контекста
}
public function setLastMessageAnswered(int $userId): void
{
// метод, который отметит последнее собщение юзеру, как отвеченное юзером
// используется консумер, который реагирует на событие отметки сообщения
// подготавливает сообщение и отправляет его прям в консумер
}
public function setMessageRead(string $uuid): void
{
// эмулирует событие в рэббит о том, что сообщение прочитано
$message = new AMQPMessage(
json_try_encode([
'name' => 'read',
'message_id' => mt_rand(),
'message_uuid' => $uuid,
]),
);
$functionalModule = $this->getFunctionalModule();
/** @var StatusCreatedConsumer $consumer */
$consumer = $functionalModule->getSymfonyServiceByClassName(StatusCreatedConsumer::class);
$consumer->execute($message);
}
}
Таким образом, мы скрыли технические детали в отдельном классе.
Теперь приведу пример мок-файла:
<?php
namespace Cbm\Tests\Mock;
use Cbm\Tests\Helper\BaseModule;
use Codeception\Util\HttpCode;
use Mcustiel\Phiremock\Client\Phiremock;
use Mcustiel\Phiremock\Client\Utils\A;
use Mcustiel\Phiremock\Client\Utils\ConditionsBuilder;
use Mcustiel\Phiremock\Client\Utils\Is;
use Mcustiel\Phiremock\Client\Utils\Respond;
class OverbookingMocks extends BaseModule
{
public function wantMockOverbookingCancelIntroLesson(): void
{
// средствами phiremock разрешаем запрос из кода на удаление букинга
$mock = $this->getPhiremock();
// запрос на удаление букинга используется в моке 2 раза,
// на разрешение запроса и на проверку, что запрос действительно был
// поэтому завернем сам запрос в приватный метод _getDeleteRequestOverbookingDeleteIntroLesson
// чтобы держать запрос в одном месте
$requestBuilder = $this->_getDeleteRequestOverbookingDeleteIntroLesson();
// такая конструкция просто разрешит коду сделать данный запрос,
// однако, если запроса не было, код не упадет, так как здесь нет ассерта
$mock->expectARequestToRemoteServiceWithAResponse(
Phiremock::on($requestBuilder)->then(Respond::withStatusCode(HttpCode::OK)->andBody(json_try_encode([]))),
);
}
public function seeIntroLessonWasCanceled(): void
{
// этот метод можно использовать, если есть необходимость сделать ассерт, о том,
// что метод действительно был вызван
$requestBuilder = $this->_getDeleteRequestOverbookingDeleteIntroLesson();
/** @var object[] $requests */
$requests = $this->getPhiremock()->grabRequestsMadeToRemoteService($requestBuilder);
$this->assertCount(1, $requests, 'No found requests for cancel intro lesson');
}
private function _getDeleteRequestOverbookingDeleteIntroLesson(): ConditionsBuilder
{
return A::deleteRequest()->andUrl(Is::equalTo('/server-api/v1/booking/delete'));
}
}
Здесь мы используем соглашение имен Codeception, когда методы, начинающиеся с «want», как бы разрешают обратиться к определенной API-точке и получить заданный у нас в коде результат. А методы, начинающиеся с «see», — это ассерты. В данном случае при вызове внутри теста wantMockOverbookingCancelIntroLesson код может обратить к точке DELETE /server-api/v1/booking/delete и получить в ответ 200. При этом Phiremock позволяет четко настраивать ожидаемый запрос, вплоть до точного указания всех параметров. А при вызове seeIntroLessonWasCanceled в тесте, тест упадет, если такой вызов не был произведен.
Для подключения хелперов и моков мы используем стандартные возможности Codeception для расширения, через конфигурацию, далее листинг файла functional.suite.yml:
actor: FunctionalTester
modules:
enabled:
- Asserts
- Symfony:
app_path: 'src'
environment: 'test'
kernel_class: 'Cbm\Kernel'
cache_router: 'true'
- \Cbm\Tests\Helper\Functional
- Db:
dsn: "pgsql:host=%DATABASE_HOST%;port=%DATABASE_PORT%;dbname=%DATABASE_NAME%_test"
user: "%DATABASE_USER%"
password: "%DATABASE_PASSWORD%"
- AMQP:
host: '%RABBIT_MQ_HOST%'
port: '5672'
username: '%RABBIT_MQ_USERNAME%'
password: '%RABBIT_MQ_PASSWORD%'
queues: [c1_business_manager.data_for_teacher_schedule_received_to_customerio]
- Phiremock:
host: '%PHIREMOCK_HOST%'
port: '%PHIREMOCK_PORT%'
reset_before_each_test: true
- \Cbm\Tests\Mock\AbTestMocks
- \Cbm\Tests\Mock\TrafficSplitterDbMock
- \Cbm\Tests\Mock\Crm2Mocks
- \Cbm\Tests\Mock\OverbookingMocks
- \Cbm\Tests\Mock\IdMocks
- \Cbm\Tests\Mock\ProfileMocks
- \Cbm\Tests\Mock\InternalMarketingMocks
- \Cbm\Tests\Mock\VimboRoomsMocks
- \Cbm\Tests\Mock\CommunicationsMocks
- \Cbm\Tests\Mock\CustomerIoMocks
- \Cbm\Tests\Mock\StudCubMocks
- \Cbm\Tests\Mock\ClickhouseEventAnalyticsMocks
- \Cbm\Tests\Helper\FlowHelper
- \Cbm\Tests\Helper\DemoFlowHelper
- \Cbm\Tests\Helper\MessengerFlowHelper
- \Cbm\Tests\Helper\AnalyticsSplitHelper
- \Cbm\Tests\Helper\FlowByClientHelper
Внутри файла используются переменные окружения %DATABASE_HOST%, %DATABASE_USER% и другие. Эти переменные имеют разные значения на проде, локальных машинах и на тестингах.
Отдельно стоит упомянуть, что описанный выше подход позволяет эмулировать ошибочные ответы сторонних систем и писать тесты на эти кейсы.
Работа с базой и RabbitMQ
Базу и Rabbit мы не мокаем, а используем отдельные инстансы для прогона тестов. Для этого у нас есть отдельная база на локальных машинах. Для базы есть вспомогательный скрипт, который ее создает при необходимости и накатывает миграции средствами доктрины. Также используется отдельный инстанс RabbitMQ в связке с расширением codeception/module-amqpю Подробнее про расширение можно почитать здесь. При этом чаще всего используем просто эмуляцию прихода события (см. метод setMessageRead, описанный выше в листинге класса MessengerFlowHelper).
Пример теста
Основная сложность тестов – нужно писать их так, чтобы были читаемыми, убирая технические подробности и не замусоривая код теста. Как описано выше, это достигается внятным DSL через хелперы и моки. Сам же тест выглядит так:
<?php
namespace Cbm\Tests\Functional\Application\Orchestration\Flow\MessengerFlow;
use Cbm\Application\Orchestration\Entity\SalesToolId;
use Cbm\Tests\FunctionalTester;
class MessengerFlowCest
{
public function testPassIntrolesson(FunctionalTester $I): void
{
$I->haveActiveMessengerFlow();
$I->after('8 minutes');
$I->goToSelfboking();
$I->bookIntroLessonAfter('2 minutes');
$I->seeInContext('salesToolId', SalesToolId::MESSENGER_ADULT_ENGLISH_INTRO_LESSON_BOOKING());
$I->passIntroLesson();
$I->after('4 minutes');
$I->seeInContext('salesToolId', SalesToolId::MESSENGER_ADULT_ENGLISH_PUSHING_TO_PAYMENT());
}
}
Код должен читаться как простой английский текст (с возможной скидкой на знание английского). Для этого используем внутренний предметный язык (DSL), который базируется на хелперах и моках. Конечно, чтобы хорошо читать такой текст, надо быть погруженным в предметную область. Посторонний человек его не поймет. Переведу этот тест на русский, как я его понимаю:
Я попал во флоу с передачей сообщений через мессенджер.
Прошло 8 минут.
Я ушел на селфбукинг (это возможность самостоятельно записаться на вводный урок).
Я записался на вводный урок через 2 минуты.
Я вижу, что сейчас идет продажа через вводный урок.
Я прошел вводный урок.
Прошло 4 минуты.
Я вижу, что сейчас идет продажа через звонок оператора.
То есть для меня, как для члена команды, код теста является понятным. Такая понятность достигается тем, что тест-кейс не содержит никаких технических деталей, а представляет собой последовательность вызовов метод от лица пользователя или системы. Все технические детали мы скрыли во вспомогательных классах. Помимо этого одни и те же вызовы вспомогательных классов используются для написания разных тест кейсов, что помогает избегать копипасты.
GitLab пайплайн и покрытие кода тестами
У нас используется GitLab как хранилище кода. Команда инфраструктуры настроила в нем много плюшек, которые можно использовать в проектах. Одна из них — прогон автотестов при каждом пуше в мерж-реквесте. После прогона тестов автоматически генерируется Codec Coverage-репорт. Данные из отчета отображаются в мерж-реквесте, а также в баджике в Readme-файле.
Для расчета степени покрытия кода тестами мы используем PHP-расширение PCOV. Отчеты показывают степень покрытия строк кода. Это не точный метод, но даже по этому показателю степень покрытия у нас составляет всего 53.5%. Кажется, что переходить на более точные методы вычисления покрытия кода, такие как branches и paths, нам пока рано.
По какому принципу мы выбираем, какой функционал покрыть дальше? При наличие свободного окна на закрытие техдолга, покрываем следующий наиболее критичный для бизнеса кусок. Причем покрываем именно бизнес-логику, а инфраструктуру как получится.
Эмуляция запросов с фронтенда
С помощью функциональных тестов на базе Codeception удобно описывать кейсы взаимодействия фронтенда и бэкенда.
С точки зрения бэкенда, поведение пользователя на вебе сводится к вызовам определенных API-точек. Мы эмулируем вызов API-точки, после чего проверяем состояние через вызов другой API-точки — проверяем код и тело ответа. Если же точки для проверки нет, можно проверить результат вызова по базе данных или иным способом.
Последовательность таких проверок превращается в сценарий. Если вы используете хороший DSL, то читаемость тестов будет высокой, а хрупкость — низкой, так как тест будет лишь использовать контракт точки, но ничего не знать про внутреннюю реализацию точки. Здесь можно заметить, что проработка хорошего DSL сложна и часто не имеет большого смысла. Можно остановиться на вызове конкретных точек прямо в тест-кейсе. Про тестирование API-точек есть статья в документации.
О скорости выполнения тестов и производительности
Сейчас у нас 238 тестов, которые делают 940 ассертов (функциональные тесты).
Время выполнения тестов в GitLab-пайплайне ~7 минут.
Время выполнения тестов локально ~8 минут.
Понятно, что это долго. Что же мы пытались делать, чтобы ускорить?
Во-первых, там, где можно, мы уходим от инициирующих http-запросов (создание пользователя внутри нашей системы) и делаем эмуляцию запроса — кидаем бизнесовое событие «Пользователь создан». Таким образом, на http-запросах экономим время. Это небольшой компромисс между хрупкостью тестов и скоростью выполнения.
Следующее узкое место — внешние интеграции. К примеру, создание пользователя во внешней системе. Как писал выше, мы используем мок-сервер. С одной стороны — он дает удобство разработки, с другой — существенно замедляет работу, так как приходится делать внешний запрос. Здесь у нас никакого решения для ускорения нет. Мок-сервер является основной причиной медленной работы тестов.
Если у кого-то есть решение проблемы — буду рад, если поделитесь в комментариях.
Итоги
Зачем нужны тесты? Улучшить качество кода, уменьшить риски возникновения ошибок на проде. Надо понимать, что качество кода достигается не только тестами — это и процессы внутри команды, и контроль бизнес-логики, статанализ, код-стайл. И, конечно, тестирование командами тестировщиков.
Сами тесты не гарантируют 100% качество даже при 100% покрытии кода. При этом в тестах нет человеческого фактора. Постоянный регресс убивает мотивацию тестировщиков, а автотесты запускаются при каждом пуше и не устают. Тестировщик может ошибиться, как впрочем и неправильно написанный тест. Тестировщики и тесты хорошо дополняют друг-друга. Так и обеспечивается качество кода.
Большой объем функциональных API-тестов сведет риск случайного нарушения контрактов к минимуму и облегчит регрессионное тестирование.
Есть развернутая статья Код без тестов — легаси. Полностью согласен с этой мыслью.
Важный момент: если ваш код имеет покрытие более 75%, вы получаете уверенность в нем и некоторые задачи можете катить на прод без ручного тестирования. Тем самым уменьшается time-to-market, что уже несет большую бизнесовую ценность. И да, важно не писать тесты хрупкими, иначе они будут не уменьшать time-to-market, а увеличивать его.