Как уменьшить объем шаблонного кода в тестах Kotlin

Тестирование должно быть простым. Если тесты слишком сложные и проблематичные в сопровождении, они теряют смысл. Тесты помогают разработчику понять логику приложения и проверить, что оно работает как надо. Создавая простые тесты, вы обеспечиваете их практическую ценность как для себя, так и для тех, кто воспользуется ими в дальнейшем. Один из способов это сделать  —  избавиться от шаблонного кода, перегружающего тесты. В статье мы займемся решением этой задачи. 

Сценарий использования 

Допустим, у нас есть приложение, которое отправляет какие-либо данные принимающему 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.

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи Uzi Landsmann: Reduce Boilerplate When Running Kotlin Tests

Предыдущая статья10 вопросов, которые помогут нанять лучшего Android-разработчика
Следующая статьяИспользование WebSocket с Python