В этой статье будут рассмотрены основные возможности платформы 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 обеспечивает удобство в разработке и лаконичность кода.

Спасибо!

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


  1. gjf
    11.01.2018 18:43

    1. gjf
      11.01.2018 18:43

      Шутка, конечно, но правда забавно.


      1. not_bad Автор
        11.01.2018 20:37

        :)


  1. Vitaljok
    11.01.2018 19:12

    Не ради холивара…
    Мне однажды показали Spock Framework, с тех пор с не могу без боли смотреть на JUnit.


    1. not_bad Автор
      11.01.2018 19:28

      В чём его преимущества (2-3 пункта по возможности)?


      1. 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    | []
        }


        1. not_bad Автор
          12.01.2018 09:01

          1) с использованием mockito создание моков сопоставимо по простоте с приведённым примером
          2) круто, но вот язык поддерживается только один


          Примеры, конечно, выразительные, но вот ограничение фреймворка возможностью писать тесты исключительно на Groovy подойдёт далеко не всем.