Як тестувати Java-додатки, що використовують Mongo

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

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

👍НравитсяПонравилось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

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

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

Все стає в рази складніше і важче.

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

Я ж тільки покритикувати.

Особисто я би надав перевагу навіть банальному, виділеному серверу із розвернутою Монгою.

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

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

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

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

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

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

Я би з цим поспорив. Реанімувати СУБД на сервері любий школьник може, а розібратися в сучасній жаба-лапші не так то просто. Без стековерфлоу і так вже нереально працювати зі всякими спрінгбутами.
Купа проектів, з падаючими тестами, які ніхто не може починити (і де щось примітивне поправити виливається в багатогодинну епопею) теж грають не на користь цієї моди.

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

Не зустрічав ще нічого, щоб можна було просто «підключити» і забути. Все вимагає підтримки, все протікає.

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

Будь який рядок коду вимагає підтримки. Навіть коментар, який, здавалося би, на компіляцію та бінарник навіть не впливає.

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

От ти пишеш

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

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

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

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

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

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

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

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

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

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

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

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