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. Поэтому хотелось бы описать отличия.
Итак, чтобы всё это работало, мне потребовался 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 }
Это достигается таким трюком: блок 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. К этому я буду стремиться и дальше.
Прошу общественность не судить строго, попробовать библиотеку в своих проектах и предложить новые функции, довести до ума текущие.
Сайт проекта: http://mockk.io