Предисловие
Эта статья — продолжение первой части, я бы даже сказал затравочки “Интровертный” подход в тестировании API.
Напомню, что в первой части мы прошлись по:
-
Проблематике зависимостей от внешних партнеров при тестировании
-
Базовым понятиям о моках
-
Как и когда лучше применять описанные виды моков
-
Очень экспертно и профессиональноРешили, что в идеальном мире мы хотим использовать подход мок-сервисов
Если Вы не читали первую часть, но хорошо знакомы с моками – можете сразу читать вторую.
В этой части:
-
Очень кратко напомню, что такое сервисы-моки
-
Подумаем, как упростить себе жизни при написании сервисов-моков
-
Подробно и с примерами разберем лучшие практики при использовании подхода с сервисами-моками
-
Напишем минимальный пример на питоне
-
Выпьем пива за тех, кто решил этот подход использовать
Повторение — мать учения
Сервис-мок — это развёрнутая внутри компании упрощённая копия внешней системы.
Он эмулирует нужные вам хендлеры и бизнес-логику вендора, но полностью контролируется вашей командой.
Сложность поддерживаемых моком сценариев будет определяться только возможностями и фантазией тех, кто этот инструмент разрабатывает.
Является наиболее честным вариантом, с точки зрения встраивания в бизнес-процесс, бесшовно может быть использован в боевых сервисах.
Таким образом, сервисы-моки — наиболее продвинутый вариант с точки зрения качества продукта и решения проблемы тестирования интеграций с внешними системами.
Но главный минус в том, что если хотите сделать что-то хорошо — сделайте это сами.
Для этого я вас и собрал, сейчас попробуем справиться с этим небольшим, совсем крохотным нюансиком на пути к невероятным вершинам в мире тестирования и качества продукта
Лучшие практики при написании сервисов-моков
Если вы знакомы с моками и их реализациями, то часть пунктов будут для вас понятна, но для полноты картины о них стоит сказать:
Контекст
-
Для написания сервис-моков по возможности выбирайте язык программирования и фреймворки, которые используются в боевых сервисах, это позволит переиспользовать контракты, отдельные куски логики и инструменты.
Дополнительно: использование того же языка, если он еще не является вашим основным — повысит экспертизу, что тоже является огромным плюсом, как минимум при реверс-инженеринге потребителей для написания моков, а как максимум позволит лучше понимать, что там вообще пишут эти хитрые разработчики.
-
Релизный флоу, линтеры, форматтеры и прочие полезности также можно подрезать у разработки.
-
Старайтесь соответствовать стандартам написания кода в компании, поддерживайте обсервабилити/метрики/алерты/логи/версионирование/…, моку не обязательно быть месивом из костылей, когда есть возможность быть молодцом с красивой кодовой базой
-
Разгоняемся. Возвращаясь к началу истории, когда мы были молодыми и шутливыми – речь зашла о наличии и отсутствии тестовых стендов вендора.
Так вот, я написал, что по тем или иным причинам мы не можем использовать тестовый стенд вендора: нестабильность, слабая настраиваемость, невозможность проверки некоторых сценариев и так далее.
Но на самом деле вариант, когда вендор предоставляет тестовый стенд, все же лучше, чем когда тестового стенда нет, хотя основным и лучшим подходом все равно остаются моки.
Нюанс в том, что мок в какой-то момент может не отражать реального поведения внешней системы. Например, мок разработан давно, разработка вендора ушла вперед и мок еще не актуализирован.
Чтобы тестирование оставалось честным и минимизировать риски, когда поведение в среде тестирования и в боевой среде не совпадает – сделайте возможность параллельно тестировать и через мок и через тестовый стенд вендора.
Так вы сможете иметь высокое покрытие, стабильные и быстрые тесты, при этом быть уверенными, что они отражают реальное состояние системы.Вариантов реализации может быть много, зависит от вашей специфики, например, у нас в команде это:
-
роутинг на уровне сервиса потребителя, до похода в ваш сервис-мок:
-
запрос приходит в последний сервис цепочки вызовов
-
на основании данных в запросе сервис из шага 1 принимает решение: использовать хост/клиент/подключение вашего мока или реальный тестовый стенд
-
-
роутинг внутри вашего сервиса-мока:
-
запрос приходит в последний сервис цепочки вызовов, до вендора
-
в среде тестирования всегда будем ходить в мок
-
запрос уходит в ваш сервис-мок
-
на основании данных в запросе моком принимается решение: проксировать запрос в реальный тестовый стенд или обработать внутри себя.
-
-
Базовые принципы
-
Реализуйте только то, что требуется вашему бизнесу, не нужно поддерживать 100500 полей и функций, которые умеет обрабатывать вендор, если сервис вашей компании использует только конкретную часть.
Например, поступила задача: мок API ЦБ РФ.
В контексте задачи вы знаете, что ваша компания использует из всего API только одну ручку – получение сегодняшнего курса валюты и парсит оттуда только три поля из сотни отдаваемых.Не делайте калькулятор материальной выгоды по льготным кредитам, и другие сложные вещи, если такой задачи не стоит.
Не делайте фабрики для динамического создания и проброса тех полей, которые не используются, сделайте их, например, валидно-хардкодными.Cделайте только используемый функционал и сфокусируйтесь на той его части, с которой непосредственно работают сервисы-потребители, а все, что нужно будет в процессе развития бизнеса – поддержите далее, итеративно и по порядку.
Это позволит оперативнее начать интеграцию мока и использование его в тестировании, код мока будет явно отражать решаемые им задачи и в дальнейшем позволит четко определить зоны развития. -
Старайтесь делать простую реализацию в лоб, если не требуется иного.
Не забывайте, в чем именно задача мока – повысить тестовое покрытие, сделать тестирование проще, быстрее и надежнее.
Не всегда требуется реализовывать космолет или даже велосипед, особенно когда еще не сделано колесо. -
Явно разделяйте типы хендлеров. Базово у вас может быть два формата:
-
/business/… — эмулируют поведение внешней системы (те, куда ходят ваши сервисы);
-
/support/… — управляют состоянием мока из тестов.
Такое разделение упрощает архитектуру, навигацию по коду и дальнейшую поддержку.
-
-
Если у вас есть возможность реализации сценариев “по-честному”, то старайтесь максимально соответствовать реальному флоу. НО!
Если же все-таки вы вынуждены реализовать вспомогательные инструменты для настройки мока (подробно в разделе Обработка запросов, пункт 4), то человек, использующий мок, должен минимально задумываться о том, как работает и как настраивается сам мок, в коде авто-тестов это тоже должно выглядеть максимально просто.
Делайте управляющее API минималистичным. Если для сценария тест должен «заказать» ошибку, передавать нужно только те параметры, которые невозможно определить на стороне мока. Остальные поля заполняются значениями по умолчанию.
Это одно из выгодных отличий самописного мока от универсальных инструментов: вы адаптируете интерфейс под свою специфику, а не требуете от тестов собирать десятки ненужных полей.
Плохо: тест вынужден тащить все поля, даже те, что не влияют на сценарий:
requests.post(url, data={"key1": "123", "key2": 321, ..., "key99": True})Хорошо: только необходимое:
requests.post(url, data={"key": "123"})В контексте нашего небольшого примера звучит не так страшно, но если полей 50 или 100 или тест должен сам по крупицам собирать из внешних источников ненужные для его сценария поля – ситуация становится печальнее.
Каждый, кто будет не в контексте всех передаваемых данных потратит много времени и своего, и потенциально еще и времени автора мока, на разбирательство того, зачем эти поля вообще нужны и за что отвечают.
Код тестов станет менее читаемым, менее нативным и при ручном тестировании людям придется самостоятельно заполнять огромное количество полей, зачастую вообще не нужных в их конкретном сценарии
-
Если тесту требуется, чтобы мок ходил в API-хендлеры сервиса вашей компании – удостоверьтесь, что это действительно нужно.
Например:
-
в тесте надо инициализировать процесс получения возрастов клиентов, делается это в ручке
POST /user/sync_ages -
команда тестирования приходит в ваш мок и говорит: “сделайте нам функционал, чтобы запускать этот синк возрастов”
В данном случае мок будет выступать проксей, для работы с целевым сервисом.
Если этот процесс позволит поддержать консистентность, например:
-
если в
POST /user/sync_agesнужно отправить информацию, которая уже есть в моке -
или весь флоу работы с юзерами уже реализован и поддержан на уровне мока, то вызов
POST /user/sync_agesбудет логично так-же делать из мока. это позволит сохранить историчность запросов и сделает процесс более единообразным.
Но если вы не можете найти преимуществ, почему моку надо выполнять роль прокси – то, скорее всего, этого и не требуется. Тест может сам делать вызовы нужного ему
POST /user/sync_ages -
Обработка запросов
-
При определении архитектуры мока, поведением по умолчанию стоит сделать именно позитивный сценарий, условный “Успех”.
Обычно, позитивные сценарии — наиболее частая схема в тестах, может понадобиться и как целевая проверка, и как предусловие к более сложному сценарию.Скорее всего, “Happy path” будет наиболее используемым функционалом в вашем моке.
Соответственно и реализация получения этого позитивного сценария должна быть наиболее простой, быстрой и понятной, без дополнительных условий и требований со стороны пользователя (в нашем случае пользователем является тест).Например: У вас есть 10 тестов, которые используют мок-сервис.
8 из них ожидают от мока фактически одни и те же ответы, условно успешный результат, и только 2 теста хотят получить что-то специфическое, ошибку/таймаут или что-то, что отличается от поведения по умолчанию.
В таком случае, конечно, логично больше ориентироваться на 8 позитивных тестов. Но почему?Помимо технического упрощения на уровне тех же тестов, где дополнительные действия для получения нужного результата понадобятся в 2 тестах, вместо 8, есть и логическая польза.
Не углубляясь в тест-дизайн, 8 позитивных тестов, в среднем, будут увеличивать тестовое покрытие больше, чем 2 негативных.@router.post("/check/{user_id}")async def handle_check(_: str): return {"success": True} # по умолчанию не проводим никаких дополнительных операций, сразу отвечаем успехом -
Не меняйте контракты сервисов-потребителей.
Мок должен принимать и возвращать ровно те же структуры, что и реальный вендор.Если для получения особого ответа тесту хочется докинуть в запрос служебное поле — не делайте этого.
Такие поля могут утечь в прод, нарушить логику или раскрыть чувствительные данные. Вместо этого используйте механизм предподготовки ответов через отдельное API (о нём — в пункте 4)
Пример:
Тестирование хочет проверить ошибку: Для клиентов с ФИО Ошибкова Ошибка Ошибковна из мока должна возвращаться ошибка на запрос возраста клиента.Но тут выясняется проблема: мок ничего не знает о ФИО и о том, что его спрашивают о возрасте именно госпожи Ошибковины, получается и о требуемой ошибке он не знает и нужный тесту результат не отдаст.
Вырисовывается логичное решение: так давайте дополнительно передавать это самое ФИО в мок, в теле запроса или в хедерах, или еще как-то, чтобы мок понял, что вот сейчас настал тот самый момент, когда нужно ответить ошибкой!
Но делать так не стоит по многим причинам: если эта логика с пробросами ФИО вдруг утечет в продакшн, и внешние сервисы начнут получать информацию, которую они получать не должны, то тут потенциально может быть сразу несколько проблем:
-
В проде могут быть другие логические связи, например, подкладываемого в среде для тестирования ФИО, в реальных условиях может не быть. Если источником ФИО были тесты и тестировщики, то в проде боевой сервис будет пытаться работать с фактически несуществующим полем. Это влечет за собой как ошибки в сервисах вашей компании, так и в сервисах, куда вы будете засылать это новое поле, которое там не ждут.
-
Госпожа Ошибковна не хочет разглашать ни свой возраст, ни свое ФИО, и вообще это чувствительные данные, а мы тут ими разбрасываемся всем желающим, а исходя из примера, даже и не желающим.
-
Если проблемы из пунктов 1 и 2 не выстрелят вам в ногу сразу, то не расстраивайтесь, у вас еще будет шанс столкнуться с последствиями такого подхода!
Отсутствие честного тестирования, когда обмен данными не отражает реального бизнес-взаимодействия — это как долгосрочная инвестиция в костыли, баги, ошибки и финансовые потери компании.
А как же быть, если все же нужно получить эту ошибку или любой другой сценарий, отличающийся от поведения по умолчанию? С этим сейчас и разберемся.
-
-
Если на уровне сервиса-мока, есть возможность логически скалькулировать ответ, который не является ответом по умолчанию – это отлично.\ Такой подход позволяет получать сценарии без дополнительных предподготовок, что упрощает использование, позволяет сохранять меньше дополнительных артефактов и поддерживать меньше дополнительного контекста.
Например, мы знаем, что флоу состоит из:
-
создания клиента
POST /user/create/{user_id}Тут мы на уровне мока можем самостоятельно провалидировать, является ли создаваемый пользователь уникальным. И в зависимости от бизнес-требований поддержать реальное поведение вендорского решения, например, отвечать ошибкой дубликата или обновлять созданного клиента новыми переданными данными. Это также позволит поддержать “из коробки” тестируемые сценарии. -
установка возраста клиента
PUT /user/set_age/{user_id}Аналогично с предыдущим шагом можно самостоятельно провалидировать наличие юзера и ответить ошибкой, но теперь если клиент не найден. -
получения возраста клиента
GET /user/get_age/{user_id}Так же, как в шаге 2, клиент, по которому получают возраст – должен быть ранее создан, иначе возвращается какая-то конкретная бизнес-ошибка.
Пример сохранения клиента для дальнейшего использования в
GET /user/get_age/{user_id}@router.post("/user/create/{user_id}")async def handle_create(user_id: str): # По аналогии с вендорским решением возвращаем ожидаемую ошибку, если клиент уже был создан if user_id in user_table: raise HTTPException(status_code=400, detail="такая же ошибка, что возвращает вендор") user_table[user_id] = None return {"success": True}То есть:
-
Вы знаете, что в рамках теста test_get_age происходит регистрация клиента
-
В рамках регистрации клиента в ваш мок уходит запрос с айди клиента
-
Далее, по созданному айди передают дату рождения
-
В таком случае, если вы знаете, что дальше по сценарию теста к вам приходят и получают возраст зарегистрированного клиента, то данные из шага 1 и 2 стоит прихранить
-
Когда к вам в мок придут с айди зарегистрированного клиента – проводите сложнейшие математические операции и по году рождения клиента считаете его возраст
-
Наслаждаетесь восторженными отзывами
А давайте добавим усложнение, что посчитать возраст мы хотим не от той даты рождения, что указана при регистрации, а от какой-то другой, или, например, мы хотим, чтобы наш мок-сервис ответил кодом 502 или вообще не ответил.
Тут становится интереснее, об этом в шаге 4 -
-
Тесты должны иметь возможность влиять на ответы от мока, чтобы получить нужный им сценарий.
При необходимости реализуйте внутри сервиса-мока API-хендлеры для управления поведением в рамках конкретного теста, это позволит из теста настраивать ответ и получать сценарии, отличающиеся от базовых.
Думаю, понятно, что при доступе к управлению ответами мока есть ненулевой риск заафектить то, что мы заафектить не должны.
То есть к реализации такого функционала стоит подходить очень осторожно, поскольку тесты должны быть независимыми друг от друга, и при параллельном выполнении в разных релизах, и при выполнении в одном пайплайне, и вообще абсолютно всегда, и при этом оставаться стабильными.Пример:
Снова наша госпожа Ошибковна и ее неуловимый возраст-
Считаем, что запрос возраста принимает id, идентификатор клиента и request_id, условный идентификатор запроса.
-
Новый хендлер в сервисе-моке, например,
support/prepare_response, который будет принимать айди, доступный на уровне теста. Чтобы тест мог самостоятельно сказать нашему моку: “когда тебе придут с таким-то значением – отдай вот это”.Здесь нужно разобраться, что доступно в рамках теста, то есть на что он вообще может завязаться для предподготовки ответа.
Если на уровне теста доступен id клиента, то завязаться на него, если request_id, то на него, если и то и то, то можно использовать связку значений. Но в любом случае ключ должен быть однозначным и уникальным. -
Этот новый хендлер базово будет принимать два параметра:
-
ключ, чтобы смаппить бизнесовый запрос (тот самый айдишник/связку айдишников)
-
и ответ, который требуется вернуть из мока при получении ключа из шага 1
-
Пример реализации хендлера для предподготовки ответов
class PrepareResponseBody: scenario: str request_id: str@router.post("/support/prepare_response")async def handle_prepare_response(body: PrepareResponseBody): # ищем, нет ли уже заготовленного сценария по тому же id prepared_resp = prepared_response_table.get(body.request_id) # проверяем, действующий ли это сценрий или считается неактуальным if not is_outdated(prepared_resp): raise HTTPException(status_code=404, detail=f"scenario for id {body.request_id} already created") # если такой заготовки еще нет или считается outdated (не актуальной) -- сохраняем, чтобы использовать в бизнес-хендлере prepared_response_table[body.request_id] = PreparedResponseRow(scenario=body.scenario, created_at=datetime.now()) return {"success": True}Итоговая схема может выглядеть так:
-
Запрос
/support/prepare_responseсkey=уникальный_ключscenario=HTTP_500 -
Запрос возраста
/user/get_ageсid=уникальный_ключ: сопоставляете полученный ключ с тем, что было создано в шаге 1 -
Если ключи совпали – выполняется запрошенный в шаге 1 сценарий.
Пример поиска заготовленного ответа в бизнес-хендлере
# если нашли нужный предподготовленный ответ, то используем его в качестве результатаif response := get_prepared_scenario(id=some_unique_id_from_request): # помечаем как использованный, чтобы больше не использовать prepared_response_table[user_id].used_at = datetime.now() return responseПример функции, которая по переданному в нее id ищет подготовленный сценарий
def get_prepared_scenario(id: str) -> dict | None: prepared_scenario = prepared_response_table.get(id) if not prepared_scenario: return None if is_outdated(prepared_scenario): return None return match_prepared_scenario(target=prepared_scenario)Причем это может быть как успешный ответ, так и неуспешный, так и таймаут, зависит от того, какой сценарий требуется в тесте.
В примере мы используем поле scenario, это может быть заранее известный Enum, где каждое значение — какой-то конкретный сценарий, известный в моке.Пример функции которая маппит созданную тестом сущность подготовленного ответа и выполняет действия в соответствии с конкретным сценарием из этой сущности
def match_prepared_scenario(target: PreparedResponseRow) -> dict | None: match target.scenario: case "TIMEOUT": time.sleep(timeout_second) return {"message": "Timeout"} case "WRONG_AGE": return {"age": 500 } case "HTTP_500": raise HTTPException(status_code=500, detail="Internal Server Error") return NoneЧтобы понять, нужно ли вам в каком-то конкретном сценарии использовать дополнительную предподготовку – задайте себе простой вопрос. “Кто является непосредственным источником данных, получаемых в сценарии?”
Если ответом будет вендор, то вероятнее всего придется делать дополнительную предподготовку.
А если же ответ неоднозначный или сценарии можно реализовать на основании уже имеющихся данных, то лучше постараться именно так и сделать -
Надежность
-
Предподготовленные ответы должны быть одноразовыми.
Если ответ использован, нужно пометить “заказанный” ответ, как использованный, причем пометить его стоит через soft delete — временной меткой, когда именно это произошло, а не явным удалением из хранилища.
Когда ваша специфика позволяет оставить при этом какие-то дополнительные артефакты, например, имя теста/конкретный запрос/добавить свое, то это тоже может быть полезным при разборе поведения вашего мока.Пример обработчика бизнес-запроса
/user/get_age/{user_id}@router.get("/user/get_age/{user_id}")async def handle_get_age(user_id: str, request_id: str): # если нашли нужный предподготовленный ответ, то используем его в качестве результата if response := get_prepared_scenario(user_id=user_id): # помечаем как использованный, чтобы больше не использовать prepared_response_table[user_id].used_at = datetime.now() return response # если заготовленного сценария нет -- используем обычное поведение if user_age := user_table.get(user_id, None): return {"age": user_age} raise HTTPException(status_code=404, detail="User not found") -
При долгом отсутствии взаимодействия с предподготовленным ответом – считайте его использованным.
Предподготовленный ответ, не использованный за заданное время (например, 5 минут), автоматически считается недействительным.
Это страхует от «забытых» подготовок, упавших до обращения тестов или ручных экспериментов, и позволяет не требовать от каждого теста явного тирдауна, закрыв все эти дыры на стороне нашего мока.Пример проверки на то, использован ли уже предподготовленный ответ и старше ли он, чем scenario_lifetime_minutes
def is_outdated(prepared_scenario: PreparedResponseRow) -> bool: if prepared_scenario.used_at: return True created_at: datetime = prepared_scenario.created_at if created_at.replace(minute=created_at.minute + scenario_lifetime_minutes) < datetime.now(): return True return False
Основные практические тезисы, кратко:
-
Для написания используйте имеющийся в вашей компании флоу и уже используемый для разработки язык программирования
-
Если нет конкретных требований к моку – начните с хардкодных позитивных сценариев и в процессе смотрите, чего не хватает для тестов и добавляйте это итеративно
-
Старайтесь реализовывать бизнес-флоу “честно”, чтобы быть максимально похожим на реальные сервисы
-
Для управления моком сделать дополнительное API, которое будет явно отделено от бизнес-API
-
Если тест подготавливает какие-то специфические сценарии в моке – они должны быть одноразовыми и с конкретным временем жизни
-
Мок по возможности должен быть максимально прост в работе и удобен для тестов, его главная задача — помочь в тестировании
Финал
Если вы не используете в своей работе подход с сервисами-моками, но при этом система имеет внешние зависимости, то попробуйте проанализировать процент покрытия, скорость тестов, удобство тестирования и решите – можно ли улучшить и упростить анализ качества продукта?
Мы разобрали, когда стоит использовать описанный подход, какие аспекты это позволит улучшить и где потенциально могут случиться проблемы и как решать их превентивно.
Если не знаете, с чего начать при внедрении подхода с сервисами-моками, то начните с позитивных хардкодных ответов, постепенно добавляйте управление сценариями и механизмы очистки.
Эту статью можно использовать как памятку или руководство для создания сервисов-моков, она отражает общую концепцию данного подхода, но не забывайте, что с учетом вашего контекста, какие-то пункты могут быть добавлены, какие-то изменены. В общем, пользуйтесь универсальным “Делай хорошо, плохо не делай” (с).
Сервисы-моки — это не серебряная пуля, но они являются самым мощным и контролируемым способом тестирования интеграции с внешними системами.
Пример кода на питоне лежит на GitHub
Вдруг вы столкнетесь с какими-то препятствиями на своем тяжелом SDET пути и захотите обсудить – можете писать мне на почту hllwwwrld@gmail.com, постараюсь помочь и подсказать
ссылка на оригинал статьи https://habr.com/ru/articles/1034736/