На Хабре совсем нет информации про TestContainers. На момент написания этой статьи, в поисковой выдаче есть анонсы наших же конференций, и всё. Между тем, в проекте на Гитхабе у них уже более 700 коммитов, 54 контрибутора и 5 лет истории. Похоже, все эти пять лет проект тщательно скрывался спецслужбами и НЛО. Настало время выйти из тени на свет.
Чукча читатель, а не писатель. Поэтому, вместо написания своего текста, я попросил разрешения на перевод соответствующей статьи из блога Rebel Labs.
Итак, здесь мы поделимся парой слов о наимоднейшей Java-библиотеке для интеграционного тестирования — TestContainers. Кроме этого, будет немного о том, почему интеграционное тестирование настолько важно для ZeroTurnaround, и их требования к интеграционным тестам. И конечно, будет полнофункциональный пример интеграционного теста для Java-агента. Если кто-то никогда в глаза не видел код Java-агента, то сейчас самое время. Добро пожаловать под кат!
Интеграционное тестирования в компании ZeroTurnaround
Продукты компании ZeroTurnaround интегрируются с большой частью экосистемы Java. В том числе, JRebel и XRebel основаны на технологии Java-агентов и интегрируются с Java-приложениями, фреймворками, серверами приложений, и так далее.
С помощью Java-агентаможно инструментировать Java-код так, чтобы добавить нужную тебе дополнительную функциональность. Чтобы протестировать, как приложение ведет себя после применения патча, необходимо запустить его через пред-настроенный Java-агент. Как только приложение запустилось и заработало, для воспроизведения желаемого поведения можно послать ему HTTP-запрос.
Чтобы запускать такие тесты на больших масштабах, требуется иметь автоматизированную систему, которая сможет запускать и останавливать среду исполнения. Включая сервер приложений или любые другие внешние зависимости, от которых зависит приложение. Должна иметься возможность запускать примерно одни и те же вещи и в среде непрерывной интеграции, и на компьютере разработчика.
В результате получается куча тестов, они работают совсем не быстро, и поэтому мы точно захотим запускать их параллельно. Это автоматом означает, что тесты должны быть изолированы, чтобы не случилось конфликтов по ресурсам. Например, если мы запускаем на одном хосте несколько экземпляров Tomcat, хотелось бы избежать конфликтов использования портов.
В таком интеграционном тестировании нам помогает небольшая красивая библиотека TestContainers. Она не просто подошла по озвученным выше требованиям — после её внедрения мы получили внушительный рост производительности.
TestContainers
Официальная документация TestContainers говорит следующее:
“TestContainers — это Java-библиотека, которая поддерживает тесты JUnit и предоставляет легкие, временные экземпляры основных баз данных, веб-браузеров для Selenium, или чего угодно еще, что можно запускать в Docker-контейнере.”
TestContainers предоставляет API для автоматизации настройки окружения. Оно запускает нужные Docker-контейнеры ровно на время работы наших тестов и гасит их сразу же, как тесты завершатся. Дальше мы посмотрим на несколько демок, основанных на официальных примерах, лежащих в их репозитории на GitHub.
GenericContainer
При использовании TestContainers, очень часто используется классGenericContainer
:
public class RedisBackedCacheTest {
@Rule
public GenericContainer redis = new GenericContainer("redis:3.0.6")
.withExposedPorts(6379);
Его конструктор принимает в качестве параметра строку, в которой указывается Docker-образ, который мы в дальнейшем будем использовать. В ходе запуска, TestContainers автоматически загружает соответствующий образ (если он не был загружен ранее).
Важное замечание: в методе withExposedPorts(6379)
, 6379 — это порт, на котором будет висеть контейнер. Далее мы сможем найти соответствующий ему связанный порт с помощью вызова на экземпляре контейнера метода getMappedPort(6379)
. Объединяя это с getContainerIpAddress()
, можем получить полный URL сервиса, запущенного в контейнере:
String redisUrl = redis.getContainerIpAddres() + “:” + redis.getMappedPort(6379);
Можно заметить, что поле из этого примера отмечено аннотацией @Rule
. Аннотация @Rule из JUnit определяет, что мы будем получать новый экземпляр GenericContainer
в каждом тестовом методе этого класса. Если же мы захотели бы переиспользовать экземпляр контейнера, для этого существует аннотация @ClassRule
.
Контейнеры под задачу
Наследники GenericContainer
— это специализированные под задачу контейнеры. Для тестирования уровня доступа к данным, из коробки имеются контейнеризованные образы MySQL, PostgreSQL и Oracle.
PostgreSQLContainer postgreSQLContainer = new PostgreSQLContainer("postgres:9.6.2")
.withUsername(POSTGRES_USERNAME)
.withPassword(POSTGRES_PASSWORD);
Всего лишь этой одной строчкой можно получить экземпляр контейнера, который останется с нами на протяжении теста. На машину, где будут запускаться тесты, не нужно вручную устанавливать базу данных. Это дает особенно большой выигрыш, если хочется провести тест на нескольких версиях одной и той же базы данных.
Свои собственные контейнеры
Наследуясь от GenericContainer
, возможно делать новые типы контейнеров. Это довольно удобно, если хочется инкапсулировать соответствующие сервисы и логику. Например, можно использовать MockServer чтобы замокать зависимости распределенной системы, в которой приложения общаются друг с другом по HTTP:
public class MockServerContainer extends BaseContainer<MockServerContainer> {
MockServerClient client;
public MockServerContainer() {
super("jamesdbloom/mockserver:latest");
withCommand("/opt/mockserver/run_mockserver.sh -logLevel INFO -serverPort 80");
addExposedPorts(80);
}
@Override
protected void containerIsStarted(InspectContainerResponse containerInfo) {
client = new MockServerClient(getContainerIpAddress(), getMappedPort(80));
}
}
В этом примере, сразу же после инициализации контейнера, используется колбэк containerIsStarted(...)
, который инициализирует экземпляр MockServerClient
. Таким образом, мы спрятали все детали реализации, специфичные для контейнера, внутри своего собственного типа контейнера. Благодаря этому мы получили более чистый код клиента и более аккуратный API для тестирования.
Дальше мы увидим, что вручную определенные контейнеры помогают в структуризации окружения для тестирования Java-агентов.
Тестирование Java-агента с помощью TestContainers
Для демонстрации идеи, воспользуемся примером, любезно предоставленным Сергеем @bsideup Егоровым, ко-мантейнером проекта TestContainers.
Демонстрационное приложение
Давайте начнем с тестового приложения. Нам понадобится веб-приложения, отвечающее на HTTP GET запросы. Жирных фреймворков не требуется — поэтому, почему бы не взять SparkJava? Чтобы добавить веселья, сразу начнем кодить на Groovy! Вот это приложение мы будем тестировать:
//app.groovy
@Grab("com.sparkjava:spark-core:2.1")
import static spark.Spark.*
get("/hello/") { req, res -> "Hello!" }
Это простой скрипт на Groovy, использующий Grape для загрузки зависимости на SparkJava, и определяющий один HTTP-эндпоинт, отвечающий сообщением “Hello!”.
Java-агент
Агент, который мы собрались проверять, патчит сервер Jetty и добавляет ему дополнительный заголовок в HTTP-ответ.
public class Agent {
public static void premain(String args, Instrumentation instrumentation) {
instrumentation.addTransformer(
(loader, className, clazz, domain, buffer) -> {
if ("spark/webserver/JettyHandler".equals(className)) {
try {
ClassPool cp = new ClassPool();
cp.appendClassPath(new LoaderClassPath(loader));
CtClass ct = cp.makeClass(new ByteArrayInputStream(buffer));
CtMethod ctMethod = ct.getDeclaredMethod("doHandle");
ctMethod.insertBefore("{ $4.setHeader("X-My-Super-Header", "42"); }");
return ct.toBytecode();
} catch (Throwable e) {
e.printStackTrace();
}
}
return buffer;
});
}
}
В этом примере, Javassist используется для патчинга метода JettyHandler.doHandle
, в который добавляется дополнительная команда, устанавливающая заголовок X-My-Super-Header
.
Конечно, чтобы стать Java-агентом, нужно правильно собраться в пакет и добавить соответствующие аттрибуты в файл MANIFEST.MF
. Всё это за нас делает сборочный скрипт, чтобы не загромождать статью он выложен на GitHub, смотрите содержимое файла build.grade.
Собственно, тест!
Тест будет довольно простым: нужно сделать запрос к нашему приложению и проверить ответ на наличие особого заголовка, который Java-агент, теоретически, должен бы туда добавить. Если заголовок найден, и значение заголовка совпадает с ожидаемым значением — тест успешно пройден. Взглянем на код:
@Test
public void testIt() throws Exception {
// Using Feign client to execute the request
Response response = app.getClient().getHello();
assertThat(response.headers().get("X-My-Super-Header"))
.isNotNull()
.hasSize(1)
.containsExactly("42");
}
Можно запустить его прямо из IDE, или из командной строки, или даже в среде непрерывной интеграции. TestContainers помогают нам запустить приложение так, что агент оказывается в изолированном окружении, в Docker-контейнере.
Чтобы запустить приложение, нужен Docker-образ с поддержкой Groovy. Чтобы сделать себе удобно, мы завели Docker-образ zeroturnaround/groovy, он лежит на Docker Hub. Вот как его можно использовать, наследуясь от GenericContainer
:
public class GroovyTestApp<SELF extends GroovyTestApp<SELF>>
extends GenericContainer<SELF> {
public GroovyTestApp(String script) {
super("zeroturnaround/groovy:2.4.5");
withClasspathResourceMapping("agent.jar", "/agent.jar", BindMode.READ_ONLY);
withClasspathResourceMapping(script, "/app/app.groovy", BindMode.READ_ONLY);
withEnv("JAVA_OPTS", "-javaagent:/agent.jar");
withCommand("/opt/groovy/bin/groovy /app/app.groovy");
withLogConsumer(new Slf4jLogConsumer(LoggerFactory.getLogger(script)));
}
public String getURL() {
return "http://" + getContainerIpAddress() + ":"
+ getMappedPort(getExposedPorts().get(0));
}
}
Посмотрите, как API предоставляет нам методы для получения IP-адреса контейнера, а также связанного порта (который, в реальности рандомизован). В смысле, порт будет разный каждый раз, когда запускается тест. Поэтому, если запустить все тесты одновременно, не будет конфликтов между портами и тесты не посыпятся.
Теперь, у нас имеется специальный класс GroovyTestApp
для простого запуска скриптов на Groovy, в нашем случае — для тестирования демонстрационного приложения:
GroovyTestApp app = new GroovyTestApp(“app.groovy”)
.withExposedPorts(4567); //the default port for SparkJava
.setWaitStrategy(new HttpWaitStrategy().forPath("/hello/"));
Запускаем тесты, смотрим на выхлоп:
$ ./gradlew test
16:42:51.462 [I] d.DockerClientProviderStrategy - Accessing unix domain socket via TCP proxy (/var/run/docker.sock via localhost:50652)
… … …
16:43:01.497 [I] app.groovy - STDERR: [Thread-1] INFO spark.webserver.SparkServer - == Spark has ignited ...
16:43:01.498 [I] app.groovy - STDERR: [Thread-1] INFO spark.webserver.SparkServer - >> Listening on 0.0.0.0:4567
16:43:01.511 [I] app.groovy - STDERR: [Thread-1] INFO org.eclipse.jetty.server.Server - jetty-9.0.2.v20130417
16:43:01.825 [I] app.groovy - STDERR: [Thread-1] INFO org.eclipse.jetty.server.ServerConnector - Started ServerConnector@72f63426{HTTP/1.1}{0.0.0.0:4567}
16:43:02.199 [I] ?.4.5] - Container zeroturnaround/groovy:2.4.5 started
AgentTest > testIt STANDARD_OUT
Got response:
HTTP/1.1 200 OK
content-length: 6
content-type: text/html; charset=UTF-8
server: Jetty(9.0.2.v20130417)
x-my-super-header: 42
Hello!
BUILD SUCCESSFUL
Total time: 36.014 secs
Тест этот не очень быстр. Какое-то время уходит на скачивание Grapes — но только самый первый раз. Тем не менее, это полноценный интеграционный тест, который запускает Docker-контейнер, приложение с использованием HTTP-стека, и делает HTTP-запросы. Кроме этого, приложение запускается в изоляции, и сделать это действительно просто. И всё это — благодаря TestContainers!
Заключение
“Работает на моем компьютере” — популярное оправдание, но оно больше не должно быть оправданием вообще. По мере того, как технология контейнеризации становится доступна всё большему количеству разработчиков, появляется возможность делать все более детерминированные тесты.
TestContainers уменьшают количество безумия в интеграционных тестах приложений на Java. Эту библиотеку очень просто интегрировать в существующие тесты. Больше не нужно вручную управлять внешними зависимостями, и это — огромная победа, особенно в среде непрерывной интеграции.
Если вам понравилось то, что вы сейчас прочитали, очень советуем посмотреть на запись с конференции GeekOut Java, где Richard North, изначальный автор проекта, дает вводную информацию о TestContainers, включая планы по развитию. Или хотя бы, посмотреть на слайды этой презентации.
Пара слов от переводчика.
Во-первых, если вы нашли какие-то неточности, ошибки и опечатки — нужно пройти в личку к olegchir и описать всё как есть. Я действительно читаю сообщения и исправляю баги.
Если вы интересуетесь Java, новыми технологиями и библиотеками, то вам стоит посетить наши Java-конференции. Ближайшие — JPoint и JBreak. Кстати, сотрудники ZeroTurnaround часто выступают на наших конференциях как спикеры и работают как члены программного коммитета.
Если же вам интересней тестирование, то мы проводим конференцию Heisenbug 2017 Moscow, которая состоится буквально через полторы недели. Тема тестирования с использованием Docker там так или иначе присутствует во многих докладах.
Будете ли вы пользоваться TestContainers? Понравилась идея, есть сомнения? Пишите в комментариях!
Автор: olegchir