Разделы портала

Онлайн-тренинги

.
Record-and-Replay тестирование — сочетание достоинств юнит и интеграционных тестов
21.09.2021 00:00

Оригинальная публикация

Вступление

Сегодня я расскажу вам про Record-and-Replay подход к тестированию т. к. я его понимаю. Оговорка про мое понимание не случайна. Про этот подход не так много общедоступных материалов, чтобы иметь некий common agreement относительно значения этого термина. Многое из того, что я опишу, является моими личными оригинальными находками, но, тем не менее, фраза record-and-replay, на мой взгляд, наилучшим образом описывает применяемые мной решения. Так что я буду использовать именно ее.

Чтобы было проще понять, какие проблемы решает RnR, в ходе этого разговора мы сначала обсудим некоторые другие подходы к написанию тестов (юнит-тестирование, интеграционное тестирование и т. д.). И отталкиваясь от их недостатков перейдем к варианту с RnR, я расскажу, что же это собственно такое, как это работает, и каким образом решает озвученные ранее проблемы. Поговорим про подводные камни, которые могут свести пользу от внедрения всего этого дела к нулю. Ну и, конечно, обсудим недостатки или границы применимости этого подхода.

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

Ключевой постулат

Итак без лишних предисловий к ключевой идее статьи. В ней будет немало оценочных суждений, и думаю необходимо сразу сказать, в свете какой идеи я делаю эти оценки. Итак… Ключевой постулат - «Ресурсы на тестирование ограничены».


Ограничено человеческое время, что значит, что у нас определенное количество людей, и они могут потратить на контроль качества определенное количество времени. Ограниченно машинное время для выполнения автотестов. Об этом не всегда вспоминают, потому-что машинное время часто дешевле человеческого, но если бы этого ограничения не было, то вы могли бы получить любой суперкомпьютер/вычислительный кластер, так что ваши тесты выполнялись бы за ничтожное время, сколько бы медлительными они не были. Наконец, есть еще одно ограничение. На какой стадии (и через какое время) производственного процесса вам становится известно о проблемах с качеством. Отчасти оно является производной первых двух, но только отчасти. Даже будь у вас миллиард тестировщиков и все процессорное время мира, пока код даже не попал в вашу систему контроля версий, не говоря уж о том, чтобы куда-то продеплоиться, эти безграничные ресурсы ничего не ускорят.

Принцип довольно простой и очевидный, поэтому я надеюсь, что все читатели со мной согласны на данном этапе. Соответственно, ключевой вопрос тестирования в этом понимании - «Как нам обеспечить как можно большую степень уверенности в качестве продукта, потратив как можно меньше ресурсов?» Тут акцент на правое или левое плечо фразы в зависимости от вашей ситуации. Например, если у вас есть условных 5 человекочасов на тестирование, то мы делаем акцент на «большую уверенность». Как с максимальной пользой для дела эти 5 человекочасов использовать. Или, напротив, если вас выделенным временем на тестирование не балуют, акцент смещается в сторону «меньше ресурсов», так чтобы получить хорошую степень уверенности за такой минимум времени, который вы все-таки сможете выкроить без специального разрешения начальства. Надеюсь вы поняли, как именно я попытался сделать эту фразу универсальной. Т. е. это некая оптимизационная задача, где ресурс мы всегда минимизируем, а уверенность максимизируем.

Пример проекта



Итак, в качестве наглядной иллюстрации представляю вам команду «Бородачи-кодеры». Это господа славятся не только своими бородами, но и умением решать непростые инженерные задачи. Совсем недавно они стали овнерами проекта FAIL (fast agile ingestion leader), который до этого разрабатывался другой командой. Ingestion leader он потому-что занимается оркестрированием сложного процесса ingestion’а — загрузки контента в систему, например .zip с новой книгой, в ходе которого координирует работу около десятка микро- и не очень сервисов. Agile, потому-что это модно, а Fast потому-что думали, что эта штука ускорит бизнес-процесс, но получилось не как хотели, а как всегда. Старая команда вроде и писала тесты, но по факту когда бородачи стали смотреть код, выяснилось, что тестами там все покрыто весьма жиденько и разнородно. Да и рефакторингом предшественники себя, похоже, не слишком утруждали. Теперь нашим бородачам нужно придумать, как по-быстрому обеспечить хоть какой-то контроль качества и двигать проект к новым горизонтам, т. е. реализовывать хотелки бизнеса, которые сыпятся как из рога изобилия. Возможно для кого-то из вас ситуация жизненная и знакомая. Итак, какие же писать тесты? Бородачи сели, чешут бороды, обдумывают.

Вариант №1. А давайте покроем все юнит-тестами?

void sendReadyForDeliveryEvent(CompositeContentContainer contentContainer, 
                               EntityDescriptor packageDescriptor) {
  LocalizedLearningProduct product = contentContainer.getProduct();
  EntityDescriptor productDescriptor = new EntityDescriptor(
    product.getId(), product.getType().getValue()
  );
  
  EntityDescriptor elementDescriptor = 
    convertContentObjectToEntityDescriptor(contentContainer);
  List<Object> eventPayload = List.of(
    productDescriptor, elementDescriptor, packageDescriptor, 
    contentContainer.getJob()
  );
  
  try {
    contentTopicService.sendEvent(
      EventType.PACKAGE_READY_FOR_DELIVERY, eventPayload
    );
  } catch (JmsException e) {
    throw new JmsEventException(e);
  }
}  
@Test
public void shouldSendReadyForDeliveryEvent() {
  // given
  EntityDescriptor productDescriptor = createEntityDescriptor(
    productId, product.getType().getValue()
  ).get();
  EntityDescriptor deliveryPackageDescriptor = 
    createEntityDescriptor("id", "type").get();
  EntityDescriptor elementDescriptor = createVersionableEntityDescriptor(
    elementId, LearningElement.TYPE, currentElementVersion
  ).get();
  willReturn(elementDescriptor).given(sut)
    .convertContentObjectToEntityDescriptor(any());
  
  // when
  sut.sendReadyForDeliveryEvent(contentContainer, deliveryPackageDescriptor);
  
  // then
  verify(contentTopicService).sendEvent(
    PACKAGE_READY_FOR_DELIVERY, 
    List.of(productDescriptor, elementDescriptor, deliveryPackageDescriptor, job)
  );
}

Может вы уже видели тесты такого рода в своем проекте, а может и сами такие пишете. Вот достаточно типичный юнит-тест из живого проекта (слегка модифицированный ради соблюдения NDA), который я взял в качестве примера. Во-первых на что я сразу хочу обратить ваше внимание — это соотношение количества кода в тесте к количеству тестируемого кода. Как видите, примерно 1:1. Причем большая часть кода теста — это настройка моков Mockito. Что еще примечательного в этом тесте? Сильная связность с исходным кодом. Хотя тест и назван так, что отражает некое требование контракта, по факту если это требование не поменялось, но код в результате рефакторинга хоть как-то поменяется, тест тоже придется менять. Что еще? Глядя на тест я гораздо меньше понимаю относительно того, что должно делать приложение, чем глядя на сам тестируемый метод. Может это только у меня? Кому кажется, что этот тест лучше документирует приложение?

Конечно, это только один тест. Судить по нему о всех юнит-тестах нельзя, но этот тест, на мой взгляд, представляет собой типичный пример юнит-тестов, тестирующих glue code (склеивающий код). Склеивающий код — программный код, который служит исключительно для «склеивания» разных частей кода, и при этом не реализует сам по себе никакую иную прикладную функцию. Когда мы пытаемся протестировать склеивающий код, мы часто заканчиваем с такими юнит-тестами, где требуется много моков и сильная связность с тестируемым кодом. Я не раз читал, что тестирование glue code — это антипаттерн. За процент сказать не решусь, но это точно не единичное мнение в индустрии. Гуглить по фразе glue code unit testing.

Вы можете заметить, что для иллюстрации недостатков юнит-тестов я взял очень неудачный юнит-тест — и будете правы. Есть хорошие алгоритмически содержательные методы, которые отлично тестируются юнит-тестами. Но вот какое совпадение... Я заметил, что подавляющее количество кода, который я пишу — это именно склеивающий код. Вот не пишем мы что-то алгоритмически сложные вещи типа новых алгоритмов сортировок или компиляторов. Не знаю, как у вас в вашем Company Name. Но у меня так.

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

  • Много кода – дорого писать и поддерживать. В этой связи меня часто удивляет, когда юнит-тесты считаются самыми дешевыми. Может вы слышали девиз code less – do more. Если можно решить проблему без лишнего кода, это зачастую предпочтительно потому-что код помимо написания (единоразовая активность) нужно еще сопровождать. Каждая лишняя строчка кода — это бремя с которым вам придется жить.

  • Мало чего проверяют в общей перспективе.

  • Сильная связность с тестируемым кодом – удорожает рефакторинг. В идеале тестирование должно развязывать вам руки для рефакторинга, помогая улучшать кодовую базу без страха что-то сломать. Юнит-тесты иногда могут делать прямо наоборот, потому-что их нужно перелопатить при рефакторинге.

  • Мало говорят о работе системы. Скажем прямо юнит-тест, проверяющий, что метод C класса А должен вызвать метод D класса B не сильно то много рассказывает о вашей системе. С точки зрения парадигмы «тесты = документация» юнит-тесты часто проигрывают всем вариантам.

Хочу подчеркнуть. Я не против юнит-тестов. И снова сошлюсь на главную идею «ресурсы на тестирование ограничены». Юнит-тесты плохие, если в данной конкретной ситуации стоят дорого относительно benefit’а. И юнит-тесты хорошие, если в данной в данной конкретной ситуации стоят дешево. Очень часто люди бездумно пишут юнит-тесты из некоего чувства долга. Хотя об этом позже, в разделе, где я про коснусь религии тестирования.

Вариант №2. Интеграционные/end-to-end тесты с внешними зависимостями

Что имеется ввиду? Когда у вас есть некая корпоративная сеть и окружение, где поднят каждый компонент и вы тестируетесь, реально используя этот компонент. Обычно это некое staging-окружение специально для тестов. Набор серверов, где поднято все от и до. Более-менее похоже на то, как оно все синтегрированно на проде, но для тестирования. Например, у нас есть несколько таких: dev, qa, ppd, perf. Проблемы с этими тестами довольно общеизвестные. Они долго отрабатывают. Они flaky. Я не нашел такого же краткого и емкого русского термина, подскажите, если знаете. Flaky это тест, который то красный, то зеленый по каким-то случайным причинам. А поскольку такие тесты вовлекают целый набор компонентов и выполняются долго, то потенциал для Flaky просто огромный. Какой-нибудь минутный лаг сети из-за плохой погоды во время 3-хчасового прогона и вот часть ваших тестов красная, хотя фактически ошибок в приложении нет.

Они дороги в машинных ресурсах. Держать полноценное окружение для тестирования — это не про дешевизну. Наконец, они зачастую не очень хорошо интегрируются в пайплайн. По хорошему прохождение тестов должно быть непременным условием, чтобы кнопочка merge для нового кода стала зеленой, но с тестами выполняющимися по несколько часов такое условие соблюсти затруднительно. В итоге код мержится и бывает даже не одна а несколько веток/задач одновременно. Потом для всего этого прогоняются тесты, что-то падает, и человеку, обеспечивающему контроль качества, приходится разбираться, какой же новый код все сломал, а программист уже за другую задачу сел, ему нужно снова переключать контекст, чтобы починить код, который уже в репозитории. Я уже не говорю о том, что один из принципов DevOps в том, что из master можно выйти на прод в любой момент, потому-что код должен тестироваться до попадания в master. С такими тестами это становится несбыточной сказкой. Это к вопросу про третий аспект цены тестирования — цикл обратной связи.

Вариант №3. Интеграционные тесты зависимостями в докере

Итак, наши бородачи-кодеры рассмотрели 2 довольно классических варианта, но оптимального решения все еще не нашли. Но они же умные ребята. Они знают Docker, Kubernetes и много других умных штук. «Почему бы не поднять все зависимости в докер-контейнерах?» - задумались они. Нам не понадобится дорогущее окружение, тесты можно будет запускать хоть на localhost. Они начинают работу в этом направлении, и перед ними встает несколько проблем. На самом деле докер здесь просто buzzword, речь в принципе о контейнерах.

Не все системы нам подконтрольны. В большой семье не без урода. Может у вас есть компонент, который не контейнеризируется нормально? Множество препятствий может стать на пути контейнеризации каждой конкретной зависимости. Лицензионные проблемы. Например, нужна полноценная версия Oracle Database. Форма поставки, которую мы не можем изменить. Например, система поставляется в виде установочного образа для установки bare-metal т. е. только на железо. Или как набор Ansible-плейбуков. Какие-нибудь внутренние политики компании, запрещающие копирование этого компонента. Или система сама представляет из себя завернутый в Docker-образ Docker Compose, где в свою очередь поднимается десяток подсистем, так что она стартует долго и жрет прорву машинных ресурсов. Многие из этих проблем преодолимы так или иначе, но сколько ресурсов вы можете потратить на их преодоление?

Это не говоря уже о том, что в Docker Compose у вас может подниматься достаточно много всего, так что тесты все равно становятся flaky. Ну и вишенкой на торте — иметь дистрибутив зависимости не всегда значит иметь все необходимое, потому-что иногда нужны тестовые данные, без которых эта зависимость бесполезна. Для которых может не быть скриптов инициализации или бэкапов, чтобы импортировать это в ваш контейнер.

Вы, наверное, обратили внимания, как часто я говорил сейчас «иногда» и «может быть». Да, иногда и может быть никаких этих препятствий не встанет. И у вас все легко, быстро и стабильно запускается командой docker compose up. Если вы из этих счастливых людей и у в ваших компонентах такой порядок, то просто забейте на эту статью. Вам незачем слушать такого неудачника как я. Вы справились со своей инфраструктурой намного лучше. Система должна подниматься в работоспособной и тестируемой конфигурации одной командой или кликом в идеальном случае. Это действительно образцовый подход. И если вы этого добились, вам ненужно мое трюкачество с RnR. Тех же, кому в жизни повезло меньше, приглашаю проследовать со мной к следующему тезису.

Вариант №4. Самописные моки для зависимостей

Итак, компания, где работали наши бородачи, оказалась не из самых прогрессивных. Они также столкнулись с проблемами при докеризации всех зависимостей для своего компонента. И стали рассматривать другие подходы. И следующий вариант, а что если взять наши зависимости и написать для них простейшие мок-сервера, покрывающие только необходимый нам функционал? Звучит, неплохо, но давайте посмотрим, какую работу мы себе при этом добавляем? Помимо, собственно, времени на написание моков, нам также придется потратить время на их актуализацию и сопровождение. Наконец, когда пишутся моки для сторонних сервисов, очень часто опускаются «несущественные» детали, которые на деле являются вполне себе существенными, но мы об этом как-то не подумали. Может быть реальный сервис делает валидацию входного параметра X, а в нашем моке, естественно, ни о какой валидации не подумали. Моя предыдущая команда наступила на эти грабли, когда разрабатывали компонент во многом полагаясь на моки собственной разработки. Их компонент на моках работал, но стадия когда понадобилось этот компонент «дружить» с реальными компонентами, в итоге очень затянулась. У нас на стэндапах фраза «на моках работает» на некоторый период времени стала крылатой.

Может быть вы скажете, а что если сделать некий гибридный подход, когда мы легко докеризуемые сервисы запускаем в докере, а для проблемных делаем мок-сервера. Это неплохая идея и вообще часто именно гибридные подходы и помогают нам хорошо так сэкономить усилия. Поэтому предлагаемая мной сегодня на рассмотрения идея тоже является гибридной, только это немного другой гибрид. RnR – это гибрид подходов с внешними зависимостями, мок-серверами и даже отчасти юнит-тестами.

Вариант №5. Record-and-replay

Основная идея крайне проста. При первом прогоне теста мы записываем ответы сторонних систем, а при последующих воспроизводим записанные ранее ответы. Давайте посмотрим, какие из вышеозвученных проблем мы тогда решим.

Во-первых эти тесты долго отрабатывают только в первый раз, когда вы пишете кассеты. Кассеты — это как раз те файлы, куда записывается трек ответов от сторонних систем. Но при последующих прогонах все выполняется со скоростью сравнимой с юнит-тестами, потому-что запросы не уходят никуда дальше локалхоста и, при этом, мы не тратим время на поднятие зависимостей. А поскольку чаще всего запись кассеты единичная операция, а вот прогон тестов в пайплайне — частая, получается, что тесты отрабатывают быстро когда это нужнее всего. Благодаря этому они легко интегрируются в пайплайн, ведь внешних зависимостей нет, а значит ваша Jenkins-джоба может работать также, как если бы это были юнит-тесты. И они не flaky поскольку никакие флуктуации сетевой связности или работы сложных внешних компонентов не оказывают на них влияния.

Но при этом для них не требуется писать много кода, как для юнит-тестов, потому-что проблема мокирования зависимостей теперь снимается с ваших плеч. Вы можете использовать ваше уже имеющееся окружение для записи кассет. Например, упомянутый ранее QA-environment.

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

Вам определенно понадобится кое-что сделать с вашим приложением, чтобы запросы уходили не во внешнюю сеть, а в библиотеку, которая обеспечит эту логику: первый прогон пишем, потом воспроизводим. Но обычно для сетевого взаимодействия мы используем какую-то библиотеку, поэтому слишком сильной связности с кодом эти тесты иметь не будут. Замена одной библиотеки — это не мокирование каждого второго класса в коде.

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

Решена проблема поддержания моков в актуальном состоянии, потому-что всегда можно просто стереть кассету и просто выполнить тест на запись снова. Ну а поскольку сервисы реальные, то никаких проблем с упрощением в моках мы тоже не испытываем.

Можно было бы удивиться, почему при всей простоте идеи, это не используется повсеместно. Но я думаю, что объяснение этому все-таки есть. Чисто умозрительно сразу возникают определенные сомнения, относительно того, будет ли это так просто, как звучит на словах. Возможно как раз какое-то из этих сомнений как раз сейчас пришло вам на ум и вы сидите и думаете: «Не, ну это не будет так просто, потому-что...» Вот на эти «потому-что» я сейчас и попытаюсь ответить. По крайней мере на те, о которых мне известно. Может в комментариях вы обогатите мою копилку новыми «потому-что».

Подводные камни (опасения), как не запороть идею

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

Кассеты будут меняться на каждый чих. Действительно, в чем смысл от выполнения с воспроизведением кассеты, если в вашем приложении эти тесты будут почти всегда выполняться в режиме записи, т. е. как обычные интеграционные? Вы просто получаете интеграционные тесты с дополнительной головной болью. Поэтому нужно сделать так, чтобы у вас кассеты не требовали постоянной перезаписи. И самый простой принцип здесь — разделите тест кейсы. Если у вас все взаимодействие с внешними сервисами будет свалено в одну кучу, например, в один файл, то естественно этот файл будет меняться постоянно. И его постоянно придется снова перезаписывать. Ведь кроме рефакторингов все изменения в коде приложения функциональные, а значит что-то будет меняться и в его поведении с внешними сервисами.

Но вот в чем штука, на самом деле ситуаций когда у нас полностью с очередным коммитом меняется все поведение с внешними сервисами очень мало. Обычно в приложении добавляется/меняется маленький кусочек функциональности, который отвечает за изменения в каком-то конкретном сценарии работы. Изолируйте эти сценарии. Например, сделав по кассете на каждый тестовый метод в своем наборе тестов. Пример: в приложении бородачей был сценарий, когда происходила загрузка zip’а с книгой. И когда происходила загрузка jpeg’а с обложкой книги. И они написали 2 теста, по одному на каждый сценарий. И когда кто-то менял какие-нибудь нюансы работы с jpeg, например требования по минимальному разрешению картинки, перезаписывать надо было только кассету для одного теста. А все остальные оставались нетронутыми. И лишь один тест им надо было перезапускать в режиме записи новой кассеты. И лишь один файл с кассетой менялся в репозитории. Причем по названию файла было четко видно, для какого сценария поменялся контракт. И если вдруг один бородач обнаруживал, что после правок для изменения разрешения картинки ломался тест testIngestionZip, потому-что в кассете не было запроса к сервису Y на валидацию картинки, то было вполне очевидно, что он случайно внес нежелательные изменения, потому-что для zip ему вообще ничего трогать было не надо. Итак, первый принцип — изолированные сценарии. Меняться в репозитории должно только то, что действительно меняется.

Поведение системы уникально при каждом прогоне. Например, мы делаем запрос на создание некоей сущности и генерируем ее id – какой-нибудь uuid. При каждом запуске приложения эта случайная величина будет другой. И, естественно, ни о каком выполнении тестов на кассетах и речи быть не может, потому-что каждый раз это будет немного другая пара request/response. Естественно, такой RnR никому жизнь не облегчит, я с вами полностью согласен. Поэтому… Для тестовой конфигурации уберите из системы недетерминированность. Это на самом деле очень простое и быстрое решение, потому-что вообще-то наши программы в своей основе чаще всего детерминированны. Компьютеры вообще детерминированная штука. В них нет очень мало случайности. Даже случайные числа, которые мы используем в своих программах, на самом деле псевдослучайные, поскольку представляют из себя элементы определенной математической последовательности, генерируемой строго детерминированным алгоритмом. Который устроен так, чтобы эти числа казались случайными. Просто стартовая итерация этого алгоритма инициализируется чем-то случайным из случайного мира людей, а не компьютеров. Например текущей датой, потому-что кто знает когда пользователю взбредет нажать кнопочку Z? Эта случайная величина становится seed (семенем) генератора псевдо-случайных чисел, на основании которого он потом генерирует последовательность псевдо-случайных чисел.

Кто в теме, те понимают, что я сейчас малость упростил. Например, не упомянув аппаратный генератор случайных чисел. Кто не в теме, можете погуглить и стать в теме. Но и тех и других я подвожу к одной простой мысли. Из вашей программы, скорее всего, очень легко убрать случайность, потому-что она в принципе для компьютеров нехарактерна. Источников случайности внутри компьютера не так уж и много. Например, как в представленном примере, поменять тестовый контекст, чтобы UUID в приложении генерировались не случайно, а каждый раз одни и те же. До тех пор, пока вы задаете в тесте тот же seed. И все. Id вашей сущности в тестах теперь не случайная величина, а повторяемая. И у вас одни и те же пары request/response.

@Bean
public UuidGenerator uuidGenerator() {
  return new UuidGenerator() {
    @Overide
    public UUID generateUuid() {
    	return UUID.nameUUIDFromBytes((seed + counter++).getBytes());
    }
  }
}

Можно заменить все вызовы new Date(), если эти даты у вас где-то в запросах на вызов некоего бина-провайдера даты. А в тестовом контексте этот провайдер будет выплевывать просто одну и ту же дату. Снова убрали недетерминированность.

Если не получается справиться с недетерминированностью, вы всегда можете немножко подправить матчинг request на response в вашей библиотеке, отвечающей за запись/вопроизведение кассеты. Например, добавив в матчинг такую регулярку, чтобы разные uuid в некоем url’е матчились как одинаковые. Когда перейдем к рассмотрению библиотек, я как раз покажу пример кода. Но это плохой путь. По возможности лучше избавляться от недетерминированности. Сейчас объясню, почему.

Кассеты будут в аршин длинной, поэтому на код-ревью никто не будет проверять, что там поменялось. И это правда. Если кассеты нечитаемые или diff их изменений в pull-request огромен, то все будут просто забивать, чего там меняется. А если этот текст никто не смотрит, то велика вероятность, что там появится полная ерунда. Например, при очередном pull-request нам отправят код, который вместо правки минимально допустимого размера картинок вообще отключает эту валидацию. Но никто кассету не смотрит. Поэтому не увидит, что в сервис валидации картинок теперь отправляется полная чепуха. Если на изменения в кассетах не смотрит человек, то ваши RnR тесты нередко вместо, собственно, тестирования могут создавать иллюзию протестированности. Как бороться? Обеспечьте хороший человекочитаемый diff. Минорные изменения в коде не должны приводить к тому, что у вас поменялась вся кассета целиком. Кассеты представляют своего рода документацию на контракт приложения. Они должны помогать вам легче видеть связь изменений в коде и изменений в контракте.



Посмотрите пример на скриншоте. Это diff изменений в кассете. Выяснилось, что изначально приложение было реализовано неправильно, и вместо уровня информативности строки отчета (ERROR/INFO и т. п.) отправляло какую-то чепуху. Были внесены изменения, чтобы отправлялся именно уровень информативности. И на diff это четко видно. Представьте себе, если бы в этой строчке поменялась еще и дата. Легко бы ваш взгляд зацепился и увидел, где же меняется контракт после этого коммита? А если бы поменялись все строчки до и после? Именно поэтому в рекомендации ранее я советовал стараться убирать недетерминированность, а не править матчинг регулярками. Это помогает еще и diff кассеты делать более читаемым. Ну а так вообще здесь нет какого-то универсального рецепта. Смотрите глазами ваши diff’ы. И старайтесь править то, что мешает их читаемости. Может ваше приложение вообще работает по бинарным протоколам? Если нет возможности модифицировать тестовый контекст так, чтобы в кассетах сохранялось их какое-нибудь человекочитаемое представление, то, возможно, RnR вам радикально не подходит.

Например, ваше приложение отправляет в сторонний сервис крошечный .xlsx – это формат Microsoft Excel и он бинарный. Но на самом деле это .zip, в котором лежит несколько .xml-файлов. Открыв этот файл в архиваторе вы легко это увидите. Рекомендация: отправляйте на сторонний сервис .xlsx, а в бин клиента этого сервиса в тестовом контексте модифицируйте так, чтобы в кассеты попадали распакованные .xml из этого .zip. Это просто пример, тут нет универсальных решений, зависит от специфики вашего приложения. Просто принцип, нечеловекочитаемые кассеты сильно снижают ценность RnR. Поборитесь за человекочитаемость. Но только это если это не сильно дорого. Ведь наши ресурсы ограничены, помните, да?

Ну и еще несколько опасений, на которые у меня нет рекомендации, как избежать, чтобы они сбылись. RnR не серебрянная пуля и может не дать вам существенных преимуществ. Например, если проблема flaky-тестов у вас не внешние зависимости, а то, что само ваше приложение работает через раз. Или оно стартует по полгода и дело не в сетевом взаимодействии. В вашем пайплайне с RnR оно все равно будет запускаться так же полгода.

Еще один совет, хотя он, пожалуй, универсальный, а не только про RnR. Вы можете легко свести преимущество перед юнит-тестами на нет, если писать тесты не с позиции внешних контрактов вашего приложения. Когда у вас поменялся тест testCnZipHappyPath, все ли поймут, какой бизнес-кейс теперь работает по-другому? Непонятные, ничего не документирующие, непрозрачные тесты можно писать и с RnR, если называть их как попало и мешать в кучу сценарии, которые с позиции бизнеса или контрактов вашего приложения совершенно разные. Называйте тесты понятно, делите их так, чтобы они были логически изолированны. Что было бы, если бы команда бородачей в одном тестовом методе проверяла и .zip и .jpeg? Вдобавок к тесту, который непонятно что проверяет, они бы еще получили и кассету, которая непонятно какой кейс описывает. 2 проблемы по цене одной.

Ах да. И последнее. Не забудьте сделать так, чтобы в кассетах у вас не оседали всякие секретные ключи и прочие пароли. Идея в том, чтобы кассеты хранились в git вместе с приложением. А секретам в git не место. Например, отключите запись в кассеты тел запросов для своего авторизационного API. Ведь чаще всего в запросах на авторизацию есть пароль в открытом виде в request body.

Библиотеки

Итак, если мне удалось вас убедить в жизнеспособности идеи, и вы хотите попробовать, то как же это сделать? Чтобы вам не собирать все шишки, которые я собрал, я немножко поделюсь сниппетами кода и деталями реализации.

В основном про Java, но немного скажу и про другие языки. Во-первых, что у нас есть из готового? WireMock, который, уверен, многие знают или хотя бы слышали, умеет работать в режиме записи/вопроизведения. Сам не пробовал, читал в документации, что есть такая возможность. Я остановился на втором варианте — AnyStub. Про него и расскажу, поскольку это то, с чем у меня есть опыт.

WireMock – это полноценный веб-сервер, который слушает на определенном порту, но может быть запущен как часть вашего приложения и имеет JavaAPI для управления этим запущенным сервером. Это позволяет его применить в более широком диапазоне случаев, ведь все-таки это по сути стандартное HTTP-взаимодействие. В режиме записи WireMock отправляет ваш запрос на внешний API, а ответ ретранслирует и сохраняет себе на будущее для воспроизведения. Ну и, конечно, если у вас не Java, вы можете запустить WireMock в docker, а в своем приложении прописать его URL вместо реальных внешних зависимостей.

В отличии от него AnyStub имеют чуть более узкую сферу применимости. Потому-что он подменяет Apache Http Client. Многие библиотеки/фреймворки в Java под капотом используют Apache Http Client, тот же Open Feign или Spring Rest Template. Многие, но не все, что и ограничивает его применимость. Однако зато AnyStub очень легко в строить в тестовый Spring-контекст, он легко туда входит как замена этого самого Apache Http Client.

Пример с AnyStub

@Configuration
public class RnrTestConfig {
  private HttpClient httpClient() {
    HttpClient real = HttpClientBuilder.create().build();
    StubHttpClient stubHttpClient = new StubHttpCleint(real);
    return stubHttpClient;
  }
  
  @Bean
  public Client feingClient() {
    return new ApacheHttpClient(httpClient());
  }
}

Вот пример тестовой конфигурации. Как видите, здесь все тривиально. Создали StubHttpClient, которому подсунули настоящий. Когда нужно будет отправлять запрос на реальный сервер, этот клиент переправит его настоящему. А когда нужно брать ответ из кассеты, то сам поищет соответствующий этому запросу response в кассете и вернет его якобы тот пришел из стороннего сервиса. Подсовываем тому же Feign наш липовый httpClient и готово. Больше конфигурировать особо нечего.

@RunWith(SpringRunner.class)
@TestPropertySource(locations="classpath:application-test.properties")
@AnySettingsHttp(
  bodyTrigger = { // include bodies in matching requests only to specific
    							// external APIs
    "job-api-dev.somewhere.com:8080/",
    "validation-service-dev.somewhere.com:8080/"
  },
  bodyMask = { // multipart boundaries are not repeatable, so we mask them
    "/content/", "--(.*)(--)?\\r"
  }
)
public class CompleteCnWorkflowRnrTest {
  @Test
  @AnyStubId(requestMode = requestMode.rmTrack)
  public void testBookZipUploadHappyPath() {
    uuidGenerator.setSeed(getMethodName() + "_cassette#3_");
    startProcess(buildUrl("nonprod-exchange-dev", "Book1976_en-US.zip"), 
                 getAuthToken());
    assertEquals(getFlowOrder(getMethodName()), flowTracker.populateStages());
  }
}

Теперь, собственно, сам код теста. Это обычный в общем-то Spring-тест. Из специфичного тут пара аннотаций для того, чтобы AnyStub знал, как работать с этим тестом. Первая — @AnySettingsHttp позволяет несколько кастомизировать поведение AnyStub. Например, по умолчанию тела запросов не сохраняются в кассету и не используются в матчинге. Тут сделано, чтобы для нужных нам сервисов (содержащих заданную подстроку в URL) они должны сохраняться. Для остальных URL останется поведение по умолчанию. А bodyMask – это как раз пример того матчинга, о котором я ранее упоминал. У нас в контентный сервер уходили запросы с multipart POST. И из-за того, что разделители multipart генерировались где-то в недрах библиотеки, нам оказалось здесь проще сделать матчинг, а не добиться детерминированности. Тут банальная регулярка. Все, что попадает под эту регулярку выкусывается перед матчингом. Другими словами когда мы, например, сравниваем request body в кассете с request body, который прилетел в наш фальшивый клиент, мы сначала удалим из обоих все, что соответствует регулярке. Убрав этот случайно генерируемый кусок мы добиваемся того, что строчки одинаковые. Вуаля, библиотека считает, что это тот же самый request, хотя у него немного другой body. И отдает в вызываемый код соответствующий response из кассеты. Первый аргумент «/content/» значит, что это правило мы применяем только для URL, в которых есть подстрока «/content/».

Ну и аннотация @AnyStubId на тестовом методе служит для задания имени кассеты и режима воспроизведения/записи. Имя здесь можно задать произвольное, но в примере оно опущено, поэтому автоматически используется имя аннотированного метода. Режим rmTrack значит, что запросы должны идти в строго том же порядке, как это было при записи. Если, допустим, у нас есть запрос GET /content/node, но в кассете он идет третьим, а сейчас пришел но вторым, то AnyStub вместо того, чтобы вернуть записанный response бросит свой RuntimeException. Тест свалится. Мы поймем по тексту исключения, что у нас внезапно образовался второй запрос, которого раньше не было. Вроде бы довольно просто.

Готовые решения для не-Java

И немного о других языках. Практически в каждой экосистеме есть свои аналоги. Например VCR.py для Python. VCR для Ruby. В общем, кто ищет — тот наверняка найдет. Да и самостоятельно что-то накостылить несложно. Наконец, как уже упоминалось ранее, можно запустить в WireMock как отдельное приложение, слушающее на своем HTTP-порту. И тогда язык вашего приложения вообще становится неважным. Для разделения тест-кейсов можно использовать, например, ваш заголовок сквозного идентификатора транзакции (header типа Transaction-Id или Trace-Id или что у вас для этого). Т. е. ваши запросы из тестового контекста могут выглядеть как-нибудь так:

POST / HTTP/1.1
Host: somewhere.com
Content-Type: application/x-www-form-urlencoded
Trace-Id: testBookZipUploadHappyPath

title=How to kill yourself using lemon and toothpaste

Еще о преимуществах

Перечислю еще несколько позитивных моментов от таких тестов, которые не были перечислены при сравнении с другими подходами. Я уже кратко упоминал о том, что кассеты в репозитории могут выступать дополнительным средством документирования вашего приложения. Но не только документирования еще и анализа. Поскольку все сетевое взаимодействие у нас записано в кассетах, у нас есть полная картина того, что и в какой последовательности оно пересылает по сети. Сведя к нулю задержки от обращений к сторонним сервисам, вы можете легче увидеть производительность именно вашего приложения в чистом виде.

И, поскольку такие тесты выполняются очень быстро, они существенно облегчают рефакторинг. Если вы хотите только улучшить кодовую базу без изменения контракта, то это замечательный способ. За несколько секунд вы можете увидеть, не внесли ли вы ненамеренных изменений в поведение вашего приложения. Хорошие тесты нужны для того, чтобы разработчики не боялись рефакторить. Если разработчики бояться вносить изменения, то это верный путь к превращению в legacy. И RnR тесты отлично помогают в решении этой проблемы. Это самый короткий цикл обратной связи из возможных. Быстрее некуда, благодаря этому избавление от legacy-кода в приложении становится простейшей операцией. Имея полную страховку от случайных изменений поведения вы имеет по сути полный карт-бланш, чтобы смело менять в коде любую деталь. Я, наверное, слишком много разными словами повторяю одну и ту же мысль… Просто я немного перфекционист и люблю полировать код приложения до блеска. А за свободу, которую мне в этом дают RnR, я особенно их люблю.

RnR тесты вполне себе подходят для написания QA-специалистами, потому-что не считая модификаций в контексте, в остальном этой чистой воды черный ящик. Т. е. в отличие от юнит-тестов, тестировщику нет необходимости иметь знания о внутренностях системы. Модифицированный контекст для тестов может написать программист, знающий внутренности приложения. А тесты может писать кто угодно. Мы ведь работаем с известными по контракту входами-выходами в систему, никаких Mockito и прочих заглушек классов внутри приложения.

Легко отслеживать изменения в контракте. Можно просто посмотреть git history для кассеты и вам сразу будет понятно, что как и когда менялось во внешнем взаимодействии сервиса.

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

Недостатки подхода

Конечно же статья будет неполной без упоминания недостатков RnR. Как уже упоминалось, это не серебрянная пуля. А значит необходимо понимание, когда RnR поможет, а когда нет. Итак, вот проблемы RnR-тестов.

Плохи для покрытия маленьких вариаций в поведении приложений из-за взрывного роста количества, а, следовательно, и объема кассет. Допустим, тот же тест на загрузку картинки. Если мы решили загрузить 600 разных картинок в разных разрешениях — вы получите 600 кассет. А это уже приличный такой объем. В вашем репозитории кассет будет больше чем кода. Поэтому если вы хотите протестировать некий очень вариативный кусочек приложения, лучше сделать это юнит-тестом. Допустим, например, бородачам вместе с .zip из сторонней системы приходит IP загрузившего книгу и есть код, который его валидирует. И чтобы проверить эту валидацию они могут захотеть попробовать около десятка разных строчек, которые представляют из себя как разные корректные, так и некорректные IP. Но это может привести к десятку кассет, в каждом из которых десяток пар request/response, но все практически одинаковое за исключением IP в одном запросе. Естественно, бородачам лучше написать юнит-тест на их класс IpValidator, в котором все эти вариации проверяются.

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

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

Место в идеологии тестирования

Эта тема может быть провоцировать холивары, но, я думаю, она достаточно важная чтобы ее затронуть. Пусть даже меня потом обругают самыми плохими словами те, у кого взгляды другие. Во-первых можно кратко уточнить, что RnR-тесты имеют мало связи с TDD, потому-что red-green итерации TDD обычно достаточно маленькие, такие, что в результате рождаются юнит-тесты. Хотя, если ваш проект уже готов и покрыт RnR, для какой-то минорной функциональности вы можете пойти руками поправить кассету, отражая то, что должно измениться в контракте, а потом пойти и поправить соответствующий класс. И это вполне укладывается в TDD, потому-что тем самым вы сначала внесли изменения в тест (red), а потом пошли и поправили код (green). Ну и, может, потом еще порефакторили. Но все-таки я бы сказал, что в пирамиде тестирования RnR занимает место где-то на границе между интеграционными и юнит-тестами.



И, теперь, собственно, о пресловутой пирамиде. Мне доводилось сталкиваться с командами, где принята практика упорно покрывать все подряд юнит-тестами, просто потому-что это ведь пирамида. Значит юнит-тестами должно быть покрыто все, а остальных тестов пропорционально меньше, а иначе у вас перевернутая пирамида. О, нет! Вы согрешили против нашей религии тестирования. Да, именно так! Религия тестирования. У многих людей написание тестов — это как исповедь у священника. Способ заглушить совесть, которая нашептывает «вот ты не пишешь тесты, а нужно писать, ты плохой». И тогда и рождаются бесконечные юнит-тесты на glue-code типа того, что мы разбирали вначале.

Хочу вернуться к главному постулату доклада. Ваши ресурсы ограничены. Написание тестов — это не религиозное действо, а разумный акт людей, которые хотят автоматизировать рутину контроля качества, чтобы эта автоматизация помогала им же, высвобождая их для других более полезных задач. Автотесты нужны, чтобы экономить ваши усилия, а не создавать вам лишнюю работу.

Наверное, один из самых спорных, но самых важных тезисов доклада. Если вы что-то проверяете дважды, а то и больше — это преступление против эффективности. По сути это значит, что несколько раз подряд была сделана одна и та же работа. Зачем? Если работа сделана, не нужно ее переделывать снова. Если не сделана — сделать. Вроде очевидно, но мне постоянно доводилось сталкиваться с командами разработки, где для какой-нибудь условной валидации входного параметра сначала разботчик напишет юнит-тест, потом тестировщик напишет интеграционный тест, а в некоторых компаниях потом еще и кто-нибудь вручную проверит. Так спокойнее. Чем больше глаз проверит, тем спокойнее. Согласен, спокойнее. Но это же и тратит ваши ресурсы. А их всегда не хватает.

Оптимизируйте, свои траты ресурсов на тестирование. Понимайте, что и на каком уровне пирамиды будет проверено, не чтобы не перепроверять одни и те же вещи по 100 раз. Обвешайте свой процесс тестирования метриками. Вы не можете оптимизировать что-то, пока вы это не считаете в некоем численном измерении. Coverage – самое очевидное, скажет вам, заглядывают ли ваши тесты вообще в эту строчку. Простая численная метрика, поэтому с ней просто работать, считается полностью автоматически массой разных инструментов. Но coverage скажет вам только о том, что этот код выполнился в ходе тестирования, но не скажет, был ли проверен его результат, хороши ли ваши assert’ы. Мутационное тестирование. Идея проста — фреймворк вносит случайные модификации в вашу кодовую базу собирает модифицированную сборку вашего приложение — мутант. И прогоняет ваш набор тестов. Процент выживших мутантов — мера оценки качества ваших assert’ов. Если в строке + на - поменялся, а ваш тест все равно выполнился успешно, хоть и по строке есть coverage, значит плох тот coverage. Тоже численная метрика. Это те, которые я знаю. Вообще буду рад идеям метрик, которые вы можете подсказать. Такие, чтобы тоже считались автоматически и помогали определить качество тестирования простым арифметическим сравнением чисел. Если есть идеи, буду рад подсказкам, чтобы засунуть их в свой pipeline.

Неважно, какой формы у вас там будет пирамида. Кривая, косая, на пирамиду не похожая. Одни приложения хорошо покрываются одними видами тестов, другие другими. Главное, чтобы у вас были числа, помогающие быть уверенным в том, что вы надежно протестировали все, что нужно протестировать. Многие проекты, с которыми мне доводилось сталкиваться, очень хорошо и недорого покрываются RnR-тестами с небольшой присыпкой из юнит-тестов для случаев, где RnR ведут к слишком большому количеству кассет. Или где обрабатываются настолько исключительные ситуации, что проще всего попасть в эту ветку кода все-таки юнит-тестом. Но встречались и проекты, где RnR не заходит совсем. Слишком малое соотношение польза/затраты.

Обсудить в форуме