В этой статье будут рассмотрены основные возможности платформы JUnit 5 и приведены примеры их использования на Kotlin. Материал ориентирован на новичков в Kotlin и/или JUnit, однако, и более опытные разработчики найдут интересные вещи.
Официальный user guide
Исходный код тестов из этой статьи: GitHub
Перед созданием первого теста укажем в pom.xml зависимость:
Создадим первый тест:
Тест проходит успешно:
Перейдём к обзору основных фич JUnit 5 и различных технических нюансов.
В значении аннотации
Как видно, значение аннотации имеет приоритет перед названием функции:
Аннотация применима и к классу:
Assertion'ы находятся в классе
JUnit включает несколько вариантов проверки ожидаемого и реального значений. В одном из них последним аргументом является сообщение, выводимое в случае ошибки, а в другом — лямбда-выражение, реализующее функциональный интерфейс
Для тестирования групповых assertion'ов предварительно создадим класс
Будут выполнены оба assertion'а:
Более прозрачная по сравнению с JUnit 4 работа с исключениями:
Как и в остальных примерах, всё делается просто:
При этом лямбда-выражение выполняется полностью, даже когда время выполнения уже превысило допустимое. Для того, чтобы тест падал сразу после истечения отведённого времени, нужно использовать метод
Некоторые библиотеки предоставляют более мощные и выразительные средства использования assertion'ов, чем JUnit. В частности, Hamcrest, помимо прочих, предоставляет множество возможностей для проверки массивов и коллекций. Несколько примеров:
Assumption'ы предоставляют возможность выполнения тестов только в случае выполнения определённых условий:
При этом тест с невыполнившимся assumption'ом не падает, а прерывается:
Одной из главных фич JUnit 5 является поддержка data driven тестирования.
Перед генерацией тестов для большей наглядности сделаем класс
Следующий пример сгенерирует пачку тестов для проверки того, что возраст каждого человека не меньше заданного:
Помимо коллекций
Жизненный цикл выполнения динамических тестов отличается от
Параметризованные тесты, как и динамические, позволяют создавать набор тестов на основе одного метода, но делают это отличным от
Код теста, проверяющего, что поступающие на вход даты уже в прошлом:
Значениями аннотации
Можно оставить или исключить определённые константы:
Есть возможность указать метод, который будет использован как источник данных:
В java-коде этот метод должен быть статическим, в Kotlin это достигается его объявлянием в объекте-компаньоне и аннотированием
Число повторений теста указывается следующим образом:
Есть возможность настроить выводимое название теста:
Доступ к информации о текущем тесте и о группе повторяемых тестов можно получить через соответствующие объекты:
JUnit 5 позволяет писать вложенные тесты для большей наглядности и выделения взаимосвязей между ними. Создадим пример, используя класс
Результат будет довольно наглядным:
JUnit 5 довольно прост в использовании и предоставляет множество удобных возможностей для написания тестов. Data driven тестирование с использованием Kotlin обеспечивает удобство в разработке и лаконичность кода.
Спасибо!
Официальный user guide
Исходный код тестов из этой статьи: GitHub
Перед созданием первого теста укажем в pom.xml зависимость:
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<version>5.0.2</version>
<scope>test</scope>
</dependency>
Создадим первый тест:
import org.junit.jupiter.api.Test
class HelloJunit5Test {
@Test
fun `First test`() {
print("Hello, JUnit5!")
}
}
Тест проходит успешно:
Перейдём к обзору основных фич JUnit 5 и различных технических нюансов.
Отображаемое название теста
В значении аннотации
@DisplayName
, как и в названии функции Kotlin, помимо удобочитаемого отображаемого названия теста можно указать спецсимволы и emoji:import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
class HelloJunit5Test {
@DisplayName("\uD83D\uDC4D")
@Test
fun `First test ?°?°)?`() {
print("Hello, JUnit5!")
}
}
Как видно, значение аннотации имеет приоритет перед названием функции:
Аннотация применима и к классу:
@DisplayName("Override class name")
class HelloJunit5Test {
Assertions
Assertion'ы находятся в классе
org.junit.jupiter.Assertions
и являются статическими методами.Базовые assertion'ы
JUnit включает несколько вариантов проверки ожидаемого и реального значений. В одном из них последним аргументом является сообщение, выводимое в случае ошибки, а в другом — лямбда-выражение, реализующее функциональный интерфейс
Supplier
, что позволяет вычислять значение строки только в случае неудачного прохождения теста:import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class HelloJunit5Test {
@Test
fun `Base assertions`() {
assertEquals("a", "a")
assertEquals(2, 1 + 1, "Optional message")
assertEquals(2, 1 + 1, { "Assertion message " + "can be lazily evaluated" })
}
}
Групповые assertion'ы
Для тестирования групповых assertion'ов предварительно создадим класс
Person
с двумя свойствами:class Person(val firstName: String, val lastName: String)
Будут выполнены оба assertion'а:
import org.junit.jupiter.api.Assertions.assertAll
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.function.Executable
class HelloJunit5Test {
@Test
fun `Grouped assertions`() {
val person = Person("John", "Doe")
assertAll("person",
Executable { assertEquals("John", person.firstName) },
Executable { assertEquals("Doe", person.lastName) }
)
}
}
Передача лямбд и ссылок на методы в проверках на true/false
@Test
fun `Test assertTrue with reference and lambda`() {
val list = listOf("")
assertTrue(list::isNotEmpty)
assertTrue {
!list.contains("a")
}
}
Exceptions
Более прозрачная по сравнению с JUnit 4 работа с исключениями:
@Test
fun `Test exception`() {
val exception: Exception = assertThrows(IllegalArgumentException::class.java, {
throw IllegalArgumentException("exception message")
})
assertEquals("exception message", exception.message)
}
Проверка времени выполнения тестов
Как и в остальных примерах, всё делается просто:
@Test
fun `Timeout not exceeded`() {
// Тест упадёт после выполнения лямбда-выражения, если оно превысит 1000 мс
assertTimeout(ofMillis(1000)) {
print("Выполняется операция, которая займёт не больше 1 секунды")
Thread.sleep(3)
}
}
При этом лямбда-выражение выполняется полностью, даже когда время выполнения уже превысило допустимое. Для того, чтобы тест падал сразу после истечения отведённого времени, нужно использовать метод
assertTimeoutPreemptively
: @Test
fun `Timeout not exceeded with preemptively exit`() {
// Тест упадёт, как только время выполнения превысит 1000 мс
assertTimeoutPreemptively(ofMillis(1000)) {
print("Выполняется операция, которая займёт не больше 1 секунды")
Thread.sleep(3)
}
}
Внешние assertion-библиотеки
Некоторые библиотеки предоставляют более мощные и выразительные средства использования assertion'ов, чем JUnit. В частности, Hamcrest, помимо прочих, предоставляет множество возможностей для проверки массивов и коллекций. Несколько примеров:
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers.containsInAnyOrder
import org.hamcrest.Matchers.greaterThanOrEqualTo
import org.hamcrest.Matchers.hasItem
import org.hamcrest.Matchers.notNullValue
import org.junit.jupiter.api.Test
class HamcrestExample {
@Test
fun `Some examples`() {
val list = listOf("s1", "s2", "s3")
assertThat(list, containsInAnyOrder("s3", "s1", "s2"))
assertThat(list, hasItem("s1"))
assertThat(list.size, greaterThanOrEqualTo(3))
assertThat(list[0], notNullValue())
}
}
Assumptions
Assumption'ы предоставляют возможность выполнения тестов только в случае выполнения определённых условий:
import org.junit.jupiter.api.Assumptions.assumeTrue
import org.junit.jupiter.api.Test
class AssumptionTest {
@Test
fun `Test Java 8 installed`() {
assumeTrue(System.getProperty("java.version").startsWith("1.8"))
print("Not too old version")
}
@Test
fun `Test Java 7 installed`() {
assumeTrue(System.getProperty("java.version").startsWith("1.7")) {
"Assumption doesn't hold"
}
print("Need to update")
}
}
При этом тест с невыполнившимся assumption'ом не падает, а прерывается:
Data driven тестирование
Одной из главных фич JUnit 5 является поддержка data driven тестирования.
Test factory
Перед генерацией тестов для большей наглядности сделаем класс
Person
data-классом, что, помимо прочего, переопределит метод toString()
, и добавим свойства birthDate
и age
:import java.time.LocalDate
import java.time.Period
data class Person(val firstName: String, val lastName: String, val birthDate: LocalDate?) {
val age
get() = Period.between(this.birthDate, LocalDate.now()).years
}
Следующий пример сгенерирует пачку тестов для проверки того, что возраст каждого человека не меньше заданного:
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DynamicTest
import org.junit.jupiter.api.DynamicTest.dynamicTest
import org.junit.jupiter.api.TestFactory
import java.time.LocalDate
class TestFactoryExample {
@TestFactory
fun `Run multiple tests`(): Collection<DynamicTest> {
val persons = listOf(
Person("John", "Doe", LocalDate.of(1969, 5, 20)),
Person("Jane", "Smith", LocalDate.of(1997, 11, 21)),
Person("Ivan", "Ivanov", LocalDate.of(1994, 2, 12))
)
val minAgeFilter = 18
return persons.map {
dynamicTest("Check person $it on age greater or equals $minAgeFilter") {
assertTrue(it.age >= minAgeFilter)
}
}.toList()
}
}
Помимо коллекций
DynamicTest
, в методе, аннотированном @TestFactory
, можно возвращать Stream
, Iterable
, Iterator
.Жизненный цикл выполнения динамических тестов отличается от
@Test
методов тем, что метод, аннотированный @BeforeEach
выполнится только для @TestFactory
метода, а не для каждого динамического теста. Например, при выполнении следующего кода функция Reset some var
будет вызвана только один раз, в чём можно убедиться, используя переменную someVar
: private var someVar: Int? = null
@BeforeEach
fun `Reset some var`() {
someVar = 0
}
@TestFactory
fun `Test factory`(): Collection<DynamicTest> {
val ints = 0..5
return ints.map {
dynamicTest("Test №$it incrementing some var") {
someVar = someVar?.inc()
print(someVar)
}
}.toList()
}
Параметризованные тесты
Параметризованные тесты, как и динамические, позволяют создавать набор тестов на основе одного метода, но делают это отличным от
@TestFactory
образом. Для иллюстрации работы этого способа предварительно добавим в pom.xml
зависимость: <dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-params</artifactId>
<version>5.0.2</version>
<scope>test</scope>
</dependency>
Код теста, проверяющего, что поступающие на вход даты уже в прошлом:
class ParameterizedTestExample {
@ParameterizedTest
@ValueSource(strings = ["2002-01-23", "1956-03-14", "1503-07-19"])
fun `Check date in past`(date: LocalDate) {
assertTrue(date.isBefore(LocalDate.now()))
}
}
Значениями аннотации
@ValueSource
могут быть массивы int
, long
, double
и String
. В случае массива строк, как видно из примера выше, будет использовано неявное преобразование к типу входного параметра, если оно возможно. @ValueSource
позволяет передавать только один входной параметр для каждого вызова теста.@EnumSource
позволяет тестовому методу принимать константы перечислений: @ParameterizedTest
@EnumSource(TimeUnit::class)
fun `Test enum`(timeUnit: TimeUnit) {
assertNotNull(timeUnit)
}
Можно оставить или исключить определённые константы:
@ParameterizedTest
@EnumSource(TimeUnit::class, mode = EnumSource.Mode.EXCLUDE, names = ["SECONDS", "MINUTES"])
fun `Test enum without days and milliseconds`(timeUnit: TimeUnit) {
print(timeUnit)
}
Есть возможность указать метод, который будет использован как источник данных:
@ParameterizedTest
@MethodSource("intProvider")
fun `Test with custom arguments provider`(argument: Int) {
assertNotNull(argument)
}
companion object {
@JvmStatic
fun intProvider(): Stream<Int> = Stream.of(0, 42, 9000)
}
В java-коде этот метод должен быть статическим, в Kotlin это достигается его объявлянием в объекте-компаньоне и аннотированием
@JvmStatic
. Чтобы использовать не статический метод, нужно изменить жизненный цикл экземпляра теста, точнее, создавать один инстанс теста на класс, вместо одного инстанса на метод, как делается по умолчанию:@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class ParameterizedTestExample {
@ParameterizedTest
@MethodSource("intProvider")
fun `Test with custom arguments provider`(argument: Int) {
assertNotNull(argument)
}
fun intProvider(): Stream<Int> = Stream.of(0, 42, 9000)
}
Повторяемые тесты
Число повторений теста указывается следующим образом:
@RepeatedTest(10)
fun `Повторяемый тест`() {
}
Есть возможность настроить выводимое название теста:
@RepeatedTest(10, name = "{displayName} {currentRepetition} из {totalRepetitions}")
fun `Повторяемый тест`() {
}
Доступ к информации о текущем тесте и о группе повторяемых тестов можно получить через соответствующие объекты:
@RepeatedTest(5)
fun `Repeated test with repetition info and test info`(repetitionInfo: RepetitionInfo, testInfo: TestInfo) {
assertEquals(5, repetitionInfo.totalRepetitions)
val testDisplayNameRegex = """repetition \d of 5""".toRegex()
assertTrue(testInfo.displayName.matches(testDisplayNameRegex))
}
Вложенные тесты
JUnit 5 позволяет писать вложенные тесты для большей наглядности и выделения взаимосвязей между ними. Создадим пример, используя класс
Person
и собственный провайдер аргументов для тестов, возвращающий стрим объектов Person
:class NestedTestExample {
@Nested
inner class `Check age of person` {
@ParameterizedTest
@ArgumentsSource(PersonProvider::class)
fun `Check age greater or equals 18`(person: Person) {
assertTrue(person.age >= 18)
}
@ParameterizedTest
@ArgumentsSource(PersonProvider::class)
fun `Check birth date is after 1950`(person: Person) {
assertTrue(LocalDate.of(1950, 12, 31).isBefore(person.birthDate))
}
}
@Nested
inner class `Check name of person` {
@ParameterizedTest
@ArgumentsSource(PersonProvider::class)
fun `Check first name length is 4`(person: Person) {
assertEquals(4, person.firstName.length)
}
}
internal class PersonProvider : ArgumentsProvider {
override fun provideArguments(context: ExtensionContext): Stream<out Arguments> = Stream.of(
Person("John", "Doe", LocalDate.of(1969, 5, 20)),
Person("Jane", "Smith", LocalDate.of(1997, 11, 21)),
Person("Ivan", "Ivanov", LocalDate.of(1994, 2, 12))
).map { Arguments.of(it) }
}
}
Результат будет довольно наглядным:
Заключение
JUnit 5 довольно прост в использовании и предоставляет множество удобных возможностей для написания тестов. Data driven тестирование с использованием Kotlin обеспечивает удобство в разработке и лаконичность кода.
Спасибо!
Комментарии (7)
Vitaljok
11.01.2018 19:12Не ради холивара…
Мне однажды показали Spock Framework, с тех пор с не могу без боли смотреть на JUnit.not_bad Автор
11.01.2018 19:28В чём его преимущества (2-3 пункта по возможности)?
Vitaljok
11.01.2018 20:44Может это, конечно, субъективно, но основные:
- создание Mock-ов просто и наглядно,
- assert-ы средствами языка
- (edited) числа сразу BigDecimal, особенно актуально в финансах, где почти все данные — различные суммы.
Несколько примеров (без контекста) :
def "should get person by id"() { given: def repository = Mock(PersonRepository) { findById("100") >> Optional.of(new Person(id: "100")) findById("200") >> Optional.empty() } when: "existing person" def bean = service.getPersonById("100") then: bean.id == "100" when: "not existing person" service.getPersonById("200") then: thrown NotFoundException } def "should find all by order id"() { when: def list = repository.findAllByOrderId(orderId) then: list.size() == size list.collect { it.id } == ids list.any { it.totalAmount == 123.45 } where: orderId | size | ids "ord-A" | 2 | ["100", "300"] "ord-B" | 1 | ["200"] "ord-C" | 0 | [] }
not_bad Автор
12.01.2018 09:011) с использованием mockito создание моков сопоставимо по простоте с приведённым примером
2) круто, но вот язык поддерживается только один
Примеры, конечно, выразительные, но вот ограничение фреймворка возможностью писать тесты исключительно на Groovy подойдёт далеко не всем.
gjf
Статья вышла одновременно с этой: Обнаружен первый Android-зловред, разработанный на языке Kotlin
gjf
Шутка, конечно, но правда забавно.
not_bad Автор
:)