Спрощуємо тести за допомогою Junit Pioneer
Всім привіт. Я Сергій Моренець, розробник, тренер, викладач, спікер і технічний письменник, хочу розповісти вам про бібліотеку Junit Pioneer, яка позбавляє вас рутинної роботи і надає вам корисні фітчі, яких немає в JUnit. У цій статті я опишу її особливості, розповім про досвід застосування у наших проектах, а також наведу її переваги та недоліки. Сподіваюся, що ця стаття буде корисною для всіх, хто хоче більше дізнатися про сучасні технології у світі Java-індустрії.
Що таке Junit Pioneer?
Зараз бібліотека Junit — не лише найпопулярніша для написання автоматизованих тестів для Java-спільноти, а й одна з найпопулярніших Java-бібліотек загалом. У той же час, її можливостей все ж таки не вистачає для реалізації всіх можливих сценаріїв роботи, тому ви можете зустріти так звані extension packs (розширення) для неї, один з яких — Junit Pioneer.
Це невеликий проєкт, який розпочала у
- SystemPropertiesExtension
- DefaultLocaleExtension
- 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 (причому
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 розтягнувся вже на два роки. І як пишуть самі розробники, вони зараз мають інші життєві пріоритети.
8 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарівА зачем юзать 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 как минимум :D5-й.
Это как раз по
ото дивись — ти мене заставив в доку лізти.
а міг би сам чекнути ж
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 проход или нет?
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); } }