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

Как уменьшить объем шаблонного кода в тестах 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.


Источник


Report Page