Вы практикующий маг менеджер. Или боевой разработчик. Или профессиональный тестировщик. А может быть, просто человек, которому небезразличны разработка и использование систем, включающих в себя клиент-серверные компоненты. Уверен, вы даже знаете, что порт это не только место, куда приходят корабли, а «ssh» это не только звук, издаваемый змеёй. И вы в курсе, что сервисы, расположенные на одной или нескольких машинах, активно между собой общаются. Чаще всего по протоколу HTTP. И от версии к версии формат этого общения нужно контролировать.
Думаю, каждый из вас при очередном релизе задавался вопросами: «Точно ли мы отсылаем верный запрос?» или «Точно ли мы передали все необходимые параметры этому сервису?». Всем должно быть известно и о существовании негативных сценариев развития событий наравне с позитивными. Это знание должно активно порождать вопросы из серии «Что если..?». Что если сервис станет обрабатывать соединения с задержкой в 2 часа? Что если сервис ответит абракадабру вместо данных в формате json?
О таких вещах нередко забывается в процессе разработки. Из-за сложности проверки проблем подобного рода, маловероятности таких ситуаций и еще по тысяче других причин. А ведь странная ошибка или падение приложения в ответственный момент могут навсегда отпугнуть пользователя, и он больше не вернётся к вашему продукту. Мы в Яндексе постоянно держим подобные вопросы в голове и стремимся максимально оптимизировать процесс тестирования, используя полезные идеи. О том, как мы сделали такие проверки легкими, наглядными, автоматическими и пойдет речь в этой статье.
Соль
Есть ряд давно известных способов узнать, как и что передается от сервиса к сервису — от мобильного приложения к серверу, от одной части к другой.
Первый из наиболее популярных и не требующих серьезной подготовки — подключиться специальной программой к одному из источников передачи или приёма данных. Такие программы называются анализаторами трафика или чаще — снифферами.
Второй — подменить искусственной реализацией целиком одну из сторон. В таком подходе есть возможность определить четкий сценарий поведения в определенных случаях и сохранять всю информацию, которая придет этому сервису. Такой подход называется использованием заглушек (mock-объектов). Мы рассмотрим оба.
Использование снифферов
Приходит новый релиз или отладка изменений, затрагивающих межсервисное общение. Мы вооружаемся нужными программами-перехватчиками — WireShark'ом или Tcpdump. Запускаем перехват трафика до нужного узла, наложив фильтры хоста, порта и интерфейса. Делаем «темные дела», инициируя нужное нам общение. Останавливаем перехват. Начинаем его разбирать. Процесс разбора у каждого сервиса свой, но обычно всегда напоминает судорожные поиски в куче текста заветных GET, POST, PUT и т.д. Нашли? Тогда повторяем это из релиза в релиз. Это же теперь проверка на регрессию! Не нашли? Повторяем это с разными комбинациями фильтров до понимания причин.
Из релиза в релиз?
Вручную такое делать можно раз. Или два. Ну, может быть, три. А потом точно надоест. А на пятый релиз это общение возьмет и сломается. Особенно сложно это заметить косвенно, когда общение представляет из себя вызов колбэка с каким-нибудь уведомлением. Повторяющиеся из релиза в релиз механические действия, отнимающие много времени и сил, стоит автоматизировать. Как это делать в JAVA? Уверен, на других языках это можно сделать похожим образом, но конкретно связка JUnit4 + Maven для автоматизации тестирования в Яндексе прекрасно работает и хорошо себя зарекомендовала.
Предположим, что сервис мы тестируем интеграционно, а значит, скорее всего это похоже на боевой режим, когда он поднят на отдельной машине, а мы подключены к ней по SSH. Берем библиотечку для работы по SSH, подключаемся к серверу, запускаем tcpdump и ловим все, что можем, в файлик (все в точности как руками). После теста принудительно завершаем процесс и ищем в файлике то, что нужно, используя grep, awk, sed и т.д. Затем полученное обрабатываем в тесте. Делали так? Нет? И не нужно!
Почему не нужно так делать?
Хочу заметить, что «не нужно» не значит «не можно».
Делать так можно. Просто есть способы проще, потому что:
- Разбор на составляющие больших строк — это всегда много специфического кода, который сложно поддерживать.
- Разбор HTTP сообщений уже давно сделан в сотне библиотек. Зачем делать сто первую?
Пробовали именно такой способ и мы в Яндексе. Сперва это казалось удобным — от нас требовалось только запустить и остановить tcpdump по ssh, а потом найти нужную подстроку в его выдаче. Первые проблемы с поддержкой начались почти сразу: порядок следования query-параметров оказался случайным в искомых запросах. Пришлось разбить эталонную строку на несколько и проверять вхождение каждой. Удручали и сообщения об ошибках в случае, если запроса не находилось, — тонны текста не давали адекватного способа себя структурировать. Головной боли добавили асинхронные запросы, которые могли появиться через несколько минут после активных действий. Приходилось строить головокружительные конструкции по ожиданию нужной подстроки в выдаче с определенной задержкой. Код тестов иногда становился сложнее кода, который мы проверяли. Тогда мы и стали искать другой способ тестировать эту часть.
Использование заглушек вместо web-сервисов
Так как речь идет о тестировании, то, скорее всего, у нас есть все возможности не просто поставить сеточку в виде сниффера, но и подменить один из сервисов целиком. При таком подходе до искусственного сервиса дойдут «чистые» запросы, а у нас будет возможность управлять поведением конечного пункта для сообщений. Для таких целей есть замечательная библиотека WireMock. Её код можно посмотреть на GitHub-странице проекта. Суть в том, что поднимается web-сервис с хорошим REST-api, который можно настроить почти любым образом. Это JAVA-библиотека, но у нее есть возможность запуска как самостоятельного приложения, было бы доступно jre. А дальше простая настройка с подробной документацией.
Тут и произвольные коды ответа, и произвольное содержимое, и прозрачное перенаправление запроса в реальные сервисы с возможностью сохранить ответы и отсылать их затем самостоятельно. Особо стоит отметить возможность воссоздать негативное поведение: таймауты, обрывы связи, невалидные ответы. Красота! Библиотека при этом может работать и как WAR, который можно загрузить в Jetty, Tomcat и т.д. И, самое главное, эту библиотеку можно использовать прямо в тестах как JUnit Rule! Она сама позаботится о разборе запроса, разделив тело, адрес, параметры и заголовки. Нам останется только в нужный момент достать список всех пришедших и удовлетворяющих критериям.
Автоматизируем проверки, используя заглушку
Прежде чем продолжать, нужно определиться, какие этапы нужно пройти, чтобы обеспечить легкое, наглядное и автоматическое тестирование, используя второй из рассмотренных вариантов — mock-объект.
Стоит заметить, что каждый из этапов так же возможно проделать вручную без особых затруднений.
Схема
Что проверяем? {сообщение}
в схеме:
тестируемый_сервис -> {сообщение} -> сервис_заглушка.
Более точно, схема будет выглядеть так:
тестируемый_сервис -> сервис_заглушка :(его лог): {сообщение}.
Таким образом, нам нужно сделать несколько вещей:
- Поднять сервис-заглушку и заставить его принимать определенные сообщения, отвечая ОК (или неОК — зависит от сценария). Этим займется WireMock.
- Обеспечить доставку сообщений до сервиса-заглушки (в схеме это
->
). Об этом этапе поговорим отдельно. - Провалидировать то, что пришло. Тут два варианта — используя средства WireMock для валидации, либо получив от нее список запросов, применяя к ним матчеры.
Поднимаем искусственный web-сервис
Как поднять сервис вручную, подробно описано на сайте wiremock в секции Running standalone. Как использовать в JUnit тоже впрочем описано. Но это нам понадобится в дальнейшем, поэтому приведу немножко кода.
Создаем JUnit правило, которое будет поднимать сервис на нужном порту при старте теста и завершать после окончания:
@Rule
public WireMockRule wiremock = new WireMockRule(LOCAL_MOCKED_PORT);
Начало теста будет выглядеть примерно так:
@Test
public void shouldSend3Callbacks() throws Exception {
// Пусть наша заглушка принимает любые сообщения
stubFor(any(urlMatching(".*")).willReturn(aResponse()
.withStatus(HttpStatus.OK_200).withBody("OK")));
...
Здесь мы настраиваем поднятый web-сервис, чтобы он на любой запрошенный адрес отвечал кодом 200 с телом «ОК». После нехитрых действий по настройке, есть несколько вариантов развития событий. Первый — у нас нет никаких проблем с доступом на любой порт от клиента до той машины, на которой выполняется тест. В этом случае мы просто совершаем нужные действия в рамках тесткейса, после — переходим к валидации. Второй — у нас есть доступ только по ssh. Все же порты прикрыты брэндмауэром. Тут на помощь приходит ssh port forwarding (или ssh-tunneling). Об этом речь ниже.
Сокращаем дорогу пакетам
Нам потребуется REMOTE (который с ключом -R) и, соответственно, ssh-доступ к машинке. Это позволит тестовому сервису обращаться на свой локальный порт, а нам — слушать свой. И все будет работать.
Если в двух словах, то ssh port forwarding (или ssh-tunneling) — это прокидывание трубы через ssh соединение от порта на удаленной машине до порта на локальной. Хорошую инструкцию по применению можно найти на www.debianadmin.com
Так как мы занимаемся автоматизацией этого процесса, рассмотрим подробно, как сделать использование этого механизма в тестах удобным. Начнем с самого верхнего уровня — интерфейса junit-правила. Она позволит прокинуть связь хост_удаленной_машины:порт -> ssh -> хост_машины_где_фейк_сервис:его_порт
до старта теста и закрыть тоннель после его завершения.
Делаем junit-правило перенаправления портов
Вспоминаем про библиотеку Ganymed SSH2. Подключаем ее, используя maven:
<!--https://code.google.com/p/ganymed-ssh-2/-->
<dependency>
<groupId>ch.ethz.ganymed</groupId>
<artifactId>ganymed-ssh2</artifactId>
<version>${last-ganymed-ssh-ver}</version>
</dependency>
(Релизную версию всегда можно увидеть в Maven Central.)
Открываем пример, использующий эту библиотеку для поднятия туннеля через ssh. Понимаем, что нам нужно четыре параметра. Будем считать, что тестируемый «разговаривает» через свой локальный порт, поэтому хост_удаленной_машины
приравниваем к 127.0.0.1
.
Остаётся три параметра, которые требуется указывать:
@Rule
public SshRemotePortForwardingRule forward = onRemoteHost(props().serviceURI())
.whenRemoteUsesPort(BIND_PORT_ON_REMOTE)
.forwardToLocal().withForwardToPort(LOCAL_MOCKED_PORT);
Здесь .forwardToLocal()
это:
public SshRemotePortForwardingRule forwardToLocal() {
try {
hostToForward = InetAddress.getLocalHost().getHostAddress();
} catch (UnknownHostException e) {
throw new RuntimeException("Can't get localhost address", e);
}
return this;
}
Junit-правило удобно делать как наследника ExternalResource
, переопределив before()
для авторизации и поднятия туннеля, а after()
для закрытия туннеля и соединения.
Само соединение должно выглядеть примерно так:
logger.info(format("Try to create port forwarding: `ssh %s -l %s -f -N -R %s:%s:%s:%s`",
connection.getHostname(), SSH_LOGIN,
hostOnRemote, portOnRemote, hostToForward, portToForward
));
connection.requestRemotePortForwarding(hostOnRemote, portOnRemote, hostToForward, portToForward);
Валидируем
Успешно поймав запросы заглушенным сервисом, остается только проверить их. Самый простой способ — использовать встроенные средства WireMock:
// Обращаясь к логу искусственного сервиса, убеждаемся в наличии нужных сообщений
verify(3, postRequestedFor(urlMatching(".*callback.*"))
.withRequestBody(matching("^status=.*")));
Гораздо более гибкий способ — просто получить список нужных запросов, а потом, достав определенные параметры, применить к ним проверки:
List<LoggedRequest> all = findAll(allRequests());
assertThat("Должны найти хотя бы 1 запрос в логе", all, hasSize(greaterThan(0)));
assertThat("Тело первого запроса должно содержать определенную строку",
all.get(0).getBodyAsString(), containsString("Wow, it's callback!"));
Как это сработало в Яндексе
Всё сказанное выше — серьезный общий подход. Его можно использовать во множестве мест как целиком, так и по частям. Сейчас использование заглушек на интеграционном уровне отлично работает в целом ряде больших проектов для подмены различных функций сервисов. Например, у нас в Яндексе есть сервис загрузки файлов, который записывает информацию о файлах не самостоятельно, а еще через один сервис. Начали загружать файл — отослали запрос. Загрузили, посчитали контрольные суммы — еще один запрос. Проверили файл на вирусы, готовы с файлом работать дальше — еще один. Каждая следующая стадия продолжается в зависимости от ответа на предыдущие, при этом количество соединений между сервисами ограничено.
Как проверить, что запросы действительно уходят и содержат всю информацию о файле в нужном формате? Как проверить, что будет, если запрос был принят, но ответа не последовало? Сперва проверяем позитивный сценарий развития событий — заменяем сервис, пишущий в базу данных искусственным, принимаем и анализируем трафик. (Примеры кода выше — копия того, что происходит в тестах.) Туннель через ssh потребовался для того, чтобы автотесты, не обладая правами суперпользователя, могли привязаться к определенному порту на локальной машине, адрес которой всегда произвольный, а в сервисе загрузки можно было указать точкой отправки запросов свой локальный адрес и порт на постоянной основе.
Успешно проверив позитивный сценарий, нам не составило труда добавить проверок и для негативных. Просто увеличив время задержки ответа в WireMock до величины большей, чем время ожидания в сервисе загрузки файлов, получилось инициировать несколько попыток отправить запрос.
//Глушим сервис записи в БД, установив таймаут на ответ в 61 секунду
//(больше чем ожидание ответа на 1с)
stubFor(any(urlMatching(".*")).willReturn(aResponse()
.withFixedDelay((int) SECONDS.toMillis(61))
.withStatus(HttpStatus.OK_200).withBody("ОК")));
Проверив, что за 120 секунд при ожидании ответа на сервисе в 60 секунд пришло два запроса, мы точно убедились, что сервис загрузки файлов не зависнет в ответственный момент.
waitFor(120, SECONDS);
verify(2, postRequestedFor(urlMatching(".*service/callback.*"))
.withRequestBody(matching("^status_xml.*")));
Значит, разработчики предусмотрели такое развитие событий, и в этом месте при такой ситуации информация о загрузке точно не потеряется. Аналогичным образом на одном из сервисов был найден баг. Заключался он в том, что если сервису не ответили сразу, то соединение оставалось открытым на несколько часов, пока его принудительно не закрывали извне контролирующие службы. Это могло привести к тому, что при неполадках в сети за короткое время мог полностью исчерпаться лимит соединений и остальным клиентам пришлось бы ждать в очереди эти несколько часов. Хорошо, что мы проверили такое раньше!
О чём еще стоит сказать
Есть и ряд ограничений в таком подходе:
- Потребуется ssh доступ на машину.
- Перенаправление портов должно быть на этой машине включено.
- Потребуется останавливать сервисы, если нужно будет занять их порт и заменить заглушкой. А это значит, что нужны права пользователю на остановку сервисов без пароля. Это так же касается портов с номерами до 1024.
- В некоторых организациях нельзя пробрасывать порт без санкций администраторов.
Локальное перенаправление портов
Помимо удаленного, есть также и LOCAL (локальное), с ключом -L
. Оно позволяет зеркально описанному выше, обращаясь к какому-то порту на своей локальной машине, попадать на внутренний порт удаленной машины, скрытый за брэндмауэром. Такой подход может быть альтернативой в тестах заходу по ssh на тестируемый сервер и вызову curl, wget.
Альтернативы
В тестах, помимо WireMock могут быть интересны аналоги: github.com/jadler-mocking/jadler или github.com/robfletcher/betamax.
Автор: Lanwen