- PVSM.RU - https://www.pvsm.ru -

Тестирование в Яндексе. Сам себе web-service over SSH, или как сделать заглушку для целого сервиса

Вы практикующий маг менеджер. Или боевой разработчик. Или профессиональный тестировщик. А может быть, просто человек, которому небезразличны разработка и использование систем, включающих в себя клиент-серверные компоненты. Уверен, вы даже знаете, что порт это не только место, куда приходят корабли, а «ssh» это не только звук, издаваемый змеёй. И вы в курсе, что сервисы, расположенные на одной или нескольких машинах, активно между собой общаются. Чаще всего по протоколу HTTP. И от версии к версии формат этого общения нужно контролировать.

Тестирование в Яндексе. Сам себе web service over SSH, или как сделать заглушку для целого сервиса [1]

Думаю, каждый из вас при очередном релизе задавался вопросами: «Точно ли мы отсылаем верный запрос?» или «Точно ли мы передали все необходимые параметры этому сервису?». Всем должно быть известно и о существовании негативных сценариев развития событий наравне с позитивными. Это знание должно активно порождать вопросы из серии «Что если..?». Что если сервис станет обрабатывать соединения с задержкой в 2 часа? Что если сервис ответит абракадабру вместо данных в формате json?

О таких вещах нередко забывается в процессе разработки. Из-за сложности проверки проблем подобного рода, маловероятности таких ситуаций и еще по тысяче других причин. А ведь странная ошибка или падение приложения в ответственный момент могут навсегда отпугнуть пользователя, и он больше не вернётся к вашему продукту. Мы в Яндексе постоянно держим подобные вопросы в голове и стремимся максимально оптимизировать процесс тестирования, используя полезные идеи. О том, как мы сделали такие проверки легкими, наглядными, автоматическими и пойдет речь в этой статье.

Соль

Есть ряд давно известных способов узнать, как и что передается от сервиса к сервису — от мобильного приложения к серверу, от одной части к другой.
Первый из наиболее популярных и не требующих серьезной подготовки — подключиться специальной программой к одному из источников передачи или приёма данных. Такие программы называются анализаторами трафика [2] или чаще — снифферами.
Второй — подменить искусственной реализацией целиком одну из сторон. В таком подходе есть возможность определить четкий сценарий поведения в определенных случаях и сохранять всю информацию, которая придет этому сервису. Такой подход называется использованием заглушек (mock-объектов). Мы рассмотрим оба.

Использование снифферов

Приходит новый релиз или отладка изменений, затрагивающих межсервисное общение. Мы вооружаемся нужными программами-перехватчиками — WireShark [3]'ом или Tcpdump [4]. Запускаем перехват трафика до нужного узла, наложив фильтры хоста, порта и интерфейса. Делаем «темные дела», инициируя нужное нам общение. Останавливаем перехват. Начинаем его разбирать. Процесс разбора у каждого сервиса свой, но обычно всегда напоминает судорожные поиски в куче текста заветных GET, POST, PUT и т.д. Нашли? Тогда повторяем это из релиза в релиз. Это же теперь проверка на регрессию! Не нашли? Повторяем это с разными комбинациями фильтров до понимания причин.

Из релиза в релиз?

Вручную такое делать можно раз. Или два. Ну, может быть, три. А потом точно надоест. А на пятый релиз это общение возьмет и сломается. Особенно сложно это заметить косвенно, когда общение представляет из себя вызов колбэка с каким-нибудь уведомлением. Повторяющиеся из релиза в релиз механические действия, отнимающие много времени и сил, стоит автоматизировать. Как это делать в JAVA? Уверен, на других языках это можно сделать похожим образом, но конкретно связка JUnit4 + Maven для автоматизации тестирования в Яндексе прекрасно работает и хорошо себя зарекомендовала.

Предположим, что сервис мы тестируем интеграционно, а значит, скорее всего это похоже на боевой режим, когда он поднят на отдельной машине, а мы подключены к ней по SSH. Берем библиотечку для работы по SSH [5], подключаемся к серверу [6], запускаем tcpdump и ловим все, что можем, в файлик (все в точности как руками). После теста принудительно завершаем процесс и ищем в файлике то, что нужно, используя grep, awk, sed и т.д. Затем полученное обрабатываем в тесте. Делали так? Нет? И не нужно!

Почему не нужно так делать?

Хочу заметить, что «не нужно» не значит «не можно».

Делать так можно. Просто есть способы проще, потому что:

  • Разбор на составляющие больших строк — это всегда много специфического кода, который сложно поддерживать.
  • Разбор HTTP сообщений уже давно сделан в сотне библиотек. Зачем делать сто первую?

Пробовали именно такой способ и мы в Яндексе. Сперва это казалось удобным — от нас требовалось только запустить и остановить tcpdump по ssh, а потом найти нужную подстроку в его выдаче. Первые проблемы с поддержкой начались почти сразу: порядок следования query-параметров оказался случайным в искомых запросах. Пришлось разбить эталонную строку на несколько и проверять вхождение каждой. Удручали и сообщения об ошибках в случае, если запроса не находилось, — тонны текста не давали адекватного способа себя структурировать. Головной боли добавили асинхронные запросы, которые могли появиться через несколько минут после активных действий. Приходилось строить головокружительные конструкции по ожиданию нужной подстроки в выдаче с определенной задержкой. Код тестов иногда становился сложнее кода, который мы проверяли. Тогда мы и стали искать другой способ тестировать эту часть.

Использование заглушек вместо web-сервисов

Так как речь идет о тестировании, то, скорее всего, у нас есть все возможности не просто поставить сеточку в виде сниффера, но и подменить один из сервисов целиком. При таком подходе до искусственного сервиса дойдут «чистые» запросы, а у нас будет возможность управлять поведением конечного пункта для сообщений. Для таких целей есть замечательная библиотека WireMock [7]. Её код можно посмотреть на GitHub-странице [8] проекта. Суть в том, что поднимается web-сервис с хорошим REST-api, который можно настроить почти любым образом. Это JAVA-библиотека, но у нее есть возможность запуска как самостоятельного приложения, было бы доступно jre. А дальше простая настройка с подробной документацией.

Тут и произвольные коды ответа, и произвольное содержимое, и прозрачное перенаправление запроса в реальные сервисы с возможностью сохранить ответы и отсылать их затем самостоятельно. Особо стоит отметить возможность воссоздать негативное поведение: таймауты, обрывы связи, невалидные ответы. Красота! Библиотека при этом может работать и как WAR, который можно загрузить в Jetty, Tomcat и т.д. И, самое главное, эту библиотеку можно использовать прямо в тестах как JUnit Rule! Она сама позаботится о разборе запроса, разделив тело, адрес, параметры и заголовки. Нам останется только в нужный момент достать список всех пришедших и удовлетворяющих критериям.

Автоматизируем проверки, используя заглушку

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

Стоит заметить, что каждый из этапов так же возможно проделать вручную без особых затруднений.

Схема

Что проверяем? {сообщение} в схеме:
тестируемый_сервис -> {сообщение} -> сервис_заглушка.

Более точно, схема будет выглядеть так:
тестируемый_сервис -> сервис_заглушка :(его лог): {сообщение}.

Таким образом, нам нужно сделать несколько вещей:

  • Поднять сервис-заглушку и заставить его принимать определенные сообщения, отвечая ОК (или неОК — зависит от сценария). Этим займется WireMock.
  • Обеспечить доставку сообщений до сервиса-заглушки (в схеме это ->). Об этом этапе поговорим отдельно.
  • Провалидировать то, что пришло. Тут два варианта — используя средства WireMock для валидации, либо получив от нее список запросов, применяя к ним матчеры [9].

Поднимаем искусственный web-сервис

Как поднять сервис вручную, подробно описано на сайте wiremock в секции Running standalone [10]. Как использовать в 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 [11]

Так как мы занимаемся автоматизацией этого процесса, рассмотрим подробно, как сделать использование этого механизма в тестах удобным. Начнем с самого верхнего уровня — интерфейса 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 [12].)

Открываем пример [13], использующий эту библиотеку для поднятия туннеля через 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() для авторизации [6] и поднятия туннеля, а 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 потребовался для того, чтобы автотесты, не обладая правами суперпользователя, могли привязаться к определенному порту на локальной машине, адрес которой всегда произвольный, а в сервисе загрузки можно было указать точкой отправки запросов свой локальный адрес и порт на постоянной основе.

Успешно проверив позитивный сценарий, нам не составило труда добавить проверок и для негативных [14]. Просто увеличив время задержки ответа в 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 [15] или github.com/robfletcher/betamax [16].

Автор: Lanwen

Источник [17]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/ssh/64131

Ссылки в тексте:

[1] Image: http://habrahabr.ru/company/yandex/blog/228691/

[2] анализаторами трафика: http://en.wikipedia.org/wiki/Packet_analyzer

[3] WireShark: http://www.wireshark.org/

[4] Tcpdump: http://en.wikipedia.org/wiki/Tcpdump

[5] библиотечку для работы по SSH: https://code.google.com/p/ganymed-ssh-2/

[6] подключаемся к серверу: https://code.google.com/p/ganymed-ssh-2/source/browse/trunk/examples/PublicKeyAuthentication.java

[7] WireMock: http://wiremock.org/

[8] GitHub-странице: https://github.com/tomakehurst/wiremock

[9] матчеры: http://habrahabr.ru/company/yandex/blog/184634/

[10] Running standalone: http://wiremock.org/getting-started.html

[11] www.debianadmin.com: http://www.debianadmin.com

[12] Maven Central: http://mvnrepository.com/artifact/ch.ethz.ganymed/ganymed-ssh2

[13] пример: https://code.google.com/p/ganymed-ssh-2/source/browse/trunk/examples/PortForwarding.java?r=2

[14] для негативных: http://wiremock.org/simulating-faults.html

[15] github.com/jadler-mocking/jadler: https://github.com/jadler-mocking/jadler

[16] github.com/robfletcher/betamax: https://github.com/robfletcher/betamax

[17] Источник: http://habrahabr.ru/post/228691/