Open source
IT systems testing
Programming
Java
Kotlin
30 October 2017

MockK — библиотека для mocking-а в Kotlin

MockK logo Kotlin пока еще очень новая технология и это значит, что существует множество возможностей сделать что-то лучше. Для меня этот путь был таким. Я начал писать простой слой веб-обработки на Netty и coroutine-ах. Всё было в порядке, я даже сделал что-то вроде веб-фреймворка с роутингом, веб-сокетами, DSL и полной асинхронностью. Для первого раза всё показалось лёгким в освоении. Действительно, coroutine-ы делают из лапши коллбэков линейный и читаемый код.


Сюрприз ожидал меня, когда я начал тестировать это всё. Оказывается, Kotlin и mocking сложно совместимые вещи. В первую очередь из-за final полей. Далее, существует ровно одна библиотека для тестирования котлина и это Mockito. Для неё создана обёртка, которая предоставляет что-то вроде DSL. Но и тут не всё гладко. В первую очередь это тестирование функций с именованными параметрами. Mockito требует задавать абсолютно все параметры в виде matcher-ов, а в Kotlin этих параметров часто много и часть из них имеют значения по умолчанию. Задавать их все слишком накладно. Кроме того, часто последним параметром передается лямбда-блок. Создавать ArgumentCaptor и выполнять сложный кастинг, для того чтобы его вызвать — перебор. Сами корутины — это функции с последним параметром типа Continuation. И это требует специальной обработки. В Mockito её добавили, но не добавили удобства вызова самых корутин. Итого, из всех этих мелочей складывается ощущение, что обёртка эта не совсем гармонично вписывается в язык.


Прикинув объем работ я пришел к выводу, что один человек вполне может справиться и начал писать свою библиотеку. Я постарался сделать её близкой к языку и решить те проблемы, с которыми я столкнулся при тестировании.


Сейчас я расскажу, что у меня вышло. Вот для затравки простейший пример:


val car = mockk<Car>()

every { car.drive(Direction.NORTH) } returns Outcome.OK

car.drive(Direction.NORTH) // returns OK

verify { car.drive(Direction.NORTH) }

Здесь не используются matcher-ы, просто в целом показан синтаксис DSL. Вначале блок every/returns задает, что mock должен возвращать, и блок verify для проверки был ли вызов произведен.


Конечно, в MockK есть возможность захвата переменных, множество matcher-ов, spy-и и других конструкций. Вот более развернутый пример. Все это также есть в Mockito. Поэтому хотелось бы описать отличия.


Tool Итак, чтобы всё это работало, мне потребовался Java Agent, чтобы убрать все final атрибуты. Это совсем не сложно, работает из Maven/Gradle, но не очень хорошо работает с IDE. Каждый раз нужно приписывать “-javaagent:<какой-то путь>” параметр. Была даже идея написать плагины для популярных IDE, которые позволяют легко запускать Java Agent-ы. Но в результате, пришлось сделать поддержку запуска JUnit4 и JUnit5 без Java Agent.


Для JUnit4 это запуск посредством стандартной аннотации @RunWith, которую и сам не люблю, но деваться некуда. Для того, чтобы хоть как-то сделать жизнь проще, я добавил ChainedRunWith. Она позволяет задавать следующий Runner в цепочке и таким образом использовать две разные библиотеки.


Для JUnit5 достаточно добавить зависимость на JAR с агентом, и вся магия произойдет сама собой. Но могу сказать, что в реализации это сущий хак с Unsafe, Javassist и Reflection. По этой причине официальным способом запуска все-же считается запуск через Java Agent.


Следующая фишка — возможность задать не все параметры, как matcher-ы, а только часть из них. Для реализации этой возможности пришлось пораскинуть мозгами. Если у нас есть вот такая функция:


fun response(html: String = "",
   contentType: String = "text/html",
   charset: Charset = Charset.forName("UTF-8"),
   status: HttpResponseStatus = HttpResponseStatus.OK)

И где-то есть его вызов:


response(“Great”)

То чтобы протестировать это в Mockito, обязательно нужно указывать все параметры:


`when`(mock.response(eq(“Great”), eq("text/html"),
    eq(Charset.forName(“UTF-8”)), eq(HttpResponseStatus.OK)))).doNothing()

Это явно ограничивает. В MockK можно указать только необходимые matcher-ы, все остальные параметры будут заменены на eq(...) или, если указан matcher allAny(), то на any().


every { response(“Great”) } answers { nothing }

every { response(eq(“Great”)) } answers { nothing }

every { response(eq(“Great”), allAny()) } answers { nothing }

Idea Это достигается таким трюком: блок every вызывается несколько раз и каждый раз matcher возвращает случайное значение, дальше данные сопоставляются и находятся нужные matcher-ы. Для тех мест, где не задан matcher, аргумент почти всегда будет константный. «Почти всегда» потому, что иногда всё-таки параметром по умолчанию будет функция, возвращающая время или что-то подобное. Это легко обойти явным указанием matcher-а.


Далее о тестировании DSL. К примеру, рассмотрим такой код:


fun jsonResponse(block: JsonScope.() -> Unit) {
  val str = StringBuilder()
  JsonScope(str).block()
  response(str.toString(), "application/json")
}

jsonResponse {
  seq {
     proxyOps.allConnections().forEach {
        hash {
           "listenPort"..it.listenPort
           "connectHost"..it.connectHost
           "connectPort"..it.connectPort
       }
    }
 }
}

Не важно, что он делает сейчас — важно, что это композиция конструкций из DSL-а, собирающая JSON.


Как это тестировать? В MockK для этого есть специальный matcher captureLambda. Удобство заключается в том, что мы одним выражением можем захватить lambd-у и в ответе вызвать ее:


val strBuilder = StringBuilder()
val jsonScope = JsonScope(strBuilder)
every {
  scope.jsonResponse(captureLambda(Function1::class))
} answers { 
  lambda(jsonScope) 
}

Чтобы проверить правильность основного кода, можно сравнить содержимое StringBuilder-а с образцом того, что должно быть в ответе. Удобство только в том, что блок, передаваемый как последний параметр, это идиома языка, и удобно иметь специальный способ её обработки в mocking фреймворке.


Поддержка coroutine тоже не столько сложно реализуемая функция, сколько просто удобный способ делать то, что язык представляет из коробки. Просто заменяем вызов every и verify на coEvery и coVerify и можем вызывать coroutine-у внутри.


suspend fun jsonResponse(block: JsonScope.() -> Unit) {
 val str = StringBuilder()
 JsonScope(str).block()
 response(str.toString(), "application/json")
}

coVerify { scope.jsonResponse(any()) }

В итоге, цель проекта сделать mocking в Kotlin максимально удобным, а не нарастить тысячи функций, которые есть в PowerMock и Mockito. К этому я буду стремиться и дальше.


Exit

Прошу общественность не судить строго, попробовать библиотеку в своих проектах и предложить новые функции, довести до ума текущие.


Сайт проекта: http://mockk.io

Что я буду делать после прочтения статьи?
81.8% попробую в использовании 27
21.2% буду использовать Mockito 7
15.1% если найду ошибки - отошлю отчет о них 5
3% помогу с новыми функциями 1
3% помогу с документацией 1
6% тестирование с mock-ами мне не нужно 2
33 users voted. 17 users abstained.

+9
7.3k 34
Comments 5