Код должен тестироваться на той СУБД, с которой он будет работать. Testcontainers — это такая библиотека, которая позволяет использовать в юнит тестах практически любую СУБД с той же лёгкостью, что embedded базы данных типа HSQLDB или H2. Был бы только Docker образ



Именно сборке удобного для использования с Testcontainers докер образа посвящена эта статья. У меня при попытке его сделать возникли проблемы, и тут я делюсь своим решением.
Собирать буду образ для Oracle 11, потому он небольшого размера и мне версии 11 хватает. С другими версиями подход примерно тот же.


Для того, чтобы было понятно, как использовать образ, тут также будет код на Java, который демонстрирует применение образа для тестирования Spring Boot приложений. Способ подключения к testcontainers я привёл, наверное, не самый лучший. Но во-первых он демонстрирует как использовать настройки, заданные при создании образа. Во-вторых он простой. И в-третьих он почти не завязан на Spring, его можно воткнуть даже в джава код, в котором нет ничего, кроме public static void main.


Предполагается, что читатель обладает поверхностным знакомством с Docker и Testcontaners, а также хорошо знает Java. Для сборки нужно использовать linux, если собираете под Windows, нужно будет применить msys2 или что-то в этом духе.


Демонстрационный код залит на гитхаб вот сюда https://github.com/poxu/testcontainers-spring-demo Поправленные скрипты для сборки образа можно посмотреть в моём форке Оракловских инструкций https://github.com/poxu/docker-images/tree/master/OracleDatabase/SingleInstance


Сборка Docker образа


Oracle не предоставляет образов для docker, но выложила на гитхаб подробную инструкцию, как их собрать.


К сожалению, использовать эти образы в testcontainers as-is невозможно, потому что контейнер, который запускается из этого образа, стартует от двух до 20 минут.


Для использования в юнит-тестах это неприемлемо, поэтому нужно вносить в скрипты свои правки, но вначале лучше попробовать собрать контейнер по той инструкции, которую предоставляет Оракл. Я тут сделаю краткий пересказ, более полная инструкция находится по этой ссылке https://github.com/oracle/docker-images/tree/master/OracleDatabase/SingleInstance


Сборка образа согласно инструкции от Оракла


Во-первых, нужно клонировать репозиторий с инструкциями как собирать образ.


git clone https://github.com/oracle/docker-images.git

Потом добыть rpm пакет для уставноки экспресс версии Оракла 11.2.0.2 Это не очень сложно, нужно просто зарегистрироваться на сайте оракла, перейти на страницу скачивания СУБД Оракл, там выбрать версию 11.2.0.2 XE и скачать запакованный rpm файл oracle-xe-11.2.0-1.0.x86~64~.rpm.zip.


Положить файл в скачанный гит репозиторий в директорию docker-images/OracleDatabase/SingleInstance/dockerfiles/11.2.0.2/


Дальше перейти в директорию docker-images/OracleDatabase/SingleInstance/dockerfiles и выполнить команду


./buildDockerImage.sh -v 11.2.0.2 -x 

Докер соберёт образ, под названием oracle/database:11.2.0.2-xe на основе которого надо сделать контейнер вот такой командой


docker run --rm --name vanilla_oracle_test_habr -p 1234:1521 -p 5678:5500 -e ORACLE_PWD=123 --shm-size="1g" oracle/database:11.2.0.2-xe    

Контейнер стартует несколько минут, потому что после старта он создаёт новую БД, а это процесс не быстрый.


Через несколько минут в консоли появится баннер


#########################
DATABASE IS READY TO USE!
#########################

После этого можно подключиться к БД, используя логин SYSTEM, пароль — 123, адрес для подключения — localhost и SID — XE.


Если всё сработало, то можно переходить к процессу создания образа под testcontainers. Если нет, то лучше сначала пройти по мануалу от оракла и разобраться что не так.


Как мы уже выяснили, контейнер долго стартует из-за того, что после старта создаётся БД. В каких-то случая это наверное может быть удобно, но сейчас от этого выходит сплошной вред. Нужно сделать так, чтобы контейнер был готов к работе сразу после запуска.


Доработка образа вручную


Один из способов получить образ с готовой БД — это подождать пока контейнер стартует и создание БД будет завершено, а потом сохранить контейнер в новый образ.


Важно только не стартовать контейнер с аргументом --rm, а то докер прибьёт его сразу после остановки.


docker commit --message "container for unit tests" <container id> my/oracle-11.2.0.2-for-unit-tests    

Это создаст из контейнера новый образ, который будет запускаться уже не несколько минут, а 20 — 30 секунд.


Модификация процесса сборки образа с целью получить готовый образ сразу после сборки


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


В последней строке докерфайла указана команда, которая выполняется после старта


CMD exec $ORACLE_BASE/$RUN_FILE

\$ORACLE~BASE~/\$RUN~FILE~ указывает на файл docker-images/OracleDatabase/SingleInstance/dockerfiles/11.2.0.2/runOracle.sh


Если этот контейнер остановить и потом запустить снова, то в результате в первый раз скрипт сделает базу данных, а во второй раз просто её запустит. Можно предположить, что если выполнить скрипт на этапе сборки образа, то образ соберётся с уже созданной БД.


Но на пути реализации этого смелого плана возникает одна сложность.


Скрипт работает вечно, то есть пока в контейнер не придёт сигнал, что работу необходимо завершить. Это решается достаточно просто. Последняя строка файла runOracle.sh содержит команду wait. Мы знаем, что на этапе сборки ждать ничего не надо, надо закончить работу и поэтому поставим в скрипте условный оператор.


Он будет проверять, не передан ли файлу аргумент --running-while-building и если этот аргумент передан, то не ждать никаких сигналов, а просто прерывать работу. То есть сделать вот так:


if [ "$1" != "--running-while-building" ]
then
    wait $childPID
fi

Ну а в докерфайл добавим ещё один вызов скрипта, только уже на этапе сборки. Получится вот так.


RUN $ORACLE_BASE/$RUN_FILE --running-while-building
CMD exec $ORACLE_BASE/$RUN_FILE

Изменения, нужные для использования в тестировании


Устранить использование томов


Нужно найти строку


VOLUME ["\$ORACLE~BASE~/oradata"]

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


Удалить ненужные файлы


Нужно добавить строки


rm -rf $ORACLE_HOME/demo && rm -rf $ORACLE_HOME/jdbc && rm -rf $ORACLE_HOME/jlib && rm -rf $ORACLE_HOME/md && rm -rf $ORACLE_HOME/nls/demo && rm -rf $ORACLE_HOME/odbc && rm -rf $ORACLE_HOME/rdbms/jlib && rm -rf $ORACLE_HOME/rdbms/public && rm -rf $ORACLE_HOME/rdbms/demo && rm -rf $ORACLE_HOME/bin/rman && \

Сразу перед строкой


chmod ug+x $ORACLE_BASE/*.sh

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


Убрать разбиение образа на слои


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


Для проброса аргументов в докер в docker-images/OracleDatabase/SingleInstance/dockerfiles/buildDockerImage.sh предусмотрен аргумент -o. То есть, чтобы пробросить аргумент --squash в докер нужно вызывать buildDockerImage.sh вот так


./buildDockerImage.sh -o '--squash'

Поменять название образа


К текущему моменту образ существенно отличается от того, что предлагает сделать Оракл, поэтому его надо переименовать. Для этого уже надо править сам файл buildDockerImage.sh Название образа скрипт берёт из переменной IMAGE~NAME~, значение которой задаётся прямо в файле


Вот тут


# Oracle Database Image Name
IMAGE_NAME="oracle/database:$VERSION-$EDITION"

Я поменял на


# Oracle Database Image Name
IMAGE_NAME="my/oracle-for-habr:$VERSION-$EDITION"

Задать пароль к БД


Этот пароль задаётся в переменной окружения ORACLE~PWD~ на этапе первого запуска контейнера. Но у нас настройка базы данных происходит во время сборки образа, поэтому переменную нужно определить ещё на этом этапе. Если возможность задавать пароль при каждой сборке через командную строку не нужна, то можно просто вписать в докерфайл:


ENV ORACLE_PWD=123

Если для чего-то нужно иметь возможность определять пароль заново при каждой сборке, то для проброса аргумента в докер опять можно использовать -o


./buildDockerImage.sh -v 11.2.0.2 -x -o '--squash --build-arg ORACLE_PWD=123'

Это передаст в докерфайл переменную окружения ORACLE~PWD~, но докерфайл не передаст её в скрипты, которые запускаются во время сборки. Для того, чтобы он это сделал, нужно добавить в докерфайл инструкцию ARG.


ARG ORACLE_PWD=default

Пароль, как наверное уже стало ясно — будет 123, а если не передавать ORACLE~PWD~ в buildDockerImage.sh то default.


Иногда Оракл считает, что пароль плохой и работать не хочет, так что возможно надо будет заменить 123 на что-то другое


Тестовое поднятие полученного образа


Теперь можно попробовать запустить контейнер на основе образа


docker run --rm --name dockertest_habr -p 1234:1521 -p 5678:5500 --shm-size="1g" my/oracle-for-habr:11.2.0.2-xe

Тут важен аргумент --shm-size="1g", без которого контейнер стартует, но сам Оракл 11.2.0.2 работать не сможет. Это, на всякий случай, не означает, что контейнеру понадобится гигабайт оперативной памяти, он отжирает около 100 мегабайт.


Если контейнер нормально поднялся, можно попробовать подключиться к БД, которая там находится.


Адрес базы — скорее всего localhost
Порт — 1234
Пользователь — SYSTEM
Пароль — 123


Если нормально запустилось, то можно приступать к следующему этапу


Скрипт инициализации БД


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


После запуска контейнер посмотрит в директорию u01/app/oracle/scripts/startup и выполнит все sql скрипты, которые там найдёт, чем и можно воспользоваться, положив туда файл который создаст схему. Что-то в этом духе.


CREATE USER TEST_USER IDENTIFIED BY passwordnoquotes;
ALTER USER TEST_USER QUOTA unlimited ON SYSTEM;
GRANT CREATE SESSION, CONNECT, RESOURCE, DBA TO TEST_USER;
GRANT ALL PRIVILEGES TO TEST_USER;

Всё это нужно добавить в файл init~db~.sql, а файл пробросить в контейнер с помощью -v


docker run --rm --name dockertest_habr -p 1234:1521 -p 5678:5500 -e ORACLE_PWD=123 -v ${PWD}/init_db.sql:/u01/app/oracle/scripts/startup/init_db.sql --shm-size="1g" my/oracle-for-habr:11.2.0.2-xe

\${PWD} Тут используется потому, что нужен абсолютный путь к файлу, при использовании Windows нужно указать его как-то по другому. Если после старта схема TEST~USER~ успешно создана, то можно переходить к прикручиванию свежесозданного контейнера к тестам.


Использование образа в коде на Javа


При тестировании с использованием встроенной базы данных, как правило возникает одна и та же проблема. Если конфигурацию нельзя взять из кеша, то Спринг собирает её заново. В том числе он пересоздаёт встроенную БД, что тесты конечно серьёзно замедляет. Я решил проблему брутфорсом, просто сделав часть, поднимающую контейнер, синглтоном. Настоящим таким, кондовым.


Для Oracle XE в Testcontainers есть специально заготовленный класс. Этот класс во-первых знает, что речь идёт о контейнере с СУБД и что что того, чтобы определить, что он поднят, надо пытаться подсоединиться к базе данных с помощью jdbc.


Объект этого класса дождётся поднятия контейнера сам, нужно только сообщить ему какой логи с паролем использовать.


import org.testcontainers.containers.BindMode;
import org.testcontainers.containers.OracleContainer; 

public class StaticOracleContainer {
    public static OracleContainer getContainer() {
        return LazyOracleContainer.ORACLE_CONTAINER;
    }

    private static class LazyOracleContainer {
        private static final OracleContainer ORACLE_CONTAINER = makeContainer();

        private static OracleContainer makeContainer() {
            // Также можно вписать название образа в testcontainers.properties
            // Проперти oracle.container.image 
            final var dockerImageName = "my/oracle-for-habr";
            final var container = new OracleContainer(dockerImageName)
                     // Имя пользователя и пароль, которые testcontainers
                     // будут использовать, чтобы проверить, что
                     // контейнер поднят
                    .withUsername("SYSTEM").withPassword("123")
                     // Порты, которые testcontainers надо 
                     // замапить на внешние порты
                    .withExposedPorts(1521, 5500)
                     // Если в контейнере меньше гигабайта shared memory
                     // Оракл не включится
                    .withSharedMemorySize(2147483648L)
                     // То же самое, что задать при запуске контейнера опцию
                     // -v /path/to/init_db.sql:/u01/app/oracle/scripts/startup/init_db.sql 
                     // Оракл выполнит файл init_db.sql при запуске
                    .withClasspathResourceMapping("init_db.sql"
                            , "/u01/app/oracle/scripts/startup/init_db.sql"
                            , BindMode.READ_ONLY);
            container.start();
            return container;
        }
    }
}

Также testcontainers при запуске мапит внутренние порты контейнера на случайно определённые не занятые внешние порты. Поэтому можно не опасаться, что контейнер не поднимется, потому что порт уже кем-то используется. Внешний порт можно получить из контейнера с помощью метода getOraclePort().


Также можно получить адрес контейнера с помощью метода getContainerIpAddress(), но этот метод есть у любого контейнера.


После первого вызова метода getContainer, контейнер заново создаваться не будет, а будет возвращаться существующий. Этот метод теперь можно использовать в голой джаве или в конфигурации Спринга для того, чтобы получить объект с контейнером, из которого можно вытащить порты и адрес для подключения.


Например, можно сделать инициализатор, который при поднятии контекста переопределит спринговые проперти, отвечающие за коннект к базе данных.


Класс для прикрепления Testcontainers к Spring


После поднятия контейнера инициализатор переопределяет проперти, отвечающие за урл для подключения к базе данных, логин, пароль и всё такое.


Но, если в application.properties нет настройки evilcorp.testcontainers.enabled то контейнер подниматься не будет и всё будет работать так, как будто testcontainers никто не подключал.


package com.evilcorp.demo;

import org.junit.platform.commons.logging.Logger;
import org.junit.platform.commons.logging.LoggerFactory;
import org.springframework.boot.test.util.TestPropertyValues;
import org.springframework.context.ApplicationContextInitializer;
import org.springframework.context.ConfigurableApplicationContext;
import org.testcontainers.containers.OracleContainer;

public class TestcontainersInitializer implements ApplicationContextInitializer<ConfigurableApplicationContext> {
    static Logger log = LoggerFactory.getLogger(TestcontainersInitializer.class);

    @Override
    public void initialize(ConfigurableApplicationContext applicationContext) {
        // Настройка, которая отключает Testcontainers
        final String testcontainersEnabled = applicationContext.getEnvironment().getProperty("evilcorp.testcontainers.enabled");
        if (!"true".equals(testcontainersEnabled)) {
            return;
        }
        OracleContainer oracleContainer = StaticOracleContainer.getContainer();
        oracleContainer.followOutput(s -> log.debug(() -> s.getUtf8String()));

    // IP адрес контейнера может меняться в зависимости
    // от того, где он запускается 
        // (linux, MacOs, Windows или Докер) , 
    // поэтому надо использовать oracleContainer.getContainerIpAddress()
    // чтобы узнать адрес
    //
    // Testcontainers замапит стандартные порты оракл
    // на случайные порты, поэтому надо использовать oracleContainer.getOraclePort()
    // для того, чтобы понять по какому порту подсоединяться к базе
        final String jdbcUrl = "jdbc:oracle:thin:@//"
        + oracleContainer.getContainerIpAddress() 
        + ":" + oracleContainer.getOraclePort() + "/XE";
        // Имя пользователя и пароль из init_db.sql
        // Именно их должно использовать приложение
        final String user = "TEST_USER";
        final String password = "passwordnoquotes";
        TestPropertyValues.of(
                "spring.jpa.properties.hibernate.default_schema=" + user,
                "spring.datasource.driver-class-name=oracle.jdbc.OracleDriver",
                "spring.jpa.database-platform=org.hibernate.dialect.Oracle10gDialect",
                "spring.datasource.username=" + user,
                "spring.datasource.password=" + password,
                "spring.datasource.url=" + jdbcUrl,
                "spring.liquibase.url=" + jdbcUrl,
                "spring.liquibase.user=" + user,
                "spring.liquibase.password=" + password
        ).applyTo(applicationContext.getEnvironment(), TestPropertyValues.Type.MAP, "test");
    }
}

Эту конфигурацию можно использовать в спринг бутовом тесте, чтобы подменить настройки БД на лету.


Тест с использованием Testcontainers


Тест просто пишет в базу объект, а потом вычитывает его, ничего особенного.


package com.evilcorp.demo;

import com.evilcorp.demo.entity.User;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ContextConfiguration;

import java.util.Optional;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNotSame;
import static org.junit.jupiter.api.Assertions.assertTrue;

@SpringBootTest
@ContextConfiguration(initializers = {TestcontainersInitializer.class})
class TestcontainersSpringDemoApplicationTests {
    @Autowired
    UserRepository userRepository;
    private User createdUser;

    @BeforeEach
    void setUp() {
        createdUser = new User();
        createdUser.setName("Fry");
        userRepository.save(createdUser);
    }

    @Test
    void userRepositoryLoaded() {
        assertNotNull(userRepository);
    }

    @Test
    void userAdded() {
        final Optional<User> loadedUser = userRepository.findById(createdUser.getUserId());
        assertTrue(loadedUser.isPresent());
        assertEquals("Fry", loadedUser.get().getName());
        assertNotSame(createdUser, loadedUser.get());
    }
}

И да, нужно добавить в pom.xml зависимости


        <dependency>
            <groupId>org.testcontainers</groupId>
            <artifactId>testcontainers</artifactId>
            <version>1.12.3</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.testcontainers</groupId>
            <artifactId>oracle-xe</artifactId>
            <version>1.12.3</version>
            <scope>test</scope>
        </dependency>

Вот примерно так можно сделать докер-образ СУБД Оракл и воспользоваться им в коде на Джаве. Осталось только выложить образ в корпоративный репозиторий артефактов и наладить запуск тестов внутри другого докер-контейнера. Но это уже совсем другая история.

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


  1. Apx
    16.12.2019 21:29

    Небольшой P. S. всем кто хочет попробовать сей инструмент. При всей любви к тест контейнерам, для тяжеловесов вроде оракловых докер образов и просто комплексных тестовых окружении для интеграционников, лучше всё таки использовать docker-maven-plugin и стартовать всё нужное перед failsafe тестами. Оно очень убогое и делает дебаг тестов и кода крайне тяжеловесным (не говоря уже о танцах с бубном чтобы зарегать приложение бегущее в хосте в рамках докер сети, если есть необходимость проксирования запросов и прочего), но позволит уменьшить суммарный рантайм тестов, что всегда есть гуд.