Спрощуємо тести за допомогою Junit Pioneer

💡 Усі статті, обговорення, новини про тестування — в одному місці. Приєднуйтесь до QA спільноти!

Всім привіт. Я Сергій Моренець, розробник, тренер, викладач, спікер і технічний письменник, хочу розповісти вам про бібліотеку Junit Pioneer, яка позбавляє вас рутинної роботи і надає вам корисні фітчі, яких немає в JUnit. У цій статті я опишу її особливості, розповім про досвід застосування у наших проектах, а також наведу її переваги та недоліки. Сподіваюся, що ця стаття буде корисною для всіх, хто хоче більше дізнатися про сучасні технології у світі Java-індустрії.

Що таке Junit Pioneer?

Зараз бібліотека Junit — не лише найпопулярніша для написання автоматизованих тестів для Java-спільноти, а й одна з найпопулярніших Java-бібліотек загалом. У той же час, її можливостей все ж таки не вистачає для реалізації всіх можливих сценаріїв роботи, тому ви можете зустріти так звані extension packs (розширення) для неї, один з яких — Junit Pioneer.

Це невеликий проєкт, який розпочала у 2017-му році (до речі, рік виходу Junit 5) група ентузіастів на чолі з угорським програмістом Міхалі Вергасом. Про його популярність можна судити за тим фактом, що в Junit 6.1, яка вийшла в травні цього року, однією з головних змін стало портування кількох розширень із Junit Pioneer:

  1. SystemPropertiesExtension
  2. DefaultLocaleExtension
  3. DefaultTimeZoneExtension

Навіщо потрібні ці розширення? Якщо ви досить давно працюєте в ІТ, то, швидше за все, зіткнулися з таким завданням, як зміна системних властивостей для певних тестів. При цьому потрібно не тільки змінити ці властивості, а й відновлювати їх після роботи вашого тесту для того, щоб не поламати всю збірку. А ще є проблема з паралельним виконанням та thread-safety. Зазвичай це реалізується так:

Properties properties;

@BeforeEach
void setup() {
       properties = System.getProperties();
}
@AfterEach
void tearDown() {
       System.setProperties(properties);
}
@Test
void change_success() {
        System.setProperty("token", "123");

Такий підхід є імперативним і призводить до того, що код тесту поєднується з інфраструктурним кодом, погіршуючи читабельність. Junit Pioneer, а тепер і сам Junit пропонують декларативний підхід:

@Test
@SetSystemProperty(key = "token", value = "123")
void change_success() {

Тут перед запуском тесту системна властивість змінюється, а після завершення відновлюється. Крім того, тепер можна для будь-якого тесту очистити чи відновити системні властивості.:

@Test
@ClearSystemProperty(key = "token")
void change_success() {

Що робити, якщо потрібно змінити системні властивості для кожного тесту? Було б помилкою намагатися реалізувати це завдання за допомогою @BeforeEach методів, оскільки це не підтримується:

@BeforeEach
@SetSystemProperty(key = "API_TOKEN", value = "123")
void setup() {
}

Натомість потрібно помістити анотацію на сам тестовий клас:

@SetSystemProperty(key = "API_TOKEN", value = "123")
public class CommonUtilTest {

Тут дуже важливо те, що якою б бібліотекою ви не користувалися (Junit або Junit Pioneer), якщо на тестах зустрічаються подібні анотації, вони завжди виконуються послідовно, позбавляючи вас від помилок, пов’язаних з паралельним виконанням. Це стосується і випадку, коли у вас вже є існуючі тести, які звертаються до системних властивостей, але не використовують це розширення. У такому випадку є спеціальні анотації @ReadSystemProperty та @WriteSystemProperty, наявність яких також призводить до послідовного виконання тестів:

@Test
@ReadsSystemProperty
void change_success() {
var token = System.getProperty("API_TOKEN");

Але системними властивостями ситуація не обмежується. У Junit Pioneer можна аналогічним чином керувати та змінними оточення:

@Test
@SetEnvironmentVariable(key = "API_TOKEN", value = "123")
void change_success() {

Ще одне часте завдання — зміна поточної локалі:

@Test
@DefaultLocale("fr-FR")
void change_success() {
var locale = Locale.getDefault(); //fr-FR

або часового поясу:

@Test
@DefaultTimeZone("GMT+2″)
void change_success() {
var locale = TimeZone.getDefault(); //GMT+2

Розширення для вхідних аргументів

У Junit 5 з’явилися параметризовані тести:

@ParameterizedTest
@ValueSource(ints = { 1, 2, 3, 4, 5 })
void update_success(int value) {

Але якщо вам потрібні числа з безперервного інтервалу (range source), то у Junit вам не обійтися без окремого методу:

@ParameterizedTest
@MethodSource("getValues")
void update_success(int value) {
      System.out.println(value);
}
static Stream<Arguments> getValues() {
      return IntStream.range(1, 6).boxed().map(Arguments::of);
}

А ось у Junit Pioneer є корисні анотації типу @IntRangeSource, які більш елегантно вирішують це завдання:

@ParameterizedTest
@IntRangeSource(from = 1, to = 5, closed = true)
void update_success(int value) {

Ще одна корисна фітча — це підтримка JSON як формат вхідних аргументів тесту. Якщо у вас є клас:

@Data
class Coordinate {
    private int x;
    private int y;
}

Те часто тест починається з ініціалізації об’єкта для передачі в тестовий метод:

Coordinate coordinate = new Coordinate();
coordinate.setX(10);
coordinate.setY(20);

Якщо всі значення тут статичні і не обчислюються в runtime, ви можете передати екземпляр в тест прямо з JSON рядка:

@ParameterizedTest
@JsonSource("""
{"x":1, "y" : 2}
""")
void update_success(Coordinate coordinate) {

Junit Pioneer позбавляє вас рутинної роботи з десеріалізації. Правда, вам обов’язково потрібно мати бібліотеку Jackson (причому 2-ї версії) у classpath, інакше вам повернеться помилка:

Caused by: org.junitpioneer.jupiter.json.NoJsonParserConfiguredException: No JSON parsing library found. Make sure a supported JSON parser (currently only Jackson) is on your test class/module path. For more information, see junit-pioneer.org/...​ocs/json-argument-source

Якщо ж у вас JSON зберігається в окремих файлах (швидше за все, так і є), то його можна просто передати, вказавши назву файлу:

@ParameterizedTest
@JsonClasspathSource("coordindate.json")
void update_success(Coordinate coordinate) {

Java records вже підтримуються:

record Coordinate(int x, int y) {
}

Це дозволяє декларативно передавати аргументи-об’єкти у тестах, не прив’язуючи їх до коду. І, зрештою, зберігати в одному місці всі дані для ініціалізації, що спрощує їх аналіз та зміну.

Ну і звичайно, не можна не згадати про одну з найвідоміших фітч Junit Pioneer — декартів добуток множин (Cartesian Product):

@CartesianTest
void update_success(@Values(ints = { 1, 2 }) int x, @Values(ints = { 3, 4 }) int y) {

Іноді потрібно отримати всі перестановки деяких множин чисел. Ось тут вам і допоможе @ CartesianTest, яка видасть вам комбінації на вході:

  • 1, 3
  • 1, 4
  • 2, 3
  • 2, 4

Цікаво, що коли в 2018 році користувачі попросили у команди Junit додати цю фітчу до їхньої бібліотеки, то після багаторічних дебатів вердикт був наступний — використовуйте Junit Pioneer, оскільки він широко поширений і повсюдно використовується

Обробка помилок

У Junit 5 з’явилося чимало анотацій для вимкнення тестів. Наприклад, @Disabled призводить до того, що тест не буде запущено. А різні анотації типу @DisabledIfEnvironmentVariable перевіряють різні умови:

  • Системні властивості
  • Змінні оточення
  • Версію JVM
  • Назва ОС
  • Запуск у GraalVM native image

Але зрозуміло, що всі можливі умови передбачити неможливо. Тому ще в Junit 5.7 з’явилася анотація @DisabledIf, яка дозволяла динамічно визначати умови для запуску або незапуску тесту:

@Test
@DisabledIf("isDisabled")
void update_success() {
}
static boolean isDisabled() {
        return DayOfWeek.from(LocalDate.now()) == DayOfWeek.SUNDAY;
}

У Junit Pioneer додали свою анотацію @DisabledUntil, яка дозволяє вимкнути тест до певної дати:

@Test
@DisabledUntil(date = "2027-05-07", reason = "Disabled until next release date")
void update_success() {

А анотація @DisabledIfTestFails не запускає наступних тестів, якщо якийсь попередній уже впав. Зазвичай це роблять, якщо тести пов’язані між собою, наприклад, за допомогою анотації @Order:

@DisableIfTestFails
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
public class OrderRepositoryTest {
@Test
@Order(1)
void save_success() {
     // IMPLEMENTATION
}
@Test
@Order(2)
void delete_success() {
      // IMPLEMENTATION
}

Перевага такого підходу в тому, що наступні тести не будуть запущені (оскільки очевидно, що вони теж впадуть) і будуть просто відзначені як disabled.

Ще одна корисна анотація — @ExpectedToFail. Іноді буває так, що тести починають несподівано падати, наприклад через помилки в коді. Щоб зібрати збірку, їх просто позначають як @Disabled, а виправлення помилок відкладають потім. Але при цьому інші розробники залишаються у невіданні про причину появи цієї анотації. І коли помилка виправляється, тест можна завжди залишитися disabled. Якщо помітити тест як @ExpectedToFail, то такий тест буде все ж таки запущений, і якщо він впаде, то він буде вказаний як aborted:

@Test
@ExpectedToFail("Should fail because of REQ-5000")
void update_success() {

А от якщо тест не впаде, то тоді він буде помічений як той, що не пройшов:

org.opentest4j.AssertionFailedError: Test marked as ’expected to fail’ succeeded; remove @ExpectedToFail from it
 at org.junitpioneer.jupiter.ExpectedToFailExtension.invokeAndInvertResult(ExpectedToFailExtension.java:64)

Зазвичай це говорить про те, що помилка в коді була виправлена, потрібно поміняти @ExpectedToFail просто на @Test.

Якщо ви пишете інтеграційні тести, які звертаються до інших сервісів або зовнішніх систем, то тут припустимі помилки, пов’язані з тимчасовою відсутністю з’єднання або перевантаження таких систем. У Junit 5 додали анотацію @RepeatedTest, в якій пізніше з’явився атрибут failureThreshold:

@RepeatedTest(value = 5, failureThreshold = 3)
void update_success() {

Таким чином, тут буде максимум 5 спроб повтору запуску тесту, але при цьому до трьох невдач. У Junit Pioneer є своя анотація @RetryingTest, яка дозволяє гнучкіше настроїти повторні спроби:

@RetryingTest(maxAttempts = 5, onExceptions = IOException.class, suspendForMs = 1000)
void update_success() {

Використовуємо Junit Pioneer

Спробуємо використати Junit Pioneer та його фітчі. Додати його в проект досить просто:

<dependency>
     <groupId>org.junit-pioneer</groupId>
     <artifactId>junit-pioneer</artifactId>
     <version>2.3.0</version>
     <scope>test</scope>
</dependency>

Зараз у нас є такий тест у MongoSyncPaymentRepositoryTest:

@Inject
PaymentRepository paymentRepository;
@Test
void save_validPayment_success() {
    Payment payment = new Payment();
   payment.setAmount(100);
   payment.setPaymentType(PaymentType.CHECKOUT);
   payment.setSuccess(true);
   payment.setOrderId(11);
   payment.setCreatedBy("100");
   Payment payment2 = paymentRepository.save(payment);
   assertNotNull(payment2.getId());
   Payment payment3 = paymentRepository.findById(payment.getId()).get();
   assertNotNull(payment3.getCreatedAt());
}

Тепер можна створити файл payment.json:

{
«amount»: 100,
«paymentType»: «CHECKOUT»,
«success»: true,
«orderId»: 11,
«createdBy»: «100»
}

І передавати його в тест за допомогою @JsonClasspathSource:

@ParameterizedTest
@JsonClasspathSource("files/payment.json")
void save_validPayment_success(Payment payment) {
     Payment payment2 = paymentRepository.save(payment);
     assertNotNull(payment2.getId());
     Payment payment3 = paymentRepository.findById(payment.getId()).get();
     assertNotNull(payment3.getCreatedAt());
}

Висновки

Підсумовуючи, можна відзначити, що бібліотека Junit Pioneer позбавляє нас рутинної роботи і додає функціональність, якої немає в самому Junit. Один з мінусів (а можливо і майбутніх фітч Pioneer) — це те, що всі значення в атрибутах є константами. А іноді потрібно динамічно генерувати значення для них. Ще один мінус — остання версія 2.3.0 вийшла ще у жовтні 2024 року, а вихід 3.0 розтягнувся вже на два роки. І як пишуть самі розробники, вони зараз мають інші життєві пріоритети.

👍ПодобаєтьсяСподобалось5
До обраногоВ обраному0
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

А зачем юзать JUnit для системных тестов, если есть TestNG, который намного лучше и удобнее именно для тестировщика?
1. Есть нормальный сетап @BeforeSuite / AfterSuite; чего в принципе нет у JUnit.
2. Нормальная работа с кол-вом потоков. Сказал — 5, будет работать именно 5, а не «как JVM наконфиженв».
3. Не столь принципиально, с учётом Allure, но всё же... Репортинг у JUnit — отвратный. Он не показывает Passed, только Failed / Skipped, что приводит к необходимости устного счета :)
4. У JUnit нет возможности сконфижить parallel = all, чтоб получить максимальную параллельность выполнения, только parallel = class или parallel = method.

JUnit всегда был рассчитан именно на запуск юнит-тестов, которые жёстко привязаны к классу. Для системных тестов, которые зачастую привязаны к логике аппликухи, а не к её структуре, он не является лучшим решением ИМХО.

це ти про 4 версію?
в 5 все є, крім першого пункту (якщо треба саме анотації)

4 — это вообще полный писец, там претензий будет пунктов 10 как минимум :D
Это как раз по 5-й.

ото дивись — ти мене заставив в доку лізти.
а міг би сам чекнути ж
docs.junit.org/...​ecution-config-properties

Ага, видел. И юзал.
junit.jupiter.execution.parallel.mode.default и
junit.jupiter.execution.parallel.mode.classes.default

Теперь задачка на догадлиаость.
У тебя скажем есть как-то наконфиженный threadPool = 10 (как это сделать — отдельный квест, ибо невозможно, ну да ладно). И есть скажем 5 классов.
Класс 1 — 1 тест
Класс 2 — 1 тест
Класс 3 — 3 теста
Класс 4 — 2 теста
Класс 5 — 3 теста.
Если ты поставишь оба параметра выше в concurrent — получится прогнать их все в 1 проход или нет?

junit.jupiter.execution.parallel.enabled = true
junit.jupiter.execution.parallel.mode.default = concurrent
junit.jupiter.execution.parallel.mode.classes.default = concurrent
junit.jupiter.execution.parallel.config.strategy = fixed
junit.jupiter.execution.parallel.config.fixed.parallelism = 10
junit.jupiter.execution.parallel.config.fixed.max-pool-size = 10
[INFO] -------------------------------------------------------
[INFO]  T E S T S
[INFO] -------------------------------------------------------
[INFO] Running parallel.ParallelTestTwoTest
[INFO] Running parallel.ParallelTestOneTest
[INFO] Running parallel.ParallelTestFourTest
[INFO] Running parallel.ParallelTestFiveTest
[INFO] Running parallel.ParallelTestThreeTest
ParallelTestFiveTest.runsFirstTest executed on ForkJoinPool-1-worker-10
ParallelTestThreeTest.runsFirstTest executed on ForkJoinPool-1-worker-8
ParallelTestOneTest.runsSingleTest executed on ForkJoinPool-1-worker-4
ParallelTestTwoTest.runsSingleTest executed on ForkJoinPool-1-worker-1
ParallelTestThreeTest.runsSecondTest executed on ForkJoinPool-1-worker-5
ParallelTestThreeTest.runsThirdTest executed on ForkJoinPool-1-worker-6
ParallelTestFourTest.runsSecondTest executed on ForkJoinPool-1-worker-3
ParallelTestFiveTest.runsThirdTest executed on ForkJoinPool-1-worker-9
ParallelTestFourTest.runsFirstTest executed on ForkJoinPool-1-worker-7
ParallelTestFiveTest.runsSecondTest executed on ForkJoinPool-1-worker-2
[INFO] Tests run: 10, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.273 s -- in parallel.ParallelTestFourTest
package parallel;

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertTrue;

class ParallelTestFourTest {

    @Test
    void runsFirstTest() throws InterruptedException {
        exercise("ParallelTestFourTest.runsFirstTest");
    }

    @Test
    void runsSecondTest() throws InterruptedException {
        exercise("ParallelTestFourTest.runsSecondTest");
    }

    private void exercise(String testName) throws InterruptedException {
        Thread.sleep(200);
        System.out.printf("%s executed on %s%n", testName, Thread.currentThread().getName());
        assertTrue(true);
    }
}

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