Как уменьшить объем шаблонного кода в тестах 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.
Читайте также:
Перевод статьи Uzi Landsmann: Reduce Boilerplate When Running Kotlin Tests