Цель статьи заключается в том, чтобы показать какие возникают трудности при использовании Spock с Kotlin, какие есть пути их разрешения и ответить на вопрос, стоит ли использовать Spock, если вы разрабатываете на Kotlin. Подробности под катом.

Я работаю в компании, в которой практикуется экстремальное программирование. Одним из основных приёмов экстремального программирования, который мы используем в повседневной работе, является TDD (test-driven development). Это означает, что прежде чем изменять код, мы пишем тест, который покрывает желаемое изменение. Таким образом, мы регулярно пишем тесты и имеем покрытие кода тестами близкое к 100%. Это предъявляет определенные требования к выбору тестового фрэймворка: одно дело — писать тесты раз в неделю, совсем другое — делать это каждый день.

Мы занимаемся разработкой на Kotlin и в определенный момент в качестве основного фрэймворка мы выбрали Spock. С того момента прошло около 6 месяцев, чувство эйфории и новизны прошло, поэтому статья является своего рода ретроспективой, в которой я попытаюсь рассказать, с какими трудностями мы столкнулись за это время и как мы их разрешали.

Почему именно Spock?




В первую очередь нужно разобраться, какие фрэймворки позволяют тестировать Kotlin и какие преимущества дает именно Spock по сравнению с ними.

Одним из достоинств Kotlin является его совместимость с Java, что позволяет использовать для тестирования любые Java фрэймворки, такие как Junit, TestNG, Spock и т.д. В то же время есть фрэймворки разработанные специально для Kotlin такие как Spek и Kotest. Почему мы выбрали имеено Spock?

Я бы выделил следующие достоинства:

  • Во-первых, Spock написан на Groovy. Хорошо это или плохо — судите сами. Про Groovy можно почитать статейку вот тут. Лично меня в Groovy привлекает лаконичный синтаксис, наличие динамической типизации, встроенный синтаксис для списков, обычных и ассоциативных массивов, а также совершенно сумасшедшие преобразования типов.
  • Во-вторых, Spock уже содержит mock-фрэймворк (MockingApi) и assertion-библиотеку;
  • Еще Spock отлично подходит для написания параметризованных тестов:

def "maximum of two numbers"() {
    expect:
    Math.max(a, b) == c

    where:
    a | b | c
    1 | 3 | 3
    7 | 4 | 7
    0 | 0 | 0
  }

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

Проблемы, возникающие при тестировании Kotlin при помощи Spock и их решения


Тут я сразу оговорюсь, что проблемы, которые описаны ниже, не являются уникальными для Spock, они будут актуальны для любого Java-фрэймворка и связаны они, в основном, с совместимостью с Kotlin.

1. final по умолчанию


Проблема

В отличие от Java, все классы Kotlin по умолчанию имеют модификатор final, что предотвращает дальнейшее создание потомков и переопределение методов. Проблема тут заключается в том, что при создании mock-объектов какого-либо класса, большинство mock-фрэймворков пытаются создать proxy-объект, который как раз и является потомком исходного класса и переопредяет его методы.

Поэтому если у вас есть сервис:

class CustomerService {
    fun getCustomer(id: String): Customer {
        // Some logic
    }
}

и вы попытаетесь создать mock этого сервиса в Groovy:

def customerServiceMock = Mock(CustomerService)

, то вы получите ошибку:
org.spockframework.mock.CannotCreateMockException: Cannot create mock for class CustomerService because Java mocks cannot mock final classes

Решение:

  • Первым, что приходит на ум, является использование ключевого слова open, которое позволит другим классам наследоваться от данного. Недостатками такого решение будет очевидное «загрязнение» кода, а также предоставление возможности расширять класс, который, возможно, вовсе не предназначен для расширения;
  • Вторым решением будет использование all-open плагина для компилятора. Этот плагин делает классы и их члены открытыми на этапе компиляции. Этот плагин необходим в том числе для использования Spring Framework и Hibernate, потому что они полагаются на использование cglib(Code Generation Library);
  • Также, можно использовать одну из mock-библиотек, разработанных специально для Kotlin (например, mockk). Однако, этот метод нельзя назвать предпочтительным, так как Spock уже содержит свою собственную библиотеку Spock MockingApi.

2. Значения аргументов по умолчанию


Проблема

В Kotlin аргументы функции или конструктора могут иметь значения по умолчанию, которые используются в том случае, если аргумент функции не указан при её вызове. Трудность заключается в том, что байт-код Java теряет значения аргументов по умолчанию и имена параметров функции и поэтому при вызове функции или конструктора Kotlin из Spock требуется явно указывать все значения.

Рассмотрим класс Customer, который имеет конструктор с 2-мя обязательными полями email и name и опциональными полями, имеющими значения по умолчанию:

data class Customer(
    val email: String,
    val name: String,
    val surname: String = "",
    val age: Int = 18,
    val identifiers: List<NationalIdentifier> = emptyList(),
    val addresses: List<Address> = emptyList(),
    val paymentInfo: PaymentInfo? = null
)

Для того, чтобы создать экземпляр этого класса в Groovy Spock тесте, придется передать в конструктор значения для всех аргументов:

new Customer("john.doe@gmail.com", "John", "", 18, [], [], null)

Решение:

  • Первое решение проблемы — использование аннотации @JvmOverloads, которая создаст перегруженные конструкторы или перегруженные функции.
  • Также, можно использовать отдельный groovy класс — фабрику объектов, чтобы делегировать логику по созданию и популяции объектов:

class ModelFactory {
    static def getCustomer() { 
        new Customer(
            "john.doe@gmail.com", 
            "John", 
            "Doe", 
            18, 
            [nationalIdentifier], 
            [address], 
            paymentInfo
        ) 
    }

    static def getAddress() { new Address(/* arguments */) }

    static def getNationalIdentifier() { new NationalIdentifier(/* arguments */) }

    static def getPaymentInfo() { new PaymentInfo(/* arguments */) }
}

3. Java Reflection vs Kotlin Reflection


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

Проблема

Классы и аннотации написаны на Kotlin, логика, связанная с тестами и рефлексией на Groovy. Из-за этого в тестах получается мешанина из Java Reflection API и Kotlin Reflection:

Конвертация Java классов в Kotlin:

def kotlinClass = new KClassImpl(clazz)

Вызов Kotlin extension-функций как статических методов:
ReflectJvmMapping.getJavaType(property.returnType)

Трудночитаемый код, где будут использоваться типы Kotlin:
private static boolean isOfCollectionType(KProperty1<Object, ?> property) {
    // Some logic
}

Конечно, это не является чем-то непосильным или невозможным. Вопрос только в том, а не проще ли это сделать при помощи Kotlin фрэймворка для тестирования, где можно будет использовать extension-функции и чистый Kotlin Reflection??

4. Coroutines


Если вы планируете использовать корутины, то это еще один повод задуматься о том, не хотите ли вы выбрать Kotlin фрэймворк для тестирования.

Проблема

Если создать suspend функцию:

suspend fun someSuspendFun() {
    // Some logic
}

, то она скомпилируется следующим образом:

public void someSuspendFun(Continuation<? super Unit> $completion) {
    // Some logic
}

, где Continuation — это Java-класс, который инкапсулирует логику по выполнению корутины. Вот тут и возникает проблема, как создать объект этого класса? Также, в Groovy недоступен runBlocking. И поэтому вообще не очень ясно, как тестировать код с корутинами в Spock.

Итоги


За полгода использования Spock он принес нам больше хорошего, чем плохого и о выборе мы не жалеем: тесты легко читать и поддерживать, писать параметризованные тесты — одно удовольствие. Ложкой дегтя в нашем случае оказалась рефлексия, но она используется всего в нескольких местах, то же самое с корутинами.

При выборе фрэймворка для тестирования проекта, который разрабатывается на Kotlin стоит хорошо подумать, какие функции языка вы планируете использовать. В случае если вы пишете простое CRUD-приложение, Spock будет идеальным решением. Используете корутины и рефлексию? Тогда лучше посмотрите на Spek или Kotest.

Материал не претендует на полноту, поэтому если вы сталкивались с другими трудностями при использовании Spock и Kotlin — пишите в комментариях.

Полезные ссылки