Меня зовут Александр Чекунков, я — Android‑разработчик в СБЕРе. Занимаюсь разработкой CSI‑опросов в мобильном приложении «СберБанк Онлайн». Я несу ответственность за функциональность, которую используют бизнес‑команды для оценки удовлетворённости клиентов.

Не так давно, в нашем проекте мы приняли решение перейти с JUnit4 на JUnit5. Эта статья поможет разобраться в причинах выбора JUnit5 в качестве целевого решения для написания unit‑тестов. Я постараюсь ответить на ключевые вопросы: что делает JUnit5 предпочтительным выбором для современных проектов, и какие конкретные улучшения и нововведения он предлагает?

Основные отличия и преимущества

Написание тестов на JUnit5 очень похоже на работу с JUnit4, но есть серьезные отличия, которые будут рассмотрены ниже.

Если у вас на проекте используется JUnit4 и много тестов написано именно на старой библиотеке, просто переехать не получится — необходимо переписывать и конвертировать каждый тест под новую версию фреймворка.

Важное примечание! Для запуска тестов JUnit4 вместе с тестами JUnit5 можно использовать библиотеку Vintage, но в рамках статьи рассматривать её не будем.

Пакет

Первое и самое явное отличие — новая версия библиотеки использует другой пакет для работы с тестами:

import org.junit.jupiter.* // JUnit5
import org.junit.* // JUnit4

Модульная структура

JUnit5 разделён на модули (JUnit Jupiter, JUnit Platform, JUnit Vintage), что обеспечивает более гибкую архитектуру и возможность использовать только необходимые части библиотеки. Более подробно про каждый модуль можно прочитать здесь.

Аннотации

JUnit5 использует новый набор аннотаций и расширяемых моделей, которые обеспечивают более гибкую конфигурацию тестовых сценариев:

JUnit4

JUnit5

Назначение

@Before

@BeforeEach

используется для обозначения метода, который будет выполняться перед каждым тестом

@After

@AfterEach

используется для обозначения метода, который будет выполняться после каждого теста

@BeforeClass

@BeforeAll

используется для обозначения метода, который будет выполняться один раз перед запуском всех тестов

@AfterClass

@AfterAll

используется для обозначения метода, который будет выполняться один раз после всех тестов

@Ignore

@Disabled

используется для временного отключения выполнения тестового метода или класса,

@Category

@Tag

помечает тесты тегами, что упрощает группировку и выборочное выполнение тестов по категориям

@Rule

@ExtendWith

используется для регистрации расширений для аннотированного тестового класса, метода, параметра

@ClassRule

@RegisterExtension

используется для регистрации расширений (extensions) в тестовом классе

-

@DisplayName

задаёт читаемое имя для теста, которое будет отображаться в отчетах о выполнении тестов

-

@Timeout

устанавливает максимальное время выполнения теста, после чего он будет автоматически прерван

-

@Nested

используется для создания вложенных классов для логической группировки тестов

-

@DisabledIf

позволяет динамически отключать тесты на основе определенных условий пользователя

-

@EnabledIf

позволяет динамически включать тесты на основе определенных условий пользователя

Более подробно со всеми аннотациями в JUnit5 можно ознакомиться здесь.

Поддержка Java8 и выше

JUnit5 полностью совместим с Java 8 и более поздними версиями. Это позволяет использовать новые возможности языка, такие как лямбда‑выражения и функциональные интерфейсы, для более удобного написания тестов.

Новые Assertions

JUnit5 ввёл ряд новых утверждений, расширяя возможности проверки условий в тестах.

// assertAll() - позволяет группировать несколько утверждений в один блок.

@Test
fun `assertAll test`() {
    val person = Person(name = "Alexandr", age = 22)

    assertAll(
        "Assertions of person",
        { assertEquals("Alexandr", person.name) },
        { assertEquals(22, person.age) }
    )
}
// assertThrows() - проверяет, что выполнение блока кода 
// выбрасывает определенное исключение.

@Test
fun `assertThrows test`() {
    val expectedMessage = "For input string: \"twenty two\""

    assertThrows<NumberFormatException> {
        "twenty two".toInt()
    }.also { exception ->
        assertThat(exception.message).isEqualTo(expectedMessage)
    }
}

// На самом деле тестирование исключение есть и в JUnit4,
// но реализованно это немного по-другому:
@Test(expected = NumberFormatException::class)
fun `assertThrows test in JUni4`() {
	val testMessage = "twenty two"
    val expected = 22     
    
	val actual = testMessage.toInt()
}
// assertTimeout() - проверяет, что выполнение блока кода 
// завершается в течение указанного времени.

@Test
fun `assertTimeout test`() {
    assertTimeout(Duration.ofMillis(100)) {
        // Код, который должен выполниться за 100 миллисекунд
        testRepository.getInfo()
    }
}
// assertTimeoutPreemptively() - предоставляет аналогичную функциональность 
// как assertTimeout(), но прерывает выполнение теста, если 
// время ожидания превышено.

@Test
fun `assertTimeoutPreemptively test`() {
    assertTimeoutPreemptively(Duration.ofMillis(10)) {
        // Код, который должен прервать выполнение, так как
        // он занимает больше 10 миллисекунд (25 миллисекунд) 
        testRepository.fetchDataInTwentyFiveMillis()
    }
}
// assertDoesNotThrow() - проверяет, что выполнение блока кода 
// не выбрасывает исключение.

@Test
fun `assertDoesNotThrow test`() {
    assertDoesNotThrow {
        "22".toInt()
    }
}

Более детально все Assertions в JUnit5 описаны здесь.

Параметризованные тесты

JUnit5 предоставляет встроенную поддержку параметризованных тестов, что позволяет создавать и запускать тесты с различными входными данными. Это делает код тестов более читаемым и обеспечивает более полное покрытие различных сценариев.

Параметризированный тест — это способ написания тестов, который позволяет запускать один и тот же тест с разными входными данными. Про параметризованные тесты я рассказывал более подробно в этой статье.

internal class CalculatorTest {

    private val calculator = Calculator()
    
    @ParameterizedTest
    @CsvSource(
        "1, 2, 3",
        "-1, 1, 0",
        "0, 0, 0",
        "10, -5, 5"
    )
    fun `parameterized test`(first: Int, second: Int, expected: Int) {
        val actual = calculator.calculateTwoNumbers(first, second)

        assertThat(actual).isEqualTo(expected)
    }   
}

Динамические тесты

JUnit5 предоставляет поддержку динамических тестов, которые позволяют создавать тесты во время выполнения программы, вместо того чтобы определять их статически в коде. Такой сценарий используют, когда количество тестов зависит от данных, внешних условий или других факторов, которые известны только во время выполнения.

internal class CalculatorTest {
 
  private val calculator = Calculator()
  
  @TestFactory
  fun `dynamic test`(): List<DynamicTest> {
      val testData = listOf(
          Triple(2, 2, 4),
          Triple(-1, 1, 0),
          Triple(5, -6, -1)
      )
  
      return testData.map { (first, second, expected) ->
          DynamicTest.dynamicTest("Test $first + $second = $expected") {
  
              val actual = calculator.calculateTwoNumbers(first, second)
  
              Truth.assertThat(actual).isEqualTo(expected)
          }
      }
  }
}

Основное отличие динамических тестов от параметризованных состоит в том, что параметризованные тесты используют заранее определённые наборы данных, а динамические тесты создаются программно во время их выполнения.

Больше информации о динамических тестах здесь.

Вложенные тесты

Ещё одной новой фичей JUnit5 стала поддержка вложенных тестов, которые позволяют логически группировать тестовые методы внутри других тестовых классов. Это полезно для организации тестов в иерархическую структуру, что облегчает понимание и поддержку кода.

internal class CalculatorTest {

    private val calculator = Calculator()

    @Nested
    inner class MultiplyCalculatorTest {

        @Test
        fun `3 multiply 3 equals 9`() {
            val firstNumber = 3.0
            val secondNumber = 3.0
            val expected = 9.0

            val actual = calculator.multiplyTwoNumbers(firstNumber, secondNumber)

            assertThat(actual).isEqualTo(expected)
        }

        @Test
        fun `2 multiply 2 not equals 5`() {
            val firstNumber = 2.0
            val secondNumber = 2.0
            val expected = 5.0

            val actual = calculator.multiplyTwoNumbers(firstNumber, secondNumber)

            assertThat(actual).isNotEqualTo(expected)
        }
    }

    @Nested
    inner class DivideCalculatorTest {

        @Test
        fun `12 divide 4 equals 3`() {
            val firstNumber = 12.0
            val secondNumber = 4.0
            val expected = 3.0

            val actual = calculator.divideTwoNumbers(firstNumber, secondNumber)

            assertThat(actual).isEqualTo(expected)
        }

        @Test
        fun `cannot divide by zero`() {
            val firstNumber = 12.0
            val secondNumber = 0.0
            val expectedMessage = "Делить на ноль нельзя!"

            assertThrows<ArithmeticException> {
                calculator.divideTwoNumbers(firstNumber, secondNumber)
            }.also { exception ->
                assertThat(exception.message).isEqualTo(expectedMessage)
            }
        }
    }
}

При запуске тестов библиотека будет выполнять тесты из каждого вложенного класса как отдельные группы тестов. Это улучшает структурирование и читаемость тестового кода.

Условное выполнение тестов

Также в JUnit5 добавили возможность условного выполнения тестов, что позволяет определять, должен ли конкретный тест быть выполнен в зависимости от определённых условий или окружения. Это особенно полезно, когда необходимо исключить выполнение тестов в определённых сценариях или на определённых платформах.

internal class СonditionalExecutionTest {

    // Тест запуститься только на операционной системе MacOS
    @Test
    @EnabledOnOs(org.junit.jupiter.api.condition.OS.MAC)
    fun `test for mac os`() {
      
    }
    
    // Тест запуститься только на версии Java 11
    @Test
    @EnabledOnJre(JRE.JAVA_11)
    fun `test for Java 11`() {
 
    }

	// Тест запуститься только при выполнении какого-то кастомного условия
	// ВАЖНО ОТМЕТИТЬ: в аннотациии необходимо указывать полный путь к методу
    @Test
    @EnabledIf("com.example.СonditionalExecutionTest#isCustomCondition")
    fun `test with custom condition`() {
    
    }
    
    private fun isCustomCondition(): Boolean = false
}

Подробнее с условным выполнением тестов можно ознакомиться здесь.

Скорость работы

Отдельно хочется отметить, что в JUnit5 были внесены некоторые улучшения в производительность: улучшили параллельное выполнение тестов, добавили новые возможности расширений и более гибкую архитектуру.

Для проверки скорости работы я произвел небольшие замеры, а именно запустил тесты в двух модулях, которые отвечают за функциональность оценки удовлетворённости клиентов в «СберБанк Онлайн».

Кол-во тестов

Покрытие кода

JUnit4 (среднее)

JUnit5 (среднее)

Feature-модуль

160

95,6%

3 сек. 597 мс.

2 сек. 995 мс.

Common-модуль

132

96%

3 сек. 463 мс.

3 сек. 045 мс.

Результаты каждого замера для JUnit4 и JUnit5 отображены на двух графиках ниже.

График скорости выполнения тестов в Feature-модуле
График скорости выполнения тестов в Feature-модуле
График скорости выполнения тестов в Common-модуле
График скорости выполнения тестов в Common-модуле

Исходя из данных в графиках и таблице, можно сделать вывод, что в среднем JUnit5 увеличивает скорость выполнения тестов на 15%.

Конечно, скорость работы JUnit4 и JUnit5 зависит от большого количества факторов: структуры тестов, конфигурации среды выполнения, используемых расширений и многого другого. На просторах интернета я нашёл обсуждения, в которых рассматривалось, что новая версия библиотеки отрабатывает медленнее, чем старая, поэтому, если хотите более детально разобраться в скорости работы библиотек, лучше провести замеры на своих проектах. Про скорость и проблемы выполнения тестов можно почитать здесь и здесь.

Поддержка других JVM-языков

И наконец, JUnit5 разработан с учётом возможности использования других языков программирования, совместимых с JVM — Java, Kotlin, Groovy, Scala. Это делает библиотеку более привлекательной для разработчиков, использующих Kotlin или другие языки JVM.


Все примеры, которые были рассмотрены выше, можно найти и изучить в этом репозитории.

В целом, JUnit5 является значительным улучшением по сравнению с JUnit4, предоставляя более гибкий набор инструментов для написания и запуска unit‑тестов.

Модульная архитектура, поддержка Java 8 и более новых версий, новые аннотации, параметризованные, вложенные и динамические тесты делают JUnit5 предпочтительным выбором для разработчиков команды СБЕРа.

Переход на JUnit5 не только облегчает тестирование, но и значительно повышает его эффективность и надёжность, что в конечном итоге способствует улучшению качества кода и ускорению разработки.

Если вы пишете свой pet‑проект или работаете над большим приложением, попробуйте JUnit5 сами и убедитесь, что работа с ним значительно упрощает и улучшает процесс тестирования.

Комментарии (4)


  1. iliks84
    20.08.2024 18:30
    +1

    Если я не ошибаюсь, в родных асертах junit отсутствует котлиновская специфика, например, корутины. Почему тогда в дополнение к собственно junit не пользуетесь assertk, где это есть? И какую библиотеку для моков используете, mockk или что-то другое?


    1. cdrChknkv Автор
      20.08.2024 18:30

      Добрый день! Для создания мокков мы используем Mockk, а для написания ассертов - Truth.
      Более подробно про все библиотеки рассказывал в этой статье.


  1. boldueen
    20.08.2024 18:30
    +1

    Насколько был сложен и сколько по времени занял переход с 4 на 5 версию у вас?


    1. cdrChknkv Автор
      20.08.2024 18:30

      Добрый день! Переход с JUnit4 на JUnit5 не потребовал большого количества времени и ресурсов. В целом, переезд был очень плавным и комфортным.