Pull to refresh
159.67
JUG Ru Group
Конференции для Senior-разработчиков

Интеграционные тесты для Java с помощью TestContainers. Меньше безумия, больше порядка, и всё это автоматически

Reading time 9 min
Views 51K
Original author: Антон Архипов

На Хабре совсем нет информации про TestContainers. На момент написания этой статьи, в поисковой выдаче есть анонсы наших же конференций, и всё. Между тем, в проекте на GitHub у них уже более 700 коммитов, 54 контрибьютора и 5 лет истории. Похоже, все эти пять лет проект тщательно скрывался спецслужбами и НЛО. Настало время выйти из тени на свет.



Чукча — читатель, а не писатель. Поэтому, вместо написания своего текста, я попросил разрешения на перевод соответствующей статьи из блога RebelLabs.


Итак, здесь мы поделимся парой слов о наимоднейшей 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? Понравилась идея, есть сомнения? Пишите в комментариях!

Tags:
Hubs:
+23
Comments 18
Comments Comments 18

Articles

Information

Website
jugru.org
Registered
Founded
Employees
51–100 employees
Location
Россия
Representative
Алексей Федоров