[Из песочницы] Тестирование с помощью JUnit 5 на Kotlin

Habrahabr

В этой статье будут рассмотрены основные возможности платформы JUnit 5 и приведены примеры их использования на Kotlin. Материал ориентирован на новичков в Kotlin и/или JUnit, однако, и более опытные разработчики найдут интересные вещи. Официальный 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!")
    }
}
Тест проходит успешно:

image

Перейдём к обзору основных фич 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!")
    }
}
Как видно, значение аннотации имеет приоритет перед названием функции:

image

Аннотация применима и к классу:

@DisplayName("Override class name")
class HelloJunit5Test {

image

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'ом не падает, а прерывается:

image

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()
    }
}

image

Помимо коллекций 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()
    }

image

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

Параметризованные тесты, как и динамические, позволяют создавать набор тестов на основе одного метода, но делают это отличным от @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)
    }

image

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

    @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 `Повторяемый тест`() {

    }

image

Есть возможность настроить выводимое название теста:

    @RepeatedTest(10, name = "{displayName} {currentRepetition} из {totalRepetitions}")
    fun `Повторяемый тест`() {

    }

image

Доступ к информации о текущем тесте и о группе повторяемых тестов можно получить через соответствующие объекты:

    @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) }
    }
}
Результат будет довольно наглядным:

image

Заключение

JUnit 5 довольно прост в использовании и предоставляет множество удобных возможностей для написания тестов. Data driven тестирование с использованием Kotlin обеспечивает удобство в разработке и лаконичность кода.

Спасибо!