На Хабре совсем нет информации про 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? Понравилась идея, есть сомнения? Пишите в комментариях!

Комментарии (16)


  1. bsideup
    27.11.2017 16:20
    +2

    olegchir тот момент когда один из главных разработчиков родом из России и в прошлом активно писал на хабр, но не додумался написать про TestContainers :D


    Спасибо за перевод!
    Надо бы мне мою https://medium.com/@bsideup/testing-your-docker-containers-with-testcontainers-and-groovy-3b9ef97ad1c2 тоже перевести.


    P.S. ходят слухи что на Heisenbug 2017 Moscow так же будет доклад про TestContainers ;)


    1. bsideup
      27.11.2017 16:27
      +2

      P.S.S. только у нас не 5 лет истории, а чуть меньше, и первый коммит датируется 12м Апреля ;)
      https://github.com/testcontainers/testcontainers-java/commit/bf37c505e7a6b5ce143fd0311209c5514a52b4f5


      1. olegchir Автор
        27.11.2017 16:35

        Кстати, видел этот коммит, по нему считал! Но догадался только вычесть годы :)

        image


    1. olegchir Автор
      27.11.2017 16:32
      +1

      «Я пишу не только на Медиум и англоязычные блоги;
      Ибо кто пишет только в англоязычные блоги — забыл лицо своего отца.
      Я же пишу на Хабр.»

      Ждем статьи, стрелок!


    1. pavelponomaryov
      27.11.2017 16:35

      P.S. ходят слухи что на Heisenbug 2017 Moscow так же будет доклад про TestContainers ;)

      говорят, что нет )))


      1. bsideup
        27.11.2017 18:16

        ты просто не с теми говоришь ;)


        1. pavelponomaryov
          28.11.2017 12:42

          :D


  1. olegchir Автор
    27.11.2017 16:31

    del


  1. ludenus
    29.11.2017 00:23

    > Понравилась идея, есть сомнения?
    да
    > Будете ли вы пользоваться TestContainers?
    нет

    Из предыдущего и текущего опыта интеграционного и системного тестирование сервисов, для себя сделал вывод, что оркестрация докеров изнутри кода — идея ущербная по двум причинам.

    1. Запускается только локально или на одной DOCKER_HOST ноде.
    Идеальный сферический микросервис без зависимостей — будет ок.
    Но реальные явишные сервисы, которые пришлось тестировать, жрут от полгига до 16 гиг памяти, имеют кучу зависимостей и на одну ноду просто не влазят. Тоже самое касается параллельного выполнения тестов. При попытке запустить дюжину selenium нод c chrome внутри — придется отдать гигов шесть, плюс собсвенно selenium grid — еще полтора — два гига, ну и собственно тестовым процессам памяти тоже бывает нужно по наблюдениям от половины до полутора гиг. Можно спорить о конкретных значениях и пытаться оптимизировать Xmx но на порядок величины — это не сильно влияет. Заурядная рабочая машина с 16 гигами памяти — не справится запустить этот тестовый сетап.

    2. Второй контраргумент. Поптыка оркестрировать докеры из явишного кода — имхо, изобретание девопс велосипеда. Есть инструменты, которые делают лучше и больше.

    Например
    А. Вот этот плагин (были про него статьи на хабре даже) github.com/fabric8io/docker-maven-plugin — Тоже ограничен локальным хостом, но умеет не только запускать докеры для тестов и гасить после, но и собирать их, тегать, пушить в docker-registry на стадии mvn deploy, что удобно. Не подойдет ненавистникам XML конфигураций и мавена, однако проверен временем и работает удовлетворительно.

    Б. kubernetes. Запросто жонглирует тяжелыми тестовыми сетапами, которые не поднимутся на локальной рабочей машине у разработчика, масштабирует компоненты по щелчку пальцев, про удобство конфигурации — и говорить нечего, это на два порядка лучше, чем конфигурить докеры в ява коде, к тому же любители YAML/JSON конфигов будут счастливы. Про тонны плюшек k8, которые идут впридачу к этому — тоже промолчу.


    1. olegchir Автор
      29.11.2017 14:59

      Так подожди, должен же разработчик на своем компьютере запустить сервис, с которым ты интегрируешься прямо сейчас и погонять немного тестов?

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

      Я как раз почти всю дорогу ворочил жирносервисами на джаве, и понимаю какая эта боль. Да, тридцать два гига рамы на локальном компьютере, да — i7, SSD, и по несколько минут на запуск. Но связываться с «полномасштабной» инфраструктурой — это еще больнее!


      1. ludenus
        29.11.2017 20:47

        должен же разработчик на своем компьютере запустить сервис, с которым ты интегрируешься прямо сейчас и погонять немного тестов?

        Конечно, он может. Те разработчики, которые хотят «немного погонять» в нашем случае предпочитают запускать spring boot у себя без докера на голом железе (им так привычнее и быстрее). Те разработчики, которые хотят локально выполнить интеграционный набор тестов а-ля Jenkins полностью — просто запускают mvn verify (который билдит и запускает все докеры с зависимостями локально) При этом в коде тестов — только логика тестов, вся конфигурация докеров — в настройках мавен докер плагина. И я настаиваю, что держать конфигурацию докеров снаружи тестового кода — лучше, чем засовывать её внутрь.

        Да, тридцать два гига рамы на локальном компьютере, да — i7, SSD, и по несколько минут на запуск. Но связываться с «полномасштабной» инфраструктурой — это еще больнее!


        «TestContainers — это Java-библиотека, которая поддерживает тесты JUnit и предоставляет легкие, временные экземпляры основных баз данных, веб-браузеров для Selenium или чего угодно еще, что можно запускать в Docker-контейнере».


        Этот дисклеймер слегка сбивает с толку.
        Выходит, что не «чего угодно ещё», а только того, что влезет на локальный компьютер.
        В случае с selenium тестами — как правило речь идет о системном тестировании, где не обойдешься изолированным контекстом одного сервиса с базой данных, и где как раз и нужно поднимать эту самую полномасштабную инфраструктуру. Ну так вот kubernetes в этом случае спасает даже тех, у кого рабочий ноут с <32 Гб памяти. Опять же запустить kubernetes apply — как правило проще и быстрее, чем апгрейдить или покупать новые ноутбуки.


        1. Borz
          30.11.2017 01:49

          забыли добавить, что ещё есть minikube и compose


        1. olegchir Автор
          30.11.2017 12:49

          Понял. Может быть, это вопрос разных вкусов?

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

          Еще момент. Я всем сердцем люблю статику, статическую типизацию, code-time и compile-time проверки, комплияторы и IDE умнее разработчика. Поэтому и языки — Java, Scala, C++. Если UI — то TypeScript, ни в коем случае не JS. И так далее. Мечтаю перейти на Haskell, чтобы использовать его как пруфер чистоты фукнционального кода.

          Вполне логично, что идея configuration as code, где код — это непосредственно код на Java, очень греет сердце. XML тоже хорош, пока это четкий и машинно-понимаемый XML. А вот Докер и командная страка — в этой парадигме, это динамическое непойми что. Сорри за жесткость данного утверждения.

          Вторая часть истории — это функциональная чистота и формальная верификация. Когда ты запускаешь что-то вручную, например SpringBoot вручную из IDE, то это опять же непонятно насколько грязное решение. Тупо в Идее могут кэши не почиститься, и соберется ересь. Запуск в стерильном докере решает этот вопрос.

          И заодним, можно не настраивать свой компьютер на запуск приложения вне IDE — не замусоривать его ненужными зависимостями типа десяти разных версий Ruby одновременно. Например, интегрируемся мы с GitLab — знаете сколько мусора притаскивает GitLab на твой компьютер? Потом с мыслом не отмыться, никогда. А как добиться репродуцируемости конфигурации твоего компьютера? А как менять их между разными версиями? («надо откатиться до предыдущей версии инфраструктуры, потому что в этой что-то глючит, но не понять — что»)

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

          У кого-то другая система представлений.


          1. ludenus
            30.11.2017 13:06

            Человек может не знать ни о каких докерах, кубернетисах, итп.

            У кого-то другая система представлений.


            Может кто-то просто не хочет знать ничего, кроме явы?

            погрести Докер под плотным слоем абстракций, чтобы работа с ним осуществлялась аналогично класслоадерам в джаве или бинам в спринге, через прозрачный доступ по Java API и только через него


            Вот это как раз оно. Грести докер под плотным слоем абстракций. Натянуть сову на глобус, чтобы было все через ява АПИ.


            1. olegchir Автор
              30.11.2017 20:50

              Представим некого сферического чудика, который вообще ничего кроме Java знать не хочет. Вообще. И что? Он теперь не человек?


              1. ludenus
                30.11.2017 22:11

                желаю успехов в оркестрации докеров с помощью Java
                жду статей про оркестрацию с помощью Scala и Haskel