Вчимося писати інтеграційні тести для баз даних та Docker
Всім привіт. Я Сергій Моренець, розробник, тренер, викладач, спікер і технічний письменник. Хочу поділитися з вами своїм досвідом написання інтеграційних тестів, що використовують Docker і Docker-контейнери. У цій статті я опишу створення таких тестів для двох платформ (Spring Boot та Enterprise Java) і двох СУБД (MySQL та Postgres). Сподіваюся, що матеріал буде корисним для всіх, хто хоче більше дізнатися про сучасні тенденції тестування. А також про те, як працює Spring Boot під капотом і як вона інтегрована з базами даних.
Testcontainers
На одному з наших проєктів ми зіткнулися з невеличкою проблемою, коли поміняли версію MySQL з 8 на 9. Сервіс, який його використав, не зміг запуститися, оскільки тодішня версія Hibernate (6.2) не підтримувала MySQL 9 без додаткових налаштувань. Наші інтеграційні тести не змогли виявити цю проблему, оскільки ми використовували досить популярну стратегію — у ролі сховища даних у тестах виступав не MySQL, а вбудована БД (H2). H2 зручна тим, що вона не вимагає інсталяції, конфігурації, вміє зберігати дані в пам’яті, підтримує сучасний синтаксис ANSI SQL і добре інтегрується зі Spring Boot.
Мінусом є те, що ми згадки не маємо, як працюватимуть наші послуги на реальній СУБД. Таким чином для очищення совісті та мінімізації майбутніх production issues вирішили додати нові інтеграційні тести, які будуть використовувати реальні бази даних. Це стосується і MySQL, і Postgres, які ми використовували в інших сервісах. Нові тести не перевірятимуть всю функціональність сервісів, їх роль — засвідчити те, що застосунок запуститься і зможе завантажити application context, а також виконати якісь базові запити.
Для такого завдання найкраще підійдуть контейнери Docker для цих баз даних. Потрібен лише якийсь інструмент для реалізації трьох завдань:
- Конфігурація та запуск необхідних Docker-контейнерів.
- Запуск тестів.
- Зупинка та видалення контейнерів.
В принципі, навіть наявність локального Docker-демона не обов’язкова, можна використовувати і віддалений. Але як з Java звернутися до Docker-демона? Зробити це було нескладно, оскільки для інтеграції з Docker є бібліотека Java, яка дозволяє використовувати Docker API з ваших Java-проєктів. Тому вже в 2015 році з’явився новий революційний проєкт, який так і назвали — TestContainers.
TestContainers підтримує основні Java-бібліотеки для тестування (Junit 4/5, Spock) та добре підходить для використання в інтеграційних тестах. Тепер нам не потрібно використовувати HyperSQL та H2, тому що ми завжди можемо запустити MySQL/Postgres з Docker, причому потрібної нам версії та конфігурації. Фактично Testcontainers — це обгортка навколо Docker-клієнта, але з досить просунутими можливостями. Завдяки яким вона набула неймовірної популярності й була портована 11 мовами програмування. Включно навіть з такими екзотичними, як Haskell і Elixir.
Spring Boot і конфігурування баз даних
Зараз для наших Spring Boot сервісів використовується H2 як база даних. Оскільки ми хочемо використовувати в тестах відразу кілька баз даних/з’єднань, спочатку потрібно з’ясувати, яким чином Spring/Spring Boot дізнаються, що потрібно використовувати саме H2. Адже ми ніде не вказуємо це в конфігурації. Якщо подивитися на Maven-конфігурацію, то у нас два драйвери для СУБД:
<dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> <version>${h2.version}</version> <scope>test</scope> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <version>${mysql.version}</version> <scope>runtime</scope> </dependency>
У тестовому application.properties ніде не вказано ні Hibernate діалект, ні JDBC URL. То як Spring Boot обирає з двох БД і налаштовує JDBC-з’єднання? Вся магія розміщена у двох класах з автоконфігурації Spring Boot — DataSourceAutoConfiguration та DataSourceProperties.
DataSourceAutoConfiguration відповідає за вибір DataSource-біна і при уважному вивченні коду видно, що він аналізує властивості з префіксом spring.datasource. Так, наприклад, в spring.datasource.type можна вказати клас використовуваного DataSource. Ну а якщо нічого не вказувати, за замовчуванням обирається HikariDataSource з бібліотеки Hikari, яка постачається разом зі Spring Boot.
Ну а DataSourceProperties насамперед перевіряють, чи доступні embedded БД, які перераховані в EmbeddedDatabaseConnection:
public enum EmbeddedDatabaseConnection { NONE(null), H2("jdbc:h2:mem:%s;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE"), DERBY("jdbc:derby:memory:%s;create=true"), HSQLDB("org.hsqldb.jdbcDriver«, «jdbc:hsqldb:mem:%s»);
Це здійснюється шляхом пошуку JDBC-драйвера в classpath:
public String getDriverClassName() { // See github.com/.../spring-boot/issues/32865 return switch (this) { case NONE -> null; case H2 -> DatabaseDriver.H2.getDriverClassName(); case DERBY -> DatabaseDriver.DERBY.getDriverClassName(); case HSQLDB -> DatabaseDriver.HSQLDB.getDriverClassName(); }; }
Таким чином, якщо у вас у проєкті є і H2, і Derby, і HSQLDB, то за умовчанням завжди обиратиметься перша у списку H2. Що, якщо ви хочете обрати HSQL? Якщо ви просто спробуєте вказати новий клас драйвера:
spring.datasource.driver-class-name=org.hsqldb.jdbcDriver
То це не спрацює, оскільки новий драйвер успішно застосовується, але JDBC URL буде, як і раніше, від H2:
Caused by: java.lang.RuntimeException: Driver org.hsqldb.jdbc.JDBCDriver claims to not accept jdbcUrl, jdbc:h2:mem:5eb8306b-34d9-42b0-99a7-23d8a454239a;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE at com.zaxxer.hikari.util.DriverDataSource.<init>(DriverDataSource.java:109) ~[HikariCP-5.1.0.jar:?]
Тому тут потрібно явно вказати новий тип БД:
spring.datasource.embedded-database-connection=hsqldb
Починаємо писати тести
Повернемося назад до тестів. У нас вже є Unit-тест для компонента TicketService, заснований на моках та Mockito:
@ExtendWith(MockitoExtension.class) class TicketServiceImplTest { @Spy @InjectMocks TicketServiceImpl ticketService; @Mock OrderRepository orderRepository;
Спробуємо тепер для нього написати інтеграційний тест за допомогою TestContainers і заснований на використанні контейнерів Docker. Це дасть змогу порівняти складність написання Unit та інтеграційних тестів для тих компонентів, які працюють із зовнішніми залежностями (у нашому випадку це база даних). Сам тест на основі H2 досить простий:
@DataJpaTest @Import(PersistenceConfiguration.class) @Transactional public class TicketServiceImplIntegrationTest { @Autowired TicketService ticketService; @Test void makeReservation_validOrder_success() { Order order = new Order(); order.setDueDate(LocalDateTime.now().plusDays(2)); order.setClientPhone("123″); order.setClientName("test«); ticketService.makeReservation(order); assertTrue(order.getId() > 0); Order order2 = ticketService.findOrder(order.getId()); assertEquals(order.getId(), order2.getId()); assertEquals(order.getDueDate(), order2.getDueDate()); assertEquals(order.getClientPhone(), order2.getClientPhone()); assertEquals(order.getClientName(), order2.getClientName()); } }
Тут ми використовуємо анотацію @DataJpaTest, щоб не завантажувати повністю весь Spring application context. Разом з нею анотацію @Import, щоб завантажити TicketService-бін:
@Configuration public static class PersistenceConfiguration { @Bean TicketService ticketService(TicketRepository ticketRepository, OrderRepository orderRepository) { return new TicketServiceImpl(ticketRepository, orderRepository); } }
А анотація @Transactional допомагає скасувати поточну транзакцію та видалити всі ті зміни в БД, які були зроблені під час роботи тесту. Це дуже важливо, щоб інтеграційні тести були ізольованими та не впливали один на одного.
Використовуємо Testcontainers
Ще одна причина популярності Testcontainers — її модульна структура, завдяки якій ви використовуєте лише ті модулі, які вам потрібні. Тому загальноприйнятою практикою є додавання BOM-залежності, в якій уже вказано всі версії модулів:
<dependencyManagement> <dependencies> <dependency> <groupId>org.testcontainers</groupId> <artifactId>testcontainers-bom</artifactId> <version>1.20.4</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement>
Як тепер прикрутити до тестів Docker та TestContainers? Насамперед потрібно додати нову залежність, яка полегшить роботу з MySQL-контейнером:
<dependency> <groupId>org.testcontainers</groupId> <artifactId>mysql</artifactId> <scope>test</scope> </dependency>
Далі додамо анотацію @Testcontainers, яка інтегрує Testcontainers і Junit:
@EnabledIfDockerAvailable @Testcontainers public class TicketServiceImplIntegrationTest {
Навіщо потрібна анотація @EnabledIfDockerAvailable? Справа в тому, що в нашому випадку сервіс збирається всередині контейнера Docker і в такому варіанті Docker сервіс буде недоступний. Але локально тест повинен запускатися та відпрацьовувати. Для таких випадків раніше ми використовували Junit анотацію @EnabledIfEnvironmentVariable:
@EnabledIfEnvironmentVariable(named = «ENABLE_TEST_CONTAINERS», matches = «true») @SpringJUnitConfig(TicketApplication.class) @Testcontainers class KafkaEventConsumerTest {
Однак ця анотація вимагає, щоб ми явно додали нову змінну оточення ENABLE_TEST_CONTAINERS для того, щоб Junit запустив цей тест під час збирання. Але нещодавно в Testcontainers з’явилася зручніша анотація @EnabledIfDockerAvailable для тієї ж мети.
Цікаво, а як працює нова анотація? Якщо подивитися на клас DockerAvailableDetector, то він просто намагається ініціалізувати Docker-клієнт, а якщо це неможливо (закритий порт), то видається виключення:
class DockerAvailableDetector { public boolean isDockerAvailable() { try { DockerClientFactory.instance().client(); return true; } catch (Throwable ex) { return false; } } }
Продовжимо із тестом. Тепер потрібно вказати, щоб перед стартом тесту автоматично запускався MySQL контейнер, а після тестів він зупинявся/віддалявся:
@Container static final MySQLContainer mysql = new MySQLContainer<>(DockerImageName.parse("mysql:9″)) .withDatabaseName("ticket") .withUsername("test") .withPassword("test");
Зверніть увагу, що поле mysql має бути static. Клас MySQLContainer дозволяє вказати, яку БД і якого користувача потрібно створити при запуску контейнера. Але якщо ми просто запустимо наш тест, то виявимо, що Docker image успішно завантажився і запустився у вигляді контейнера, але в тесті все одно використовується H2. Тому потрібно змінити налаштування для з’єднання з сервером MySQL. Це можна зробити різними способами. Перший спосіб, який з’явився ще в Spring 5.2, полягає в написанні в тестовому класі статичного методу, який повинен мати дві особливості:
- Анотація @DynamicPropertySource
- Один аргумент DynamicPropertyRegistry, який дозволяє динамічно змінювати значення властивостей
Далі в тілі цього методу ми додаємо, а фактично замінюємо ті властивості, які й відповідають за з’єднання з базою даних. Зверніть увагу, що JDBC URL нам явно повертається з об’єкта MySQLContainer, немає необхідності вручну його створювати:
@DynamicPropertySource static void configureProperties(DynamicPropertyRegistry registry) { registry.add("spring.datasource.url", mysql::getJdbcUrl); registry.add("spring.datasource.username", mysql::getUsername); registry.add("spring.datasource.password«, mysql::getPassword); }
Запускаємо тест і отримуємо помилку, яка говорить, що з якоїсь причини в базі даних відсутня таблиця ORDERS. Причому в логах немає і повідомлень, що вона була створена:
23:15:20.394 ERROR — Table ’ticket.orders’ doesn’t exist
Це виглядає дивно тим, що ми нічого не змінювали в налаштуваннях Hibernate, а просто змінили деталі з’єднання. Зазвичай за роботу зі створенням схеми в Hibernate відповідає ця властивість:
hibernate.hbm2ddl.auto=update
Ми його ніде не вказували, проте наші інтеграційні тести на базі H2 успішно працювали і без нього. Детальне дослідження цієї проблеми наштовхує нас на клас HibernateDefaultDdlAutoProvider і такий метод:
String getDefaultDdlAuto(DataSource dataSource) { if (!EmbeddedDatabaseConnection.isEmbedded(dataSource)) { return «none»; } SchemaManagement schemaManagement = getSchemaManagement(dataSource); if (SchemaManagement.MANAGED.equals(schemaManagement)) { return «none»; } return «create-drop»; }
Тобто коли ми використовували вбудовану базу даних (H2), то за умовчанням використовувався режим create-drop, який створював/знищував схему цілком у тестах. Як тільки ми перейшли на MySQL (не embedded), це перестало працювати. Виправити це легко, потрібно лише явно вказати, що ми хочемо, щоб Hibernate автоматично генерував схему:
spring.jpa.hibernate.ddl-auto=update
Тут ми вказали не значення create-drop, а update, що мало б однаковий ефект для H2. Оскільки і там, і там схема бази даних створювалася б з нуля. Але для MySQL він є переважним з причин, про які я розповім пізніше. Варіант @DynamicPropertySource довгий час залишався єдиним робочим, поки в Spring Boot 3.1 не з’явився новий проєкт Spring Boot Testcontainers, який дозволяв простіше налаштовувати з’єднання до контейнерів:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-testcontainers</artifactId> <version>${spring.boot.version}</version> <scope>test</scope> </dependency>
Тепер ми можемо видалити весь метод configureProperties і додати для поля mysql-анотацію @ServiceConnection з нової залежності, яка буде робити те ж саме, тобто налаштовувати властивості для з’єднання з базою:
@Container @ServiceConnection static final MySQLContainer mysql = new MySQLContainer<>(DockerImageName.parse("mysql:9″)) .withDatabaseName("ticket").withUsername("test").withPassword("test");
Новий підхід досить зручний, але доводиться додавати поле mysql скрізь у тестових класах, де нам потрібен MySQL-контейнер. Вихід із цієї ситуації полягає у винесенні цього поля з тестового класу та перетворення на Spring-бін, а Spring Boot автоматично запустить контейнер, якщо виявить такий бін. Головне не забути анотацію @ServiceConnection, без якої у нас, як і раніше, буде використовуватися H2.
@Configuration public class ContainerConfiguration { @Bean @ServiceConnection MySQLContainer<?> mysql() { return new MySQLContainer<>(DockerImageName.parse("mysql:9")).withDatabaseName("ticket").withUsername("test") .withPassword("test"); } }
Залишилося імпортувати новий клас-конфігурацію ContainerConfiguration у тестовому класі:
@EnabledIfDockerAvailable @DataJpaTest @Import({PersistenceConfiguration.class, ContainerConfiguration.class}) @Transactional @Testcontainers public class TicketServiceImplIntegrationTest {
Ми досягли своїх цілей, але тепер тести, які завантажують усі Spring-біни нашого проєкту, також завантажують і нову конфігурацію, і запускають MySQL-контейнер. Нам це не підходить, тому ми хочемо, щоб ті тести, які використовували H2, продовжували це робити. Найпростіше додати анотацію @Profile на клас-конфігурацію:
@Configuration @Profile("container") public class ContainerConfiguration {
І вказати її у тестовому класі:
@ActiveProfiles("container") public class TicketServiceImplIntegrationTest {
Перевикористовуємо контейнери
Спробуємо порівняти швидкодію всіх трьох типів тестів у нашому сервісі:
- Інтеграційний тест на базі H2 — 1.05 сек.
- Unit-тест — 1.44 сек.
- Інтеграційний тест на базі MySQL/Docker — 0.3 сек.
Тут нікого не повинно бентежити, що третій тест відпрацьовує лише за 0.3 сек. Це дійсно так, але якщо подивитися в лозі повідомлення від Testcontainers:
12:56:28.609 INFO — Image pull policy will be performed by: DefaultPullPolicy() 12:56:40.674 INFO — Container is started (JDBC URL: jdbc:mysql://localhost:62255/ticket)
То ми побачимо, що процес створення та запуску контейнера зайняв 12(!) секунд. На щастя, тут є можливості для оптимізації — властивість reuse. Якщо її вказати у налаштуваннях об’єкта-контейнера:
@Bean MySQLContainer<?> mysql() { return new MySQLContainer<>(DockerImageName.parse("mysql:9")).withDatabaseName("ticket").withUsername("test") .withPassword("test").withReuse(true); }
То Testcontainers не буде видаляти контейнер після закінчення тестів. Ба більше, при створенні контейнера в тестах він створить SHA-1 хеш на основі всіх його характеристик. І цей хеш буде додано як label з ідентифікатором org.testcontainers.copied_files.hash у цей контейнер. При повторному запуску тесту, якщо властивості контейнера не змінилися, то його шукають за цим унікальним label. І, як видно з логів, тепер контейнер стартує лише за одну секунду:
13:00:47.529 INFO — Image pull policy will be performed by: DefaultPullPolicy() 13:00:48.248 INFO — Reusing existing container (030b9685564b36fa89abb186fd96c821d98fa112fb8d6075c8c3915bd305138b) and not creating a new one 13:00:48.479 INFO — Container is started (JDBC URL: jdbc:mysql://localhost:62364/ticket)
Щоправда, тут можна потрапити у дві пастки, якщо довіритися Testcontainers і не перевірити результат. Річ у тому, що опція reuse не має жодного значення, якщо ви глобально не дозволили таку фічу, як перевикористання контейнерів. Дозволити її можна, встановивши опцію testcontainers.reuse.enable у true двома способами:
- Змінні оточення.
- Конфігураційний файл .testcontainers.properties у домашній папці поточного користувача.
Ще одна підступна пастка полягає в тому, що хеш генерується на основі всіх атрибутів Docker image/контейнера. Якщо якийсь атрибут генерується випадковим чином, то хеш щоразу буде різним і Testcontainers не зможе знайти контейнер від попереднього запуску тесту.
Весь тест цілком виглядає так:
@EnabledIfDockerAvailable @DataJpaTest @Import({PersistenceConfiguration.class, ContainerConfiguration.class}) @Transactional @Testcontainers @ActiveProfiles("container") public class TicketServiceImplIntegrationTest { @Autowired TicketService ticketService; @Test void makeReservation_validOrder_success() { Order order = new Order(); order.setDueDate(LocalDateTime.now().plusDays(2)); order.setClientPhone("123″); order.setClientName("test"); ticketService.makeReservation(order); assertTrue(order.getId() > 0); Order order2 = ticketService.findOrder(order.getId()); assertEquals(order.getId(), order2.getId()); assertEquals(order.getDueDate(), order2.getDueDate()); assertEquals(order.getClientPhone(), order2.getClientPhone()); assertEquals(order.getClientName(), order2.getClientName()); } }
Запускаємо тести — всі вони проходять успішно, причому запускаються на звичайному комп’ютері й ігноруються всередині Docker-контейнера.
Тести для Enterprise Java
Тепер перейдемо до сервісу, який працює на базі Jakarta/Java EE та використовує Postgres базу даних. Спочатку додамо необхідні Maven-залежності для Postgres:
<dependency> <groupId>org.testcontainers</groupId> <artifactId>postgresql</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.testcontainers</groupId> <artifactId>junit-jupiter</artifactId> <scope>test</scope> </dependency>
Потім додамо новий тест для компонента StationRepository:
@EnabledIfDockerAvailable @Testcontainers public class HibernateStationRepositoryTest { StationRepository stationRepository; @Container static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>(DockerImageName.parse("postgres:17-alpine")) .withDatabaseName("geography").withUsername("test").withPassword("test").withReuse(true);
Ми відразу вказали опцію reuse, а також обрали Alpine версію Postgres-контейнера, оскільки він займає набагато менше місця. І такі Docker images якраз призначені для використання там, де немає потреби в якихось додаткових ОС-пакетах. Цікаво, що MySQL не має офіційного Alpine-образу, він збирається на базі slim-версії Oracle Linux.
Головна складність полягає в конфігуруванні, тому що тут немає Spring Boot та його автоконфігурації налаштувань. Ми використовуємо специфікацію Eclipse Microprofile та бібліотеку SmallRye Config для завантаження властивостей з конфігураційних файлів:
@BeforeEach void setup() { SessionFactoryBuilder builder = new SessionFactoryBuilder(ConfigProvider.getConfig()); stationRepository = new HibernateStationRepository(builder); }
Тут виникає та сама проблема, що й була з попереднім сервісом. Потрібно динамічно змінити значення властивостей, причому тільки для одного тесту. Найпростіше це зробити через системні властивості:
@BeforeAll static void initialize() { System.setProperty("hibernate.dialect", PostgreSQLDialect.class.getName()); System.setProperty("hibernate.connection.url", postgres.getJdbcUrl()); System.setProperty("hibernate.connection.username«, «test»); System.setProperty("hibernate.connection.password«, «test»); System.setProperty("hibernate.connection.driver_class", postgres.getDriverClassName()); }
Ось як виглядає сам тест:
@Test void save_validStation_sucess() { var station = new Station(); station.setPhone("123″); station.setTransportType(TransportType.AVIA); try { stationRepository.save(station); assertTrue(station.getId() > 0); var station2 = stationRepository.findById(station.getId()); assertNotNull(station2); assertEquals(station.getId(), station2.getId()); } finally { if (station.getId() != 0) { stationRepository.delete(station.getId()); } } }
Ми наприкінці видаляємо створену станцію, щоб вона не впливала на решту тестів. Але тут виникає одна проблема. Тепер усі тести, які запускаються після створеного нами, також використовують нові налаштування. Але це ще півбіди. Головна проблема в тому, що вони намагаються під’єднатися до сервера Postgres, хоча той вже зупинений разом з Docker-контейнером. Тому доводиться в тестовому класі спочатку запам’ятовувати всі системи властивості, які ми змінюємо, а потім їх відновлювати:
static Map<String, String> oldProperties; @BeforeAll static void initialize() { oldProperties = new HashMap<>(); updateSystemProperty("hibernate.dialect", PostgreSQLDialect.class.getName()); updateSystemProperty("hibernate.connection.url", postgres.getJdbcUrl()); updateSystemProperty("hibernate.connection.username«, «test»); updateSystemProperty("hibernate.connection.password«, «test»); updateSystemProperty("hibernate.connection.driver_class", postgres.getDriverClassName()); } @AfterAll static void tearDown() { oldProperties.forEach((key, value) -> { if (value == null) { System.clearProperty(key); } else { System.setProperty(key, value); } }); } private static void updateSystemProperty(String name, String value) { oldProperties.put(name, System.setProperty(name, value)); }
Тепер усі тести запускаються успішно.
Висновки
Отже, нам вдалося додати нові інтеграційні тести, які вже використовують Testcontainers та Docker-контейнери (MySQL/Postgres). Це дозволить впевнитись на етапі збирання, що наша поточна конфігурація не призведе до помилок на продакшені під час роботи з БД. Найпростіше це робити для Spring Boot застосунків завдяки гнучкій інтеграції Spring та Testcontainers. Ще один важливий момент — варто перевикористовувати контейнери, щоб скоротити час запуску тестів.
1 коментар
Додати коментар Підписатись на коментаріВідписатись від коментарів