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

  1. Я попал во флоу с передачей сообщений через мессенджер.

  2. Прошло 8 минут.

  3. Я ушел на селфбукинг (это возможность самостоятельно записаться на вводный урок).

  4. Я записался на вводный урок через 2 минуты.

  5. Я вижу, что сейчас идет продажа через вводный урок.

  6. Я прошел вводный урок.

  7. Прошло 4 минуты.

  8. Я вижу, что сейчас идет продажа через звонок оператора.

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

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, а увеличивать его.