Как уменьшить объем шаблонного кода в тестах Kotlin
https://t.me/javatgТестирование должно быть простым. Если тесты слишком сложные и проблематичные в сопровождении, они теряют смысл. Тесты помогают разработчику понять логику приложения и проверить, что оно работает как надо. Создавая простые тесты, вы обеспечиваете их практическую ценность как для себя, так и для тех, кто воспользуется ими в дальнейшем. Один из способов это сделать — избавиться от шаблонного кода, перегружающего тесты. В статье мы займемся решением этой задачи.
Сценарий использования
Допустим, у нас есть приложение, которое отправляет какие-либо данные принимающему API. Отправляем следующее:
data class Stuff(val name: String, val type: String)
Сервис перенаправляет запрос другому классу для фактической отправки. Код выглядит так:
import okhttp3.OkHttpClient class StuffService() { private val client = OkHttpClient() .newBuilder() .addInterceptor { chain -> val request = chain.request().newBuilder() .addHeader("Content-Type", "application/json") .build() chain.proceed(request) }.build() private val stuffLink = StuffLink(client, "http://some.where") fun sendStuff(stuff: Stuff) = stuffLink.sendStuff(stuff) }
Клиент (назовем его ссылкой, тем самым исключая множественные трактовки этого понятия) выполняет фактическую отправку, создавая запрос и отправляя его посредством заданного OkHttpClient
и URL
. Рассмотрим код:
import com.fasterxml.jackson.databind.ObjectMapper import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response class StuffLink(private val client: OkHttpClient, private val url: String) { private val objectMapper = ObjectMapper() fun sendStuff(stuff: Stuff): Response { val request = Request.Builder() .url(url) .post( objectMapper.writeValueAsString(stuff) .toRequestBody("application/json".toMediaType()), ).build() return client.newCall(request).execute() } }
Вариант теста 1
Предположим, мы должны проверить, что с помощью класса StuffLink
принимающему API отправляются правильные значения. Пишем первый вариант теста:
@Test fun `Stuff should be sent by StuffLink (version 1)`() { val requestSlot = slot<Request>() val mockkClient = mockk<OkHttpClient> { every { newCall(capture(requestSlot)) } returns mockk(relaxed = true) } val stuffLink = StuffLink(mockkClient, url) val bob = Stuff("Bob", "Armchair") stuffLink.sendStuff(bob) val request = requestSlot.captured val bodyAsMap = parseRequestBody(request) assertThat(request.url.toString()).isEqualTo(url) assertThat(bodyAsMap["name"]).isEqualTo("Bob") assertThat(bodyAsMap["type"]).isEqualTo("Armchair") }
Половина этого метода представляет собой шаблонный код. Мы создаем: 1) слот в качестве плейсхолдера для запроса; 2) имитационного клиента, оснащенного слотом; 3) экземпляр класса StuffLink
для запуска тестов. Далее вызываем метод отправки с тестовыми данными.
И только после всего этого мы делаем то, что задумали: проверяем, содержит ли запрос ожидаемые данные.
Вариант теста 2
Первый вариант теста оказался громоздким. Чтобы не повторять эту практику в каждом создаваемом тестовом методе, попробуем поместить часть подготовительной логики в переиспользуемый метод, как показано ниже:
@Test fun `Stuff should be sent by StuffLink (version 2)`() { val (mockkClient, requestSlot) = createMockAndSlot() val stuffLink = StuffLink(mockkClient, url) val bob = Stuff("Bob", "Armchair") stuffLink.sendStuff(bob) val request = requestSlot.captured val bodyAsMap = parseRequestBody(request) assertThat(request.url.toString()).isEqualTo(url) assertThat(bodyAsMap["name"]).isEqualTo("Bob") assertThat(bodyAsMap["type"]).isEqualTo("Armchair") } private fun createMockAndSlot(): Pair<OkHttpClient, CapturingSlot<Request>> { val requestSlot = slot<Request>() val mockkClient = mockk<OkHttpClient> { every<Call> { newCall(capture(requestSlot)) } returns mockk<Call>(relaxed = true) } return mockkClient to requestSlot }
Вариант теста 3
Во втором варианте теста все еще присутствует шаблонный код. К тому же переиспользуемый метод не кажется таким привлекательным со своим странным именем и двойным возвращаемым значением. Предпримем третью попытку:
@Test fun `Stuff should be sent by StuffLink (version 3)`() { val bob = Stuff("Bob", "Armchair") val request = runInStuffLink { sendStuff(bob) } val bodyAsMap = parseRequestBody(request) assertThat(request.url.toString()).isEqualTo(url) assertThat(bodyAsMap["name"]).isEqualTo("Bob") assertThat(bodyAsMap["type"]).isEqualTo("Armchair") } private fun runInStuffLink(action: StuffLink.() -> Unit): Request { val requestSlot = slot<Request>() val mockkClient = mockk<OkHttpClient> { every<Call> { newCall(capture(requestSlot)) } returns mockk<Call>(relaxed = true) } val stuffLink = StuffLink(mockkClient, url) stuffLink.action() return requestSlot.captured }
Переиспользуемый метод runInStuffLink
позволяет избавиться от оставшегося шаблонного кода. В качестве аргумента он принимает метод, предназначенный для запуска в классе StuffLink
. Сначала реализация создает слот и клиента, а затем — экземпляр класса StuffLink
. После этого запускает заданный метод в этом экземпляре и возвращает полученный слот.
Вышеуказанная техника описана в разделе документации Kotlin Function literals with receiver (Функциональные литералы Kotlin с receiver). Она часто применяется для создания DSL.
Код статьи доступен по данной ссылке на репозиторий GitHub.