Як тестувати Java-додатки, що використовують Mongo
Тестування Java додатків не таке просте завдання, особливо для enterprise-проєктів. Тому я постійно звертався до цієї теми при написанні четвертої частини своєї книги «Розробка Java додатків» і хочу зараз розповісти про те, як просто і ефективно тестувати Mongo-додатки. Також в цій статті використано практичний досвід з нашого тренінгу «NoSQL для Java розробників».
Практично будь-який комерційний додаток використовує те чи інше сховище даних. Навіть pet projects зазвичай використовують базу даних. І зрозуміло, ви навряд чи будете писати свою СУБД з нуля, а будете використовувати готове рішення. Навіть зараз, в 2021 році, реляційні бази даних найбільш популярні і прості і в використанні/підтримці. Вони досить стабільні, пропонують свої розширення як у плані SQL, так і пропонованої функціональності.
Але в їх використанні була одна пастка. Для інтеграційних тестів потрiбен був встановлений сервер БД, а це додаткові витрати і часу, і ресурсів. Тому зазвичай застосовували два обхідних шляхи:
- Різні mock-технології.
- In-memory бази даних (H2, HyperSQL), які зберігають дані в пам’яті і видаляють ці дані після завершення роботи JVM.
Другий підхід став більш популярним, але й у нього знайшлися недоліки:
- In-memory бази даних підтримують ANSI-SQL, але не підтримують розширення, які є в MySQL або Postgres.
- Вони не підтримують багато фіч крупних СУБД, наприклад, збережені процедури або мову PL/SQL.
- У деяких випадках запити працюють інакше в H2, ніж, наприклад в Postgres.
Тому якщо ви використовуєте H2 в тестах, і всі ваші тести проходять, це на 100% не гарантує, що весь код буде успішно працювати на реальнiй БД.
Тепер уявімо, що ви перейшли з реляційних СУБД на NoSQL, наприклад, Mongo. Ви підтримуєте принцип TDD (або просто любите писати тести), у вас вже готове CI/CD оточення для збірки додатку. І тут головна перешкода для автоматизації збирання — неможливість запуску тестів без встановленого Mongo сервера. Ви раніше використовували H2 і навіть мирилися з її мінусами. Чи є її аналоги для MongoDB? Є, причому не один:
- Embedded Mongo.
- Fongo.
- MongoDB Java server.
- TestContainers.
Embedded Mongo — проєкт, який бере початок з 2012 року, і призначений в основному для використання в тестових цілях. Справа в тому, що Mongo підтримує in-memory engine, але тільки в Enterprise Edition, яка є платним продуктом. Тому Embedded Mongo працює наступним чином:
- Завантажує MongoDB потрібної вам версії.
- Запускає Mongo сервер локально на вашому комп’ютері.
- Після завершення всіх тестів зупиняє Mongo сервер.
У такій технології є чимало шанувальників, але є й свої мінуси:
- Хоча ви самі вибираєте версію MongoDB для використання, але, по суті, ви обмежені тими дистрибутивами, які викладені на офіційному сайті і можуть бути видалені в будь-який момент.
- Ви можете використовувати тільки ті дистрибутиви, які є для вашої ОС.
- Процес завантаження займає певний час і вимагає наявності стабільного і швидкого Інтернету.
- Версія офіційно не підтримується MongoDB, ви використовуєте її на свій страх і ризик.
Тому свого часу з’явилися альтернативи цьому продукту, в першу чергу Fongo (від скорочення Fake Mongo). Fongo з’явився в 2013 році і запропонував абсолютно інший принцип роботи. Замість того, щоб запускати Mongo сервер, він просто перехоплює всі виклики до Mongo драйверу і виконує необхідні операції в пам’яті. Такий підхід вимагає мінімум часу для старту і мінімум витрат пам’яті, але й тут є свої мінуси:
- Найголовніший — це те, що проєкт не підтримується з 2018 року і з драйвером версії 4.x не сумісний (і навіть з деякими 3.x версіями, наприклад 3.8.x).
- Підтримуються найбільш популярні операції, але не всі, наприклад, GridFs ви використовувати не можете, як і повнотекстовий пошук.
Тому ті розробники, які використовували Fongo, в 2018 році були змушені перейти на альтернативний проєкт — Mongo Java server. Він не перехоплює виклики до Java драйверу, а працює через MongoDB Wire протокол, отримуючи команди/запити на відкритий сокет і видаючи відповідь. Тому ця технологія не залежить від конкретної версії Java драйвера і може бути використана навіть на не-Java проєктах. Всю ту інформацію, яку ви зберігаєте в Mongo (документи, колекції) можна вибірково зберігати двома способами:
- У пам’яті.
- У базі даних (H2 або Postgres).
Mongo Java server також не підтримує всі фічі Mongo, тому ви використовуєте його на свій страх і ризик, без гарантії, що код в production буде працювати так само, як і в тестах. Це особливо критично, якщо врахувати, що всі ці три проєкти ніякого відношення до компанії MongoDB не мають і офіційно не підтримуються. Їх підтримка може бути припинена в будь-який момент (як це трапилося з Fongo).
Більш того, у розглянутих технологій є і додатковий мінус — ніщо з них не є універсальним рішенням (як H2 для реляційних БД), яке можна було б застосувати до будь-якої NoSQL бази даних. Тому для Redis потрібна буде своя технологія, а для Amazon DynamoDB — своя.
Зрозуміло, що ІТ-індустрія завжди знаходиться в пошуку нових рішень. Ще в середині
- Конфігурація і запуск необхідних Docker контейнерів.
- Запуск тестів.
- Зупинка і видалення контейнерів.
В принципі, навіть наявність локального Docker демона — не обов’язкова, можна використовувати і віддалений. Але як з Java звернутися до Docker демона? Зробити це було нескладно, оскільки для інтеграції з Docker є бібліотека на Java, яка дозволяє використовувати Docker API з ваших Java проєктів. Тому вже в 2015 році з’явився новий революційний проєкт, який так і назвали — TestContainers. TestContainers підтримує основні Java бібліотеки для тестування (Junit 4/5, Spock) і добре підходить для використання в інтеграційних тестах. Тепер, якщо ви використовуєте реляційну СУБД, вам не потрібно використовувати HyperSQL і H2, так як ми завжди можемо запустити вашу СУБД з Docker, причому потрібної нам версії і конфігурації.
TestContainers підтримує будь-яку з сучасних платформ (Spring Boot/Java EE), але цікавіше спробувати його з якоюсь новою технологією, наприклад Micronaut. Використовувати TestContainers просто в будь-якому варіанті, наприклад для інтеграції з Junit 5 потрібно додати всього лише одну залежність:
testImplementation("org.testcontainers:junit-jupiter")
З її допомогою ви можете використовувати обгортку для будь-якого Docker образу в ваших тестах. Але каверза криється в тому, що для багатьох образів потрібно додаткове налаштування (за допомогою параметрів або змінних оточення). І для таких технологій TestContainers приготував спеціальні Maven залежності, куди вже вбудована ця підготовча робота. Тому зручніше використовувати саме цю надбудову:
testImplementation("org.testcontainers:mongodb")
Як зміняться нашими інтеграційні тести? Уявімо, що у на є клас MongoSyncPaymentRepository, який реалізує патерн репозиторій для доступу до Mongo, і тестовий клас MongoSyncPaymentRepositoryTest. Для того, щоб інтегрувати його з TestContainers, потрібно додати анотацію @TestContainers:
@MicronautTest
@Testcontainers
public class MongoSyncPaymentRepositoryTest {
Потім потрібно вказати, які Docker контейнери нам потрібні при запуску тестів. Для цього використовується наступна конструкція. Ви додаєте публічне поле, тип якого — GenericContainer (або його спадкоємці) і додаєте анотацію @Container:
@Container
public MongoDBContainer mongo = new MongoDBContainer("mongo:4.4");
Тепер перед стартом тестів TestContainers запустить Mongo контейнер, а після їх завершення — зупинить його. Спробуємо запустити цей тест. Виходячи з логу, спочатку все було добре:
[35mo.testcontainers.DockerClientFactory_[Checking the system...
[35mo.testcontainers.DockerClientFactory_[✔︎ Docker server version should be at least 1.6.0
[35mo.testcontainers.DockerClientFactory_[✔︎ Docker environment should have more than 2GB free disk space
[35m🐳 [mongo:4.4]_[Creating container for image: mongo:4.4
[mongo:4.4]_[ Starting container with ID: 2e70b4030a5440a2d56313bf625c3446111bb67f0670243dc00d648c83c59189
[35m🐳 [mongo:4.4]_[Container mongo:4.4 is starting: 2e70b4030a5440a2d56313bf625c3446111bb67f0670243dc00d648c83c59189
[35m🐳 [mongo:4.4]_[Container mongo:4.4 started in PT0.9890366S
[35morg.mongodb.driver.cluster_[0;39m — Cluster created with settings {hosts=[localhost:27017], mode=SINGLE, requiredClusterType=UNKNOWN, serverSelectionTimeout=’30000 ms’}
[cluster-ClusterId{value=’609d2f345c7f8d76ea7d4e27′, description=’null’}-localhost:27017]
А потім — помилка з’єднання:
com.mongodb.MongoSocketOpenException: Exception opening socket
Але ця помилка очікувана. Коли ми локально запускаємо контейнер з MongoDB за допомогою Docker CLI, то завжди вказуємо параметри для port mapping. Port mapping відкриває локальний порт, який буде з’єднаний тунелем з портом в контейнері (27017). Якщо ви використовуєте MongoDBContainer, то це робити не потрібно, за замовчуванням він вже включає port mapping для порту 27017. Але який в цьому випадку буде локальний порт?
В принципі ви можете його вказати явно за допомогою низькорівневого API від бібліотеки Docker Java:
@Container
public MongoDBContainer mongo = new MongoDBContainer("mongo:4.4")
.withCreateContainerCmdModifier(createCmd -> createCmd.getHostConfig()
.withPortBindings(new PortBinding(
Ports.Binding.bindPort(27017), new ExposedPort(27017))));
Але такий спосіб вкрай не рекомендується використовувати, оскільки він непередбачуваний і в разі, якщо порт 27017 у вас вже зайнятий, тест впаде. Крім того, що такий варіант використовує низькорівневий Docker API і може бути змінений в нових версіях. Тому ми будемо використовувати стандартний варіант, в якому локальний порт вибирається випадковим чином:
@Container
public MongoDBContainer mongo = new MongoDBContainer("mongo:4.4");
Як же тоді визначити цей порт і як вказати його Micronaut? У Micronaut головний конфігураційний файл — application.yml, і налаштування для автоконфігурації з’єднання з MongoDB зазвичай виглядають наступним чином:
mongodb:
servers:
main:
uri: mongodb://${MONGO_HOST:localhost}:${MONGO_PORT:27017}
Як ви бачите, локальний порт за замовчуванням 27017, але ви можете перевизначити (або додати) це значення за допомогою властивості MONGO_PORT. Нам потрібно цю властивість перевизначити в тестах, причому динамічно, на етапі ініціалізації тесту. Для такого варіанту в Micronaut є спеціальний інтерфейс TestPropertyProvider. Якщо ви його реалізуєте в вашому тестовому класі:
public class MongoSyncPaymentRepositoryTest implements TestPropertyProvider {
то потім можете перевизначити потрібні властивості:
@Override
public Map<String, String> getProperties() {
return Map.of("MONGO_PORT", String.valueOf(mongo.getMappedPort(27017)));
}
При цьому потрібно додати спеціальну інструкцію @TestInstance, щоб у нас створювався один об’єкт тестового класу для всіх тестів (інакше getProperties ніяк не враховуватиметься):
@MicronautTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@Testcontainers
public class MongoSyncPaymentRepositoryTest implements TestPropertyProvider {
Запускаємо тест ще раз і отримуємо нову помилку:
java.lang.IllegalStateException: Mapped port can only be obtained after the container is started
at org.testcontainers.shaded.com.google.common.base.Preconditions.checkState(Preconditions.java:174)
Проблема в тому, що метод getProperties викликається ще до того, як стартував Mongo-контейнер. Щоб виправити цю помилку, доведеться вручну стартувати Mongo-контейнер:
@Container
public static MongoDBContainer mongo;
static {
mongo = new MongoDBContainer("mongo:4.4");
mongo.start();
}
При цьому ми залишаємо анотацію @Container, щоб TestContainers зупинив і видалив контейнер після завершення всіх тестів.
Запускаємо тести, і тепер тест проходить успішно, при чому в консолі видно, який локальний порт був обраний:
[org.mongodb.driver.cluster — Cluster created with settings {hosts=[localhost:49160]
Таким чином, ви можете використовувати TestContainers в тих Java-проєктах, де ви хочете використовувати інтеграційні тести і зовнішні сервери (включаючи і бази даних).
28 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів