Як тестувати 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 — своя.

Зрозуміло, що ІТ-індустрія завжди знаходиться в пошуку нових рішень. Ще в середині 2010-х років, після успішного сприйняття Docker і контейнерів, розробники задумалися про те, а чому б не використати для інтеграційного тестування Docker? Адже практично для будь-якого продукту є відповідний Docker image, який можна завантажити і запустити за допомогою Docker API. Більш того, ви можете вибрати і потрібну версію (тег) продукту. Тому тестування могло б йти за такою схемою:

  • Конфігурація і запуск необхідних 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-проєктах, де ви хочете використовувати інтеграційні тести і зовнішні сервери (включаючи і бази даних).

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті

👍ПодобаєтьсяСподобалось3
До обраногоВ обраному4
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Мы активно используем testcontainers в функциональных тестах. В докере можно использовать не только MongoDB — SQL DB (и не только open source), messaging (Kafka, RabbitMQ, ZeroMQ) и куча другого инфраструктурного софта.

Я бы выделил следующие основные преимущества использования testcontainers в отличии от чистого docker/docker-compose или ручного поднятия серверов:
* возможность использовать динамические (свободные) порты при поднятии инфраструктуры, т.е. не будет геморроя от конфигурации портов
* тесты не зависят от одного и того же сервера, поэтому их можно смело параллельно выполнять на одном хосте (дев среда, CI)
* тесты можно значительно упростить, например, можно перед каждым тестом удалять данные предыдущего теста — это ни как не повлияет на другие параллельные выполнения тестов
* есть возможность выполнять одни и те же тесты для разных версий инфраструктуры (DB, messaging, etc.)
* минимальные требования к софту среды выполнения — JDK, docker (относительно свежей версии), build system (если надо)
* благодаря docker вы не зависите от среды выполнения/разработки — Linux/Windows/Mac

Ну и минусы (как же без них):
* Время выполнения тестов может значительно увеличится. Например, в ряде случаев может быть ситуация, когда у вас для отдельных тест кейсов или отдельного теста каждый раз подымается/останавливается инфраструктура в докере. Это можно избежать, использовать напрямую docker/docker-compose, но в этом случаи вы теряете преимущество «динамические порта» и «параллельное выполнение на одном хосте».
* Требования к железу значительно увеличивается. 8Гб ОЗУ — с этим нельзя будет работать. 16Гб — может быть приемлемым, если инфраструктура относительно небольшая. Для 100% комфортной работы надо 32Гб.
* После окончания тестов вся инфраструктура останавливается, это может быть минусом при разборе проблем с тестами, так же нет возможности запустить сервер/сервис в обычном режиме. Как вариант, можно использовать напрямую docker/docker-compose или надо дополнительно девелопить эту возможность с использованием testcontainers
* testcontainers, в зависимости от версии, может быть не совсем стабильным

А що поганого в тому, що можна запустити Docker контейнер з додатка? Адже це запуск в тестах, не в production коді.

Ваші пропозиції щодо вирішення задачі яку означив автор?

Як ми будете масштабувати це рішення на команду? Хто буде пильнувати що виділений сервер не впав? Хто буде його апдейтити? Хто буде чистити бази?

На всі ці питання дає відповідь тестконтейнер який ефемерний (або напівефемерний).

Значно менше ніж девопс який буде пасти сервери.
Докер поставив на локалхост і все.

Апдейти контейнерів може робити одна людина комітом в репу.
Ефемерність і тд з коробки.

девелопер + Докер >>>>> девопс + база

Для чого там розбиратись? Тестконтейнери досить просто піднімаються. Один раз підключив і все працює.

Про яку підтримку ти говориш у даному випадку з тестконтейнером? Нічого там не потрібно підтримувати. Час від часу версію оновити.

Ти загалом пишеш правильні речі і задаеш правильні питання але ти не бачиш про що каже Вячеслав — про те шо постійно зростає складність сетапу і що ті хто це адвокатують або замовчують це, або не розуміють косту постійного інкременту складності стеку.

От ти пишеш

Докер поставив на локалхост і все.

Йой, так можна будь-який скілл чи технологію звести до «%name% поставив і все» але практика свідчить що череда таких %name% росте з кожним проектом і в якийсь момент ти розумієш, що попав на проект, вивчити тестове середовище якого займає півтора місяця, або інше, тест середовище настільки складне, що фактично стале неробочим, і люди перестали звертати увагу на падіння тестів, бо «там шось %name% перестав працювати, колись потім пофіксять».

Давай в твоему стилі я опишу як навчитись джаві
1) ждк поставив і все
2) ідею поставив і все
3) хеловорлд написав і все
4) мавен поставив і все
5) все інше поставив і все
6) сіньором став і все
півгодинки і вжух ти сіньор

апд.
от нижче Вячеслав майже те саме написав.

але ти не бачиш про що каже Вячеслав

Я бачу що він каже, ба більше я й сам так вважаю.

Готовий вислухати ваші пропозиції щодо спрощення сетапу. Немає? Тоді давайте погодимося що докер на локалхості через прошарок testcontainers це менше зло ніж адмін з сервером монги у каптьорці.

я таки вважаю що «адмін з сервером монги» менше зло.
якщо порівнювати складність, це простіше.

І як мінімум потратити час щоб зробити генерацію тестові даних так щоб не заважати іншим людям в команді.
І як чистити дані якщо тест вилетів посередині?
Що робити коли сервер впав? Чекати адміна?
Проблем з тим явно більше.

С тем,что тяжелее соглашусь. Но почему сложнее? Например, реализация функциноальных тестов, которые бы могли параллельно выполняться с одной БД будет в разы сложнее, чем тупо очищать БД перед каждым тестом. Я уже не говорю о сложностях установки куча инфраструктурного софта — БД (возмжно несколько штук), messaging (тоже может бьть разные сервера, например, Kafka, RabbitMQ), разные FTP/SFTP сервера и куча другого г... Если же вы планируете это ставить на комп разработчика, то в итоге вы получаете в куча неиспользуемого софта большую часть времени разработки, а этот софт кушает ресурсы, начиная от памяти, заканчивая ЦПУ. Так чем же сложнее? С тем, что надо подучить основы новой технологии?

Сложность понятие растяжимое. Например, вы можете реализовать 40% google guava, что бы не тянуть лишнюю зависимость и надеется, что надежность, поддержка и изучение вашего кода для ньюкамеров будет простым и гладким. С таким успехом надо отказаться от спринга, JEE и куча другого софта — все можно и самому реализовать на чистом JDK. А с точки зрения инфраструктуры и вашего недовольства тестов — можно на них вообще забить и нанять ораву мануальных тестеров, админов, переплачивать за юзание облоков или покупать дорогое железо. Плюс писать тонны мануалов о настройки конфигурации среды разработки и не только ее. Регулярно забивать на работу, когда что-то не доступно или упало или «что там у этих админов опять случилось?». Но тогда возникает вопрос в вашей профессиональности и полезности проекту/бизнесу.

Подытожу:
* на проекте лучше использовать зависимости, которые являются стандартом дефакто — это проверенные и надежные реализации, использование которых знаеет большинство на рынке труда
* docker был опубликован в марте 2013 года, т.е. это не новая технология, ей уже больше 8 лет
* docker сейчас является неотъемлемой технологией разработки современного большого софта и на рынке труда большинство вменяемых специалистов его использовали как минимум в пет проектах
* testcontainers разрабатывается с 2015 года, т.е. эта технология появилась не вчера и на рынке труда достаточно специалистов, которые его знают и используют

Так что у вас есть выбор, остаться в IT и дальше учить новые технологии, которые часто не новые, а просто обновленные (основы остаются те же) или же свитчнутся не в IT, например, идите в политику, на протяжении уже более 20 лет в Украине мало что поменялось в ней.

Чекаю наступну статтю про те, як в тесті підняти кубернетокластер.

Не понятно зачем? Докера вполне сдотсаточно для использования функциональных тестов. Для полноценных интеграционных нужно юзать реальную выделенную среду. Так что ждать прийдется долго :)

вот беру первую статью — How we approached integration testing in Kubernetes ... Это должно быть интересно девопсам, а не разработчикам, потому что странно пытаться развернуть кластер из 100500 сервисов и куча инфраструктурного софта на локальном компе разработчика. А вот для построения среды выполнения интеграционных тестов — да, может быть очень полезным делом. Конечно, можно захотеть даже в космос полететь, но никто не отменял здравый смысл.

Так це стаття про Монго чи таки про TestContainers?

Стаття про те, якими способами можна тестувати Java-додатки, в яких використовується Mongo. Один з них — TestContainers.

Підписатись на коментарі