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

    Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.

    Что я буду делать после прочтения статьи?
    Поделиться публикацией

    Похожие публикации

    Комментарии 5
      0

      Спасибо, выглядит неплохо. Попробую поэкспериментировать с библиотекой.


      А не рассматривали вариант расширения функционала mockito-kotlin вместо написания отдельной библиотеки?

        –1

        Есть несколько причин, почему новая библиотека это хорошо:


        • по моему мнению, это всё ещё не занятая ниша;
        • возможность реализовать и развивать что-то, что близко к языку изначально;
        • конкуренция, когда выигрывают оба проекта.
          0

          Не знаю насколько релевантно: есть библиотека, которая позволяет пускать агента из уже с запущенного кода без нужды указывать -javaagent:
          https://github.com/electronicarts/ea-agent-loader
          Запуск агента производится через определенный OpenJDK-шный JMX — весь инструментарий для рантайм инжиниринга вналичии. Я так пускаю EclipseLink JPA с динамическим weaving-ом в рантайме.

            0

            Гляну. Спасибо

            +1
            Попробовал библиотеку, разобрался, как мокировать котлиновские функции из внешних библиотек минут за 20. Спасибо!

            Пример тут.

            Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

            Самое читаемое