Эта статья — расшифровка доклада Дениса Кудряшова, QA-инженера Leroy Merlin, с конференции QA Meeting Point 2020.
Денис рассказал, как столкнулся со сложной логикой, реализованной в сервисе, применил подход Control Flow Testing, и что из этого вышло. Из текста вы узнаете, можно ли использовать этот подход для синхронных или для асинхронных логических схем, какие нюансы есть у каждого кейса, а также почему моки и Control Flow Testing — идеальное сочетание.
«Небольшой» эндпоинт и первые нюансы
Некоторое время назад мой коллега попросил помочь ему в тестировании одного, как он сказал, «небольшого» эндпоинта в микросервисе их команды. Мы залезли в документацию, и я увидел это.
Хотя схема на картинке не из реального проекта, но передает ситуацию почти на 100%. На реальной схеме было большое количество ветвлений и различных вариантов обработки данных. Но это было не главное: я знал, что есть какой-то нюанс, который смутил моего коллегу — так и оказалось.
Во-первых, схема была реализована в системе управления бизнес-процессами. Суть ее заключалась в последовательном вызове эндпоинтов других микросервисов, и в общем-то больше никакой логики реализовано не было. Схема состояла из ряда запросов и небольшой постобработки полученных данных, после чего данные возвращались клиенту.
Во-вторых, обнаружилось, что мы не можем влиять на выбор ветки входными данными. Те данные, которые отправлялись на вход тестируемого микросервиса, могли повлиять только лишь на список возвращаемых параметров. То есть мы могли задать какие-то интересующие нас параметры, они бы вернулись в ответе. Если же их не задавали — тогда возвращался просто дефолтный набор данных. Но на маршрут движения данных по схеме мы никак не влияли. Повлиять могли исключительно ответы связанных сервисов, но на том окружении мы не могли ничего сделать, так как интегрированные сервисы были в зоне ответственности других команд.
Нюансы всплыли, задача ясна, надо подумать, как это протестировать. Первый и самый основной вопрос: а кто уже делал подобное до меня?
Я изучил архитектуру приложения, накидал небольшую схемку и обнаружил, что у нас в системе есть как минимум два таких места. Первое — это микросервис, о котором я рассказал ранее (Middle слой). Вторым был микросервис на бэкенде, где происходила совсем чёрная магия.
Если в первой схеме мы могли отслеживать по ответу хотя бы параметры, то в сервисе на бэкенде обработка запроса происходила в полностью асинхронном режиме, и единственный ответ, который возвращал нам сервис, заключался в информировании того, стартанул он или нет. Всё. О результатах завершения процесса мы не уведомлялись никоим образом.
Допустим, пусть схема асинхронная. Но, может быть, коллеги уже нашли какое-то решение? К сожалению, на тот момент команда микросервиса тоже билась над этой задачей. Что ж, решения нет, нужно гуглить.
Чтобы гуглинг был эффективным, я решил понять, с чем же мы вообще имеем дело и дополнительно изучить возможные нюансы задачи.
То, что мы тестируем, выглядит как API. То есть такая логика может быть зашита в любом микросервисе. В нашем случае, если мы запрашиваем какой-то сервис, то отправляем запрос, происходит магия обработки данных по некоей логической схеме и возвращается ответ. И если мы отправляем такой же запрос в асинхронную схему, то нам как минимум возвращается ответ о том, что процесс стартанул либо нет. То есть весь тест условно можно свести к запросу и анализу ответа и каких-то дополнительных данных. Это уже лучше. Но опять же — есть нюанс. Одинаковый результат можно достигнуть совершенно разными ветками схемы. И вот здесь возникает вопрос: как нам эти пути однозначно определить (проверить-то нужно все варианты)? Пометим себе этот вопрос и идём дальше.
Большим подспорьем стало то, что мы знаем конкретную логику выполнения запроса. То есть у нас перед глазами есть схема, она часть документации, и мы можем по ней определить какие-то условия, при которых запрос и данные будут обрабатываться определённым образом по определённому пути.
Попробуем формализовать и обобщить задачу и приведём какую-то абстрактную схему, для простоты — с одним ветвлением.
Схема начинает работать, когда на вход подают данные, далее происходит ветвление по определённому условию. Таким образом, зная условия ветвления, мы можем выделить две ветки движения данных. То есть, для более сложных схем возможно выделить любое количество веток, в зависимости от количества ветвлений. В данной схеме это всего лишь две ветки, которые показаны красными и зелёными стрелочками. На первый взгляд — достаточно просто.
Данная методика называется Control Flow Testing и заключается в том, что мы как бы разделяем нашу логическую схему на пути обработки данных, и подбором входных данных и управлением ответами от интегрированных сервисов формируем прохождение наших данных по тестируемой ветке логической схемы. Вроде бы просто. Но опять же — есть нюанс. Имеется как минимум две схемы работы, одна асинхронная, другая синхронная. Можем ли мы применить подход Control Flow Testing для обеих схем?
Начнем проверку нашей гипотезы с синхронной схемы.
Control Flow Testing для синхронных схем
Вот пример того, как может выглядеть реальная схема в нашей документации. Она полностью синхронная, здесь есть несколько запросов в интегрированные микросервисы. Первый запрос в эндпоинт /start, далее — запросы в сервис 1 и сервис 2. Движение данных у нас зависит от ответов микросервисов. В принципе схема достаточно информативная, но для удобства восприятия вариантов движения данных, ее можно разбить её по веткам и представить в табличном виде.
Вот такая простая табличка получилась у меня. Мы берём проверку на узле ветвления за шаг, движемся от проверки к проверке и таким образом описываем путь данных. «+» и «–» обозначают True/False соответственно, пустое значение – ветвление недоступно в данной ветке. В принципе ничего сложного. Единственное, что мы должны учесть в нашем примере — это запрос в сервис 2, но о том, как это сделать правильно, расскажу ниже.
В процессе тестирования обнаружился интересный эффект от создания такой таблички — помимо формализации веток схемы мы проверяем повторяющиеся проверки. Например, несколько проверок одного и того же параметра. И если у нас есть повторяющиеся проверки, то как правило это говорит о недостаточной проработке системы и, в частности — логической схемы. Когда мы устраняем дублирование, то у нас остаются только неповторяющиеся проверки параметров, и мы можем говорить о том, что схема похожа на правду и работает без оверинжиниринга и явных логических ошибок.
То же самое касается повторяющихся веток. Такая ситуация возможна в случае, когда, например, после какого-то определённого ветвления данные не изменяются, после чего ветки вновь объединяются в единый поток. Я остановлюсь на этом более подробно, а пока примем, что отсутствие повторяющихся веток является критерием качественно спроектированной схемы.
И так же мы можем при помощи таблички проверить коды ошибок, возвращаемых сервисом. Это необязательно, и в наших тестах мы не всегда это используем, но удобно в случае, например, когда у нас разные ветки могут возвращать одну и ту же ошибку. В этом случае мы можем проверять дополнительно ещё и коды ошибок, и заносить их в нашу табличку, чисто в качестве информации.
Какие же нюансы у нас возникают при попытке применить Control Flow Testing для анализа схемы? Опытным путем было выяснено, что табличку надо составлять на самом первом этапе, то есть на этапе проектирования фичи или сервиса. Мы столкнулись с тем, что при составлении таблицы и дальнейшей проверке ветвлений были получены результаты, которые вынудили команду переделать схему. У нас были дополнительные ненужные проверки, очень-очень много дублей. Всё это разработчикам пришлось выпилить, из-за чего процесс затянулся. Поэтому таблички лучше всё-таки предоставлять в самом начале проектирования фичи.
Следующий момент: иногда нам необходимо проверить последний вызов в схеме (или проверить его отсутствие — см. рисунок выше). Для чего это делается?
Если мы посмотрим на схему, то вызов в сервис-2 (самый правый снизу) происходит у нас как бы в информативном режиме для схемы, и после него никаких ветвлений нет. Соответственно, данный вызов может повлиять либо на состояние системы (и мы его должны будем проверить, чтобы удостовериться, что вызов был или не был произведен), либо на возвращаемые данные. В последнем варианте нам необходимо дополнительно проверить вернувшиеся данные.
Control Flow Testing для асинхронных схем
Ок, вроде бы с синхронной схемой разобрались. Что же с асинхронной?
С асинхронной схемой ситуация примерно следующая: в документации схемы обычно описываются в BPMN виде. Это связано с нюансами реализации и используемыми инструментами.
Выделенная часть схемы — это цепочка запросов в сервис 5, которая является циклической. При обработке представленной схемы у нас может возникнуть ситуация, когда данные пойдут по пути положительного выполнения задачи, и схема достигнет статуса «готово». Но при этом с начальный момент времени схема зациклится и повиснет в статусе «ожидает старта». Такое очень даже возможно, а значит — нам нужно каким-то образом учесть эти циклы.
Что важно при этом помнить? Первое: циклы — это не блоки ветвления. Они используют блоки, но если мы посмотрим на выделенный кусок схемы и уберём проверку тайм-аута, то у нас получится просто ровная веточка, при движении по которой статус задачи изменяется от «в работе» до «готово».
Если мы немного доработаем схему и переставим этот цикл в виде блока для каждой отдельной ветки, и для положительной, и для ошибочной (то есть когда мы попадаем в статус «ошибка»), то у нас получаются две обычные ветки.
Действительно, когда мы готовим таблицу для данной схемы, мы просто не учитываем циклические блоки, так как они нам не нужны для анализа веток.
Однако циклы будут нам очень полезны при составлении тест-кейса. В тест-кейсе проверяется статус задачи, статус допускает несколько вариантов. Если мы попадаем в вариант, который у нас представлен циклической частью схемы, то дописываем какие-то дополнительные действия по проверке статуса схемы.
Что ещё интересного в тест-кейсе с асинхронной работой? Здесь мы в некоторых случаях вынуждены эмулировать действия сторонних сервисов, делать запросы в тестируемый микросервис или кидать сообщения в очередь RabbitMQ или Kafka от имени интегрированных микросервисов. То есть нам придётся в самом тесте эмулировать эти сервисы. Но в целом тест-кейс похож на вариант с синхронной схемой.
Перейдём к нюансам асинхронных схем.
Ответ сервиса нам, как правило, возвращает 200, 201, 202 — то есть ответы группы Ок (2хх). В этом случае ответ абсолютно неинформативен, мы только знаем, что процесс запущен, и приходится дополнительно проверять через методы сервиса, в каком статусе он находится. Соответственно, в проверяемой ветке статус может иметь различные значения. Нам необходимо учитывать такую инвариантность статуса при проведении теста и правильно ее обрабатывать. В противном случае тест просто может упасть, не дождавшись смены статуса на нужный.
Приходится имитировать интегрированные сервисы. Как я говорил выше, в тесте может потребоваться имитация работы сторонних сервисов и выкидывание каких-то сообщений в очереди или запросов к тестируемому сервису.
Вывод: мы можем применить Control Flow Testing к обеим вариантам логических схем.
Правила составления таблиц
Я расскажу, какие же правила мы придумали у себя в команде для того, чтобы более эффективно работать со схемами и их тестировать.
Первое: в таблицу вносятся все узлы ветвлений по порядку. Это значит, что мы берём логическую схему, и, в зависимости от формата, проходим ее либо слева направо, либо сверху вниз. И записываем в табличку все узлы, в которых происходит выбор того или иного пути дальнейшей обработки данных.
Далее мы можем разделить таблички по ответу сервиса, например, на ветки без ошибок и с ошибками. Это позволяет отследить логику как обработки исключительных ситуаций, или детально проанализировать работу позитивных сценариев.
Также отдельной строкой в таблицу добавляется запрос при условии, что после него нет ветвления. Здесь нам необходимо проверить, что запрос вот в этот сервис действительно был, с определёнными параметрами и ответом.
В таблицу не вносятся циклы. Они нам мешают сосредоточиться на ветвлении, которое разделяет сами ветки, соответственно мы их можем представить просто в виде какого-то блока кода, который не имеет к ветвлению никакого отношения.
В тест-кейсе можем добавить обработку дополнительных вариантов, например статуса задач. Следуя этим правилам, мы получаем достаточно эффективный инструмент для контроля наших логических схем. И самое главное: мы можем эффективно использовать эту таблицу при составлении тест-кейсов и настройки моков.
Моки
Итак, мы используем подход Control Flow Testing, используем табличное представление схемы, но это не все — для теста нам потребуются моки. Зачем? Чтобы ответить на этот вопрос, давайте ещё раз взглянем на схему.
Для тестирования определенной ветки нам нужно заставить сервис отправлять нужные нам данные. Как мы это можем сделать? Вариант №1 — мокировать этот сервис и уже в заглушке отправлять нужные нам данные.
Вариант №2: запрос в сервис 1, который также возвращает какие-то данные. Однако вернувшиеся данные в схеме не анализируются, проверяется только ответ сервиса. То есть если бы у нас был 404 not found, мы бы пошли по веточке с запросом в сервис 2, иначе — тестируемый сервис вернул бы некоторые данные и положительный ответ. При мокировании важен только ответ сервиса, возвращаемые данные не важны.
И вариант № 3, когда мы не анализируем вообще ответ сервиса, но при этом запрос у нас есть и каким-то образом нам нужно отлавливать момент запроса вот в этот сервис, либо отсутствие запроса в этот сервис. Здесь возвращаемые заглушкой данные также не важны.
Как использовать моки
Если мы посмотрим на табличку с уже известными ветками и допустим, что нам потребовалось составить тест-кейс для ветки № 3, очень удобно использовать те данные, которые у нас уже имеются. Что у нас есть? Есть запрос в эндпоинт /start, помеченный плюсиком. В блоке «проверка ответа» нас интересует, чтобы сервис возвращал какой-то код ответа в теле, равный двум. В этом случае мы попадаем в ветку 3. Но нам этого недостаточно, нам необходимо ещё поставить мок для сервиса 2. Соответственно, в тесте мы проверим, что у нас был запрос в этот сервис. Потому как схема, может, например, быть неактуальной. Команда доработала какие-то фичи, что-то сделала и запроса в сервис может не быть. Нам этот момент необходимо выяснить, мы вешаем туда мок и в тесте проверим, что запрос в сервис 2 действительно был.
Таким образом мы можем настроить моки под каждую ветку. И вот что интересно: для каждой ветки комбинация моков и возвращаемых параметров будет индивидуальна. При помощи комбинации входных данных и моков мы можем однозначно задать нужную ветку обработки данных. И если в данном случае, например, для ветки 3 мы не устанавливаем мок для запроса в сервис 1, а у нас по каким-то причинам ветка пошла именно по этому пути, то тест однозначно свалился, потому что мок-сервер ничего нам не ответит и что-то пойдёт не так и явно вывалится с ошибкой.
Настройка моков
Какие есть нюансы настройки моков?
Первое — это уникальные настройки для каждой ветки. То есть мы настраиваем не просто мок, мы также настраиваем и тело ответа (не во всех случаях, см. примеры выше).
Далее — мы имеем как минимум два варианта для настройки мок-сервера.
Первый вариант, который мы естественно сразу же попробовали, был вариант с параллельными тестами. Когда у нас поднимался стационарный мок-сервер, мы его планировали использовать для регресса, закидывали туда преднастройки для разных веток, и всё у нас должно было, по идее, замечательно работать. Почему это не взлетело, расскажу чуть позже.
Второй вариант настройки — когда мы настраиваем комбинации непосредственно перед запуском теста — то, к чему мы пришли после.
Возвращаемся к параллельному запуску тестов. Здесь есть нюанс: иногда наши схемы не позволяют, запускать все варианты веток в параллели. Это может случиться из-за невозможности настройки ветки в зависимости от входных данных (как было в нашем случае). При отправке GET-запроса по запуску какой-то асинхронной схемы, мы можем не передавать никаких входных данных и, соответственно, никак не можем эти данные пробросить куда-то дальше на наши моки, и не сможем запустить тесты параллельно. Поэтому иногда мы вынуждены заниматься настройкой комбинаций непосредственно перед тестом. И в общем-то, это не плохо.
Основное достоинство настройки моков непосредственно в тесте заключается в том, что мы не зависим от входных данных и перед каждым тест-кейсом у нас очищены и определённым образом настроены мок-серверы. Мы точно знаем, какие эндпоинты у нас есть на заглушке, а каких нет. Мы сразу видим, на каком запросе у нас тест свалился.
Из недостатков, которые я выделил: «накостылить» аннотацию (этот недостаток относится уже к автоматизации тестирования), когда нам необходимо будет каким-то образом настраивать на лету наши мок-сервера. В ряде случаев это может быть проблемой.
А что же дальше? А дальше автоматизация!
Я вас не буду грузить кодом. Реализовать автоматизацию можно по-разному, в зависимости от инструментов, которые вы предпочитаете.
Однако приведу те схемы, которые мы использовали при создании автотестов. Весь запуск теста у нас будет выглядеть примерно подобным образом.
Здесь представлена схема запуска теста. Мы используем кастомный фреймворк на базе TestNG, и в тесте у нас обрабатываются все NG-шные аннотации. Мы дописали дополнительную аннотацию @Wiremock, которая отвечает за настройку мока. В ней всего два параметра: имя файла с JSON-настройками для мока, и логический параметр — чистить или не чистить мок-сервер (хост мок-сервера задается в настройках окружения). То есть при очистке мы просто удаляем какие-то настройки, которые у нас были до старта теста. Если в этом нет необходимости или у нас гоняются какие-то тесты параллельно, мы их просто оставляем и дописываем либо перезаписываем наши настройки.
Тестирование
Давайте посмотрим также на процедуру тестирования наших логических схем. Ниже представлена блок-схема теста:
Мы отправляем запрос в тестируемый сервис, проверяем его ответ, а также (при необходимости) тело ответа. Здесь нет никакой валидации запросов в мок-сервер, постольку поскольку логические схемы с запросами к интегрируемым сервисам, после которых нет постобработки и ветвлений, у нас достаточно редки. Если это необходимо, мы добавляем дополнительно проверку вот таких «висячих» запросов (был или не был произведен), и на этом тест заканчивается. Здесь проявляется плюс настройки моков перед тестом — нет нужды проверять, куда у нас действительно были запросы, в какие эндпойнты с какими параметрами. Комбинация однозначна — либо тест свалился, если была попытка произвести несипользуемый в ветке запрос, либо по схеме был произведен нужный запрос, тестируемый сервис достучался в мок и получил нужные данные.
Напоследок поговорим про асинхронные схемы.
Здесь единственное отличие только в цикле ожидания (нужен при ожидании требуемого в тесте статуса тестируемого процесса. Ну и дополнительно — имитация действий интегрируемых сервисов. Т.е. выполняем запрос, получаем ответ, проверяем, что у нас схема запустилась. Дальше ждём нужного статуса, имитируем какие-то действия и проверяем, что у нас получилось.
Что же в итоге?
Мы выяснили, что подход Control Flow Testing применим для тестирования логики наших сервисов.
Он замечательно работает как на синхронных, так и на асинхронных схемах, и мы можем его успешно применить.
Отличный инструмент для анализа логических схем — их табличное представление (когда мы разбиваем схемку на ветки и анализируем, каким образом у нас происходит запрос с какими параметрами)
Моки — наше всё, потому как настраивая моки определённым образом, мы можем выбрать конкретную тестируемую ветку и делаем возможным тестирование по методике Control Flow Testing.
Q&A
Как вы организуете хранение преднастроек моков? Интересует кейс, когда есть одна фишка и очень много комбинаций. Используете ли инструмент генерации или храните в сыром виде?
Сейчас мы тестируем новый инструмент генерации. До этого использовали настройку именно перед тестом. Просто закидывали JSON’ы, которые у нас хранились рядом с тестом. Сейчас делаем генерацию. Что касается преднастроенных моков, мы решили проблему следующим образом: для подобных тестов и микросервисов мы с командой соорудили несколько вариантов сервера в Wiremock с преднастройками. И раскатываются они у нас один раз, автоматически, при деплое. То есть мы какие-то изменения вносим, запускаем джобу в Jenkins, и у нас всё автоматом раскатывается по серверам, никаких настроек менять не нужно. Для тех тестовых окружений, где уже используются эти моки, это проходит бесшовно. Если что-то необходимо добавить, мы просто добавляем это в репозиторий с настройками и раскатываем в Wiremock. Всё. Здесь никакой магии.
Проводите ли функциональное тестирование в живой среде без моков?
Пока да. Единственное, что мы от этого сейчас отказываемся в пользу изолированных сред. Поскольку команда у нас становится всё больше и больше, IT в Leroy Merlin динамично развивается и поддерживать какую-то такую живую среду, хотя бы отдалённо похожую на прод, становится накладно. Это занимает огромное количество времени, поэтому мы уходим в сторону изолированного тестирования. Но пока есть.
Философский вопрос: по ощущениям — насколько это трудозатратный способ? Не слишком ли много времени придётся потратить на составление таблиц?
Производство фичи с разветвлённой логикой без таблиц с исключительно компонентными интеграционными тестами занимает, условно говоря, какой-то период времени — допустим, месяц. При использовании подхода с таблицей, методики Control Flow Testing, мы умудрились как минимум в два раза сократить время выкатки фичи, и я подозреваю, что это не предел. Мы с коллегами сейчас разрабатываем поэтапное проектирование логических схем, где стараемся свести количество итераций по изменению и доработке самой логики до максимум двух. То есть — да, можно ускориться, и это действительно очень и очень помогает в разработке. Мы можем таблицу использовать как документацию в конечном счёте. Здесь очень много нюансов, об этом можно говорить долго, но — да, это действительно ускоряет разработку.
Насколько зависимы будут результаты тестов сервисов при мокировании других сервисов? Вы как-то задаётесь этим вопросом?
Да, мы задаёмся этим вопросом. Сейчас для таких схем мы начинаем применять дополнительно контрактное тестирование.
Сколько времени в среднем уходит на поддержку моков?
Вообще достаточно их один раз настроить, и очень редко они меняются, если только фича дорабатывается. Это если мы говорим о преднастроенных моках. Если мы говорим о моках в тесте (те, которые у нас были просто в виде файликов JSON), здесь в общем то же самое, что и в преднастроенном сервере. Доработки практически не требуют. Если же мы говорим о том, что переходим именно на динамическую конфигурацию моков, то здесь всё проще. Здесь один раз сделал код, и дальше он сам работает.
Насколько затраченное время эквивалентно пользе от таких табличек? Находят ли они баги? Помогают ли они найти баги на этапе проектирования, дают ли уверенность разрабам, что всё ок?
Они дают уверенность нашим тимлидам, что всё ок, это точно. Разрабы, я думаю, тоже довольны. То, о чём я сейчас рассказывал в докладе — реальный кейс, хоть схемы и утрированы. И мы, как только начали применять подобный подход, действительно сходу нашли несколько багов только в одной схеме. С учётом того, что нашем микросервисе порядка 17 эндпоинтов с такими схемами и половина из них сейчас не покрыта вот такими тестами — да, это очень сильно улучшит качество нашего сервиса. И да — на этапе проектирования это позволяет отлавливать баги. Но это, скорее, не баги, а валидация требований, выраженных в логической схеме. Можно так ответить.
Запись видео можно посмотреть здесь:
QA Meeting Point - бесплатная онлайн-конференция для всех, кто занимается тестированием ПО и его автоматизацией: QA-специалистов и разработчиков. Своими болями и успехами делятся инженеры из разных городов России. Сейчас уже можно подать доклад на конференцию, которая состоится осенью 2021.