Всем привет! Я QA Engineer в Scalable Solutions. Наша команда отвечает за работу сердца биржи – биржевого ядра, которое процессит регистрацию, сведение торговых заявок, проведение различных проверок и выполняет ряд других важных операций. Мы уже писали про специфику тестирования высоконагруженного бэкенда в финтехе, но сегодня я хочу рассказать, какое место в нашем процессе занимают компонентные тесты, и как мы их готовим.
Зачем нам это?
В отличие от интеграционных, компонентные тесты предполагают тестирование отдельного компонента изолированно от всей остальной системы. Для этого другие модули, с которыми взаимодействует тестируемый компонент, заменяются “искусственными” компонентами (моками), с помощью которых в тесте задают входные данные для компонента и контролируют выходные. Таким образом, тестируется один модуль, а не вся система целиком.
Такое тестирование необходимо, когда нет возможности провести тесты через внешние точки доступа.
Есть ряд коробочных инструментов, которые могут использоваться для построения моков. Сразу на ум приходят такие решения как Wiremock, FastAPI и MockServer. Однако для наших целей данные инструменты не подходят, т.к. для изолированного тестирования какого-либо компонента биржевого ядра требуется имитировать компоненты, способные подключаться по TCP, UDP, способные генерить сообщения определённого формата.
В том числе необходимо уметь генерить сообщения в бинарном виде, чего перечисленные выше инструменты не умеют, и предназначены в основном для архитектуры REST.
Особенности тестируемого приложения
Чтобы не углубляться в детали реализации, опишу верхнеуровнево, что из себя представляет биржевое ядро.
Главным компонентом является Matching, который регистрирует, сводит заявки и содержит order book (биржевой стакан). Вокруг Matching построены микросервисы, которые выполняют вспомогательные функции – списание фандингов в фьючерсной торговле, торговые гейты, риск менеджмент, рекавери системы и многие другие. В соответствии с тем, что приходит от Matching, микросервисы должны приводить своё внутреннее хранилище в консистентное состояние.
Так, например, при исполнении заявки все микросервисы получат информацию, что заявка исполнена. В случае же падения микросервиса, первым что он сделает после восстановления – запросит снапшот от системы восстановления, чтобы привести себя в консистентное состояние, т.к. пока сервис был недоступен, остальные компоненты работали, заявки сводились, отменялись и т.п.
Какую же проблему мы хотим решить и почему не можем использовать интеграционные тесты?
Покажу на примере нашего сервиса Activator. Сервис предназначен, как это следует из названия, для активации заявки. Что это значит? Существуют заявки, которые должны быть помещены в биржевой стакан только тогда, когда выполняется определенное условие.
Возьмём для примера стоп-лимитную заявку. Это заявка, которая размещается с заданной ценой, а также с ценой активации – stop_price. Триггером активации для стоп-лимитной заявки на покупку будет служить тот факт, что цена последней исполненной сделки (трейда) больше стоп-цены, которая задается при создании стоп-лимитной заявки. То есть получается такая схема:
Регистрация заявки в Matching, отправка в Activator --> Activator регистрирует стоп-лимитную заявку --> Заявка ожидает активации от Маtching --> Выполнился новый трейд с необходимым условием для активации заявки --> Activator активирует заявку, отправляет запрос на активацию в Matching --> Matching принимает запрос и помещает заявку биржевой стакан.
Выставляя заявки с разными направлениями и различными параметрами, мы можем проверить функциональное соответствие поведения cервиса Activator ожидаемому. Но давайте зададимся вопросом: что будет, если Activator по какой-либо внешней причине упадет, и за доли секунды пока будет восстанавливаться, заявка будет отменена пользователем или системой? Matching отправит сообщение об отмене. А что случится с Activator после восстановления? Не упадет ли он опять? Не будет ли слать в Matching запрос на активацию уже отмененной заявки?
Другой пример. Что будет, если во время своей работы упадёт сервис, который отвечает за периодическое списание процентов? Не начнёт ли он списания после восстановления с самого начала, таким образом дважды списав средства со счёта пользователя?
Ответы на эти вопросы как раз и дают компонентные тесты.
Подготовка теста
Что из себя представляет компонентный тест? Условно можно разделить такой тест на 2 составляющие:
Подготовка инфраструктуры,
Выполнение непосредственно логики теста.
Если выполнение тестовой логики не вызывает особых сложностей, то с подготовкой инфраструктуры всё гораздо сложнее, так как по сути мы должны написать код, который будет имитировать поведение реальной системы:
Получать и отправлять сообщения,
Правильно сериализовать сообщения в бинарный формат и отправлять сообщения компонентам,
Подготовить БД, настроить конфигурацию системы (пользователи, брокеры, символы и прочие бизнес-сущности),
Соблюсти правильную последовательность шагов при старте торговли,
Хранить во внутреннем хранилище заявки, удалять их при исполнении из хранилища.
При этом всём, если нам необходимо протестировать поведение Matching, задача выглядит проще, т.к. по сути нам требуется заменить настоящий компонент на мок и уже им получать/отправлять сообщения в Matching. Но в случае, когда мы хотим протестировать какой-то компонент вокруг Matching, наступает время упражнений, описанных выше, т.к. тестируемый компонент реальный, а поток сообщений, логика работы Matching реализуется моком Matching.
Для подготовки инфраструктуры необходимо разобраться, как под капотом работает система. В этом нам поможет документация по коду приложения, которая может быть не идеальна. Поэтому необходимо много общаться с разработчиком, который реализовал тестируемый функционал, а также смотреть в код приложения.
Представим, что мы разобрались с тем, как работает система, и что конкретно мы будем эмулировать. Теперь нам нужно следующее:
Написать кодек, который заготовленное сообщение конвертит в требуемый формат. Что также должно работать и в обратную сторону, т.к. необходимо уметь и вычитывать сообщения.
Описать каждое сообщение, которое может отправляться от Matching. Тут нам в помощь – код самой системы в качестве документации. Для инженера, который пишет тесты, задача, на первый взгляд, нетривиальная, но с течением времени и нескольких походов к автору кода системы для выяснения всех деталей привыкаешь и становится проще разобраться. Основная боль здесь – не пропустить какое-нибудь полюшко, из-за чего байты съедут, и это приведёт к развлечению с дебаггером на несколько часов. Например, сообщения отправляются в бинарном формате, при этом само сообщение не содержит пар ключ-значение, а только байты самих значений, которые записаны в определённой последовательности. На одном конце мы эту последовательность формируем, принимающая система по заданной спецификации вычитывает значения полей с определенным размером в определённой последовательности. Если попытаться вычитать поле, скажем, request_id и прочитать не 32 байта, а 33, то остальные поля съедут на этот байт и при конвертации увидим не валидную информацию по полям. Тут надо быть внимательным и аккуратным.
Написать паблишер и клиент, которые будут отправлять/вычитывать “сырые” байты сообщения.
Написать класс, который будет отвечать за подготовку конфигурации – готовить брокеров, клиентов, торговую пару, другие специальные настройки. Плюс описать логику хранения заявок/балансов.
Написать вспомогательные функции: вейтеры и функции, которые подготавливают сообщения с указанием необходимого статуса. Далее это подготовленное функцией сообщение будет отправляться паблишером.
Нужно подготовить инфраструктуру. А именно – поднять только необходимые компоненты, которые требуются для теста. Для нас это означает правку оркестратора по подготовке тестового окружения плюс некоторые внутренние вещи, на которых заострять внимание в рамках данной статьи избыточно.
Пример компонентного теста
После того как моки подготовлены, дело в шляпе – нам остается только выстроить логику теста. Возьмём один из реальных тестов для примера и посмотрим, что в нём происходит.
Данный тест проверяет логику работы Activator, моком в данном случае выступает Matching. Тут мы хотим проверить, что Activator перестанет присылать запрос на активацию ордеров, если Matching отправил сообщение о том, что ордер отменён.
@allure.title('Checking resending messages after order cancelation')
def test_stop_sending_after_order_canceled():
with allure.step('Prepare test env'):
matching = prepare_env()
with allure.step('Place new order'):
symbol = matching.symbol('BTCUSDT')
user_1 = matching.get_user(user_id=100001)
user_2 = matching.get_user(user_id=100002)
order_body = {'user': user_1, 'symbol': symbol, 'side': Buy, 'qty': 20,
'price': 100, 'order_type': StopLimit, 'stop_price': 105}
order = matching.make_new_order(order_body)
matching.send(order)
with allure.step('Set last trade price'):
matching.set_last_trade(105)
with allure.step('Wait for Activator sending order activation'):
req = matching.wait_for_msg(msg_type=OrderActivation)
assert isinstance(req, OrderActivation)
with allure.step('Wait for Activator sending order activation over again'):
req = matching.wait_for_msg(msg_type=OrderActivation)
assert isinstance(req, OrderActivation)
with allure.step('Send from Matching message OrderCancel'):
matching.send_order_canceled(order)
with allure.step('Verify, that PM does not send messages to trading server anymore'):
message = matching.wait_for_msg(msg_type=OrderActivation)
assert message is None
Шаг Prepare test env. Здесь, как следует из названия, подготавливается окружение. Поднимается мок Matching, подготавливается конфигурация для торговли.
Шаг Place new order. Здесь мок Matching размещает стоп-лимитную заявку на покупку. Функция make_new_order подготавливает сообщение о регистрации заявки, которое должен отправить мок Matching. Функция send сериализует это сообщение в требуемый формат и отправит компонентам. Поскольку заявка “стоповая”, Activator будет дожидаться триггера для активации. В данном случае таким событием будет являться сведение других заявок с ценой, которая больше или равна значению stop_price стоп-лимитной заявки.
Шаг Set last trade price. Здесь под капотом будет выполнено сведение двух встречных заявок, о чём мок Matching отправит сообщение в Activator. Это необходимо, чтобы получить цену сделки, которая будет триггером для нашей стоп-лимитной заявки.
Вот теперь начинается самое интересное, в последующих шагах мы проверяем логику работы Activator. По сути все подготовительные шаги мы делали именно для этого.
После того, как произошла сделка c необходимым условием, Activator отправит запрос в мок Matching на активацию стоп-лимитной заявки (шаг Wait for Activator sending order activation). В реальной системе Matching отправил бы сообщение о том, что стоп-лимитная заявка активирована и помещена в биржевой стакан. При этом Activator перестанет отправлять запрос на активацию. Но Matching – мок, и мы не хотим заявлять об активации заявки, т.к. хотим проверить, что по истечении некоторого таймаута Activator, не получив сообщение об активации, отправит запрос на активацию ещё раз. Проверяем это в шаге Wait for Activator sending order activation over again.
Теперь отправим от мока Matching сообщение о том, что заявка отменена в шаге Send from Matching message OrderCancel.
Финальная проверка. Убедимся, что Activator более не будет отправлять в Matching запрос на активацию ордера.
Плюсы и минусы подхода
Давайте же теперь разберёмся, какие плюсы и минусы данного подхода.
Плюсы:
Компонентные тесты позволяют проверить кейсы, которые невозможно либо очень сложно проверить интеграционными и end-to-end тестами.
Компонентные тесты позволяют отловить поведение, которое не предусмотрел разработчик. При этом функциональные тесты такое поведение не отловили бы, и проблемы вылезли бы на продакшене.
Минусы:
Инженер, который будет писать компонентные тесты, должен обладать более глубокой экспертизой. В том числе должен суметь разобраться в коде приложения, понять логику работы и правильно её воспроизвести в коде тестов.
Компонентные тесты дороже в разработке и поддержке, чем интеграционные, т.к. требуют больше времени и большей внимательности к мелочам. И сложнее в отладке.
Необходима полная исчерпывающая документация по проекту. В противном случае придётся дёргать разработчика для помощи, таким образом отнимая время более дорогого специалиста.
Что мы в итоге получили?
Известно, что больше всего багов автотесты находят непосредственно в процессе написания. И подготовка компонентных тестов не стала исключением. Именно при их подготовке мы отловили редкие кейсы, которые потенциально могли привести к серьезным проблемам, т.к. в высоконагруженном финтехе цена ошибки очень высока.
Также у нас выработалась некоторая культура написания тестов. При описании флоу сообщений и сущностей в коде фреймворка/тестов мы стараемся задавать имена переменных так же, как в коде системы. Таким образом, когда разработчик вносит правки в существующий код, он может сам поправить тесты/формат сообщений/сущности в тестовом фреймворке без привлечения инженера по тестированию. У нас считается хорошим тоном, когда разработчик правит тесты сам после исправления своего кода.
Ещё один плюс в том, что инженеры по тестированию глубже понимают работу системы, сущности, которыми оперирует разработчик, более плотно работают с логами системы.
Пожалуй, на этом все. Спасибо, что дочитали до конца. Хочется пожелать вам успехов во всех начинаниях, интересных вызовов и отсутствия пятничных деплоев!
Еще по теме:
Как устроен криптотрейдинг, с какими рисками нужно считаться, и в чем был прав Марк Твен
Фу, тестовое. Или 8 ошибок в заданиях для QA на живом примере
Подписывайтесь на @Scalable_Insights, где мы делимся аналитикой и инсайтами в new fintech!