Обложка: Делаем типовой проект на Kotlin для тестирования RestAPI сервиса в Docker

Делаем типовой проект на Kotlin для тестирования RestAPI сервиса в Docker

Максим Кочетков

Максим Кочетков

Тестировщик Мир Plat.Form

Всем привет! Меня зовут Максим Кочетков. Я занимаюсь тестированием ПО более 10 лет. Из них около 7 лет автоматизацией на Java, Kotlin и Groovy. В команде Мир Plat.Form (ИТ-бренд Национальной системы платёжных карт) я отвечаю за вопросы, связанные с автоматизацией тестирования. Наши сервисы, например, платёжную систему «‎Мир»‎ и Систему быстрых платежей (СБП), используют десятки миллионов россиян.

В этой статье я расскажу, как сделать проект-шаблон для наращивания покрытия любых RestAPI сервисов. Мы создадим проект с нуля: поднимем RestAPI-сервис в контейнере и протестируем с помощью http-клиента Retrofit. Код напишем на Kotlin, а тестовый движок — в сочетании с Kotest. RestAPI-cервис будет реализован как заглушка с помощью библиотеки Wiremock. Поднимать контейнер мы будем в TestContainers. Результат теста сохраним в формате Allure и сгенерируем отчёт.

Настраиваем Wiremock

Wiremock — это инструмент для создания заглушек HTTP-сервисов, который написан на Java и под капотом использует производительный Netty сервер. Подходит не только для функционального, но и нагрузочного тестирования. Есть аналог MockServer, который создан на NodeJs. Я рекомендую именно Wiremock, потому что для него можно писать расширения на Java для реализации сложного поведения.

Есть 2 варианта описания и регистрации заглушек:

  • создать в Java-коде и передавать на сервер через Wiremock Admin API;
  • оформить в формате json и подкладывать в определённую папку на сервере.

Будем использовать второй вариант — он более наглядный, и его могут редактировать не знакомые с Java коллеги. Например frontend-разработчик способен создавать Wiremock-заглушки для эмуляции backend части.

В ресурсах создаем папку wiremock в ней под-папки __files и mappings. Названия этих двух директорий зарезервированы Wiremock.

Наш сервис будет иметь один контроллер — Employee с двумя методами POST и GET, на создание и получения сотрудника. Создаем два json файла с описанием заглушек в папке mappings — это описание того, как будет сопоставляться входящий запрос, и как формироваться исходящий ответ. Еще 2 файла в папке __files — это тело ответа, они находятся в отдельной директории. В будущем наша иерархия увеличится, а тело ответа разрастётся на сотню-другую строк, поэтому делаем сразу удобно.

проект на Kotlin

Описание заглушки employee_get.json:

{
  "request": {
    "method": "GET",
    "urlPathPattern": "/employee/\\d+"
  },
  "response": {
    "status": 200,
    "bodyFileName": "employee_get_body.json",
    "headers": {
      "Content-Type": "application/json;charset=utf-8"
    }
  }
}

В заглушке ожидаем method GET с URL-path (все, что после порта) советующим регулярке «/employee/\\d+«. Если пришел такой запрос, — отвечаем 200 с указанными

headers и телом из файла  employee_get_body.json:

{  "id": "{{request.pathSegments.[1]}}",  "name": "Max"}

В ответе двойные фигурные скобки вызывают встроенный движок шаблонов с широким набором функций. В данном случае — у пришедшего запроса взять сегмент URL-path с индексом 1 (нумерация с 0), то есть ID.

И вторая заглушка в файле employee_post.json

{
  "request": {
    "method": "POST",
    "urlPathPattern": "/employee",
    "basicAuthCredentials" : {
      "username" : "user",
      "password" : "user"
    }
  },
  "response": {
    "status": 200,
    "bodyFileName": "employee_post_body.json",
    "headers": {
      "Content-Type": "application/json;charset=utf-8"
    }
  }
}

Тут все аналогично, но добавляется авторизация basicAuthCredentials. Обрабатываться будут только те запросы, у которых есть заголовок Authorization с логином и паролем в формате Basic авторизации. И ответ employee_post_body.json:

{
  "id": "{{randomValue length=5 type='NUMERIC'}}",
  "name": "{{jsonPath request.body '$.name'}}"
}

randomValue length=5 type=’NUMERIC’ — функция, которая сгенерирует случайное число с пятью цифрами. А jsonPath request.body ‘$.name’ — взять значение из тела запроса по json-path поле name.

Сборочный Gradle скрипт

Перед написанием кода нужно настроить сборку нашего проекта и определить зависимости. Используем Gradle 6.8.3. Создаём необходимые файлы в корне проекта, сразу постараемся применить лучшие практики.

Предпочитаю Groovy DSL. Он лучше заточен на работу с замыканиями и благодаря динамической типизации менее многословен, но ничего не имею против Kotlin DSL

settings.gradle — так как у нас одномодульный проект, то здесь только название. Если бы модулей было несколько, то были бы их имена, а также настройки, которые необходимо выполнить до запуска основного скрипта. Например откуда качать плагины, если у вас корпоративные ограничения:

rootProject.name = 'article-tprogger-first'

gradle.properties — здесь храним переменные проекта, которые доступны в сборочном скрипте. В основном это версии библиотек:

kotlin.code.style=official
## Project Version
version=0.1.0
## Project Group
group=ru.iopump.qa.tprogger
## Gradle version. Adjust 'Use Gradle From' = 'wrapper task'
gradleWrapperVersion=6.8.3
## Gradle Plugins
benManesPluginVersion=0.38.0
## Kotest
kotlinVersion=1.4.32
kotestVersion=4.4.3
kotestAllureVersion=1.0.1
## Logger
slf4jVersion=1.7.30
## Reporting
allureVersion=2.13.9
allurePluginVersion=2.8.1
aspectVersion=1.9.6
## Docker
testContainers=1.15.2
## Http
retrofitVersion=2.9.0
okhttpVersion=4.9.1
## Mock
wiremockVersion=2.27.2

build.gradle — сборочный скрипт:

plugins {
    id 'idea'
    id 'com.github.ben-manes.versions' version "$benManesPluginVersion"
    id 'org.jetbrains.kotlin.jvm' version "$kotlinVersion"
    id "io.qameta.allure" version "$allurePluginVersion"
}
repositories { mavenLocal(); mavenCentral() }
wrapper {
    distributionType = Wrapper.DistributionType.ALL
    gradleVersion = gradleWrapperVersion
}
test {
    useJUnitPlatform()
    systemProperties << ['wiremock.version': wiremockVersion]
}
dependencies {
    /* Kotlin */
    implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8'
    /* Logging API and Impl*/
    implementation "org.slf4j:slf4j-api:$slf4jVersion"
    runtimeOnly "org.slf4j:slf4j-simple:$slf4jVersion"
    /* Retrofit Lib and Http Client Implementation */
    implementation "com.squareup.retrofit2:retrofit:$retrofitVersion"
    implementation "io.qameta.allure:allure-okhttp3:$allureVersion"
    /* Retrofit Convertors and Interceptors*/
    implementation "com.squareup.retrofit2:converter-jackson:$retrofitVersion"
    implementation "com.squareup.retrofit2:converter-scalars:$retrofitVersion"
    implementation "com.squareup.okhttp3:logging-interceptor:$okhttpVersion"
    /* Kotest */
    testImplementation "io.kotest:kotest-runner-junit5:$kotestVersion"
    testImplementation "io.kotest:kotest-assertions-core:$kotestVersion"
    testImplementation "io.kotest:kotest-property:$kotestVersion"
    /* Kotest Extensions */
    testImplementation "ru.iopump.kotest:kotest-allure:$kotestAllureVersion"
    testImplementation "io.kotest:kotest-extensions-testcontainers:$kotestVersion"
    /* Main Testcontainers lib */
    testImplementation "org.testcontainers:testcontainers:$testContainers"
    /* Wiremock Client */
    testImplementation "com.github.tomakehurst:wiremock:$wiremockVersion"
}
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach {
    sourceCompatibility = JavaVersion.VERSION_11
    targetCompatibility = JavaVersion.VERSION_11
    kotlinOptions.jvmTarget = JavaVersion.VERSION_11
}
allure {
    version = allureVersion
    aspectjVersion = aspectVersion
    autoconfigure = false
    aspectjweaver = true
    resultsDir = file "$rootDir/allure-results"
}

Блок plugins — блок с плагинами для Kotlin, отчетов Allure и ben-manes.versions для обновления версий библиотек.

Блок test — настраиваем тестовый движок на junit5 и записываем в системные переменные теста версию Wiremock, которая пригодится для создания контейнера. На самом деле используем мы Kotest, но через адаптер к Junit5, чтобы переиспользовать весь функционал по интеграции Gradle и JUnit.

Блок tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile) — настройка версии Java для всех задач по компиляции (из документации Kotlin).

Блок allure — настройка генерации отчета. Важно отключить autoconfigure и указать верный путь до результатов resultsDir

Описываем модель для http-клиента

Модель сотрудника очень простая:

data class Employee(
    val id: Int? = null,
    val name: String? = null,
)

Для DTO всегда используем nullable типы и выставляем по умолчанию null. Иначе могут быть NPE если сервер пришлет не все поля и не получится проверить отсутствие значения.

Retrofit позволяет описывать модель взаимодействия с сервисом с помощью Интерфейсов, где методы и их аргументы аннотируются всей необходимой информацией. Также для работы нужна реализация HTTP-клиента в нашем случае это OkHttp.

Последовательность такая:

  • описываем интерфейс взаимодействия;
  • создаем http клиент с нужным набором перехватчиков (Interseptor);
  • создаем инстанс Retrofit с базовым URL и созданным ранее http клиентом, а также с конвертором для тела запросов и ответов;
  • вызываем Retrofit.create<ServiceName>() для создания реализации интерфейса.

Retrofit использует для реализации Java Dynamic Proxy. Аналоги: Feign(Netflix), Jax-rs. По тем же принципам работают ORM фреймворки / Spring Data JPA.

Теперь создаём модель взаимодействия

interface EmployeeService {

    @GET("employee/{id}")
    fun get(@Path("id") id: Int): Call<Employee>

    @POST("employee")
    fun create(
        @Header("Authorization") bearer: String,
        @Body employee: Employee
    ): Call<Employee>
}

Каждый метод возвращает объект Call<T> — отложенный вызов. В качестве параметра используется класс DTO. Однако если мы не знаем что нам вернется или не хотим ничего конвертировать (сериализовать), то используем Call<ResponseBody>.ResponseBody. Это специальный класс от Retrofit который содержит сырой ответ.

Если путь начинается со слеша (/) , то Retrofit посчитает его корневым и удалит всё, что после порта в базовом URL. Например для @GET(«/employee») адрес «http://localhost:80/one/two» превратится в «http://localhost:80/employee», а для @GET(«employee») в «http://localhost:80/one/two/employee». 

Чтобы всё запустить необходимо инициировать запрос, вызвав блокирующий метод Call<T>.execute(). Он выполнит запрос, получит ответ и вернёт нам результат в виде контейнера  Response<T>. Контейнер содержит все параметры ответа в том числе код и статус ответа, и сконвертированное тело.

Создадим утилитный класс для работы с Retrofit

object HttpUtil {
    private val log = LoggerFactory.getLogger(HttpUtil::class.java)

    val slf4jInterceptor: Interceptor = HttpLoggingInterceptor(log::info).apply {
        when {
            log.isTraceEnabled -> setLevel(HttpLoggingInterceptor.Level.BODY)
            log.isDebugEnabled -> setLevel(HttpLoggingInterceptor.Level.HEADERS)
            log.isInfoEnabled -> setLevel(HttpLoggingInterceptor.Level.BASIC)
            else -> setLevel(HttpLoggingInterceptor.Level.NONE)
        }
    }

    val allureInterceptor: Interceptor = AllureOkHttp3()

    val jsonConverter: Converter.Factory = JacksonConverterFactory.create()

  private fun createOkHttpClient(interceptors: Collection<Interceptor>) =
        OkHttpClient.Builder().apply {
            interceptors.forEach { interceptor -> addInterceptor(interceptor) }
        }.build()
        
    fun createRetrofit(
        baseUrl: String,
        converter: Converter.Factory,
        interceptors: Collection<Interceptor>
    ): Retrofit = Retrofit.Builder()
        .baseUrl(baseUrl)
        .client(createOkHttpClient(interceptors))
        .addConverterFactory(converter)
        .build()
}

slf4jInterceptor — перехватчик для логирования всех запросов в Slf4J. В зависимости от уровня Slf4J устанавливаются подробности журналирования.

allureInterceptor — перехватчик для добавления всех запросов в Allure отчет.

jsonConverter — конвертер для Json запросов и ответов. Все конверторы идут как дополнительные зависимости от разработчиков Retrofit. Важно помнить, что этот конвертор всегда будет пытаться преобразовать в Json и будет выдавать ошибку, если формат тела некорректен. Существует множество видов конверторов и нужно аккуратно выбирать подходящий. Либо создавать свой вариант со сложной логикой. Есть возможность получать сырые запросы через Call<ResponseBody> в обход конвертора.

createOkHttpClient — функция для создания клиента с перехватчиками.

createRetrofit — функция для создания Retrofit с базовым URL, с перехватчиками и конвертором.

Создаём конфигурацию тестов

Перед написанием тестов выполним настройку движка и создадим класс Registry необходимых объектов, чтобы держать их в единичном экземпляре.

Для конфигурации тестов я предпочитаю использовать Spring Test и паттерн Dependency Injection. Но в статье мы этого делать конечно же не будем, так как это слишком большая тема.

Создаём класс-наследник от AbstractProjectConfig. Это позволит Kotest найти наш класс в classpath и применить переопределённые методы.

object RegistryAndProjectConfiguration : AbstractProjectConfig() {

    override val parallelism: Int = 2

    private val wiremockVersion: String = System.getProperty("wiremock.version")

    val GenericContainer<*>.containerUrl: String
        get() = "http://$containerIpAddress:$firstMappedPort"

    val wiremockContainer = GenericContainer<Nothing>(
        "rodolpheche/wiremock:$wiremockVersion"
    ).apply {
        withExposedPorts(8080)
        withCommand("--verbose", "--global-response-templating")
        withClasspathResourceMapping("wiremock", "/home/wiremock", BindMode.READ_ONLY)
    }

    val wiremockClient: WireMock by lazy {
        WireMock.create()
            .host(wiremockContainer.containerIpAddress)
            .port(wiremockContainer.firstMappedPort)
            .build()
    }

    val retrofitJson: Retrofit by lazy {
        HttpUtil.createRetrofit(
            wiremockContainer.containerUrl, 
            jsonConverter,
            listOf(allureInterceptor, slf4jInterceptor)
        )
    }

    val employeeService: EmployeeService by lazy { retrofitJson.create() }

    override fun listeners(): List<Listener> = listOf(
        wiremockContainer.perProject("wiremock")
    )

}

parallelism — это настройка Kotest, здесь указываем размер пула потоков для тестовых классов. Тесты внутри тестовых классов будут выполняться в одном потоке.

wiremockVersion — версия Wiremock из системной переменной, которую мы передали из build.gradle.

containerUrl — расширение для типа GenericContainer, чтобы собрать базовый URL доступа к контейнеру с хост машины.

wiremockContainer — описание контейнера Wiremock. В конструкторе задаем образ, а далее настройки:

  • withExposedPorts(8080) — пробрасываем внутренний порт 8080 на внешний случайный;
  • withCommand — добавляем аргументы к запуску сервер. –verbose — включить вывод логов. –global-response-templating — включить движок шаблонов Wiremock, для обработки выражений вида {{ function }};
  • withClasspathResourceMapping — смонтировать содержимое папки wiremock/ из тестовых ресурсов в нашем проекте на внутреннюю директорию контейнера /home/wiremock — отсюда Wiremock прочитает все описания заглушек. BindMode.READ_ONLY — означает, что во время работы Wiremock не сможет изменит содержание нашей локальной папки, даже если почистит смонтированную директорию в контейнере.

wiremockClient — создаём клиент для настройки сервера Wiremock из кода тестов. Здесь используется делегат lazy для обеспечения ленивой инициализации по первому запросу, а в последующие запросы возвращает вычисленное значение и делает это потокобезопасно. Без lazy работать не будет, так как на момент создания нашей конфигурации контейнер ещё не запущен, и у него нет адреса и порта. А первый запрос делает из прогона теста, когда контейнер уже поднят.

retrofitJson — создаем инстанс Retrofit c базовым URL на контейнер, json конвертором и двумя перехватчиками. Также используем lazy.

employeeService — создаём реализацию интерфейса для доступа к сервису employee, так же в одном экземпляре.

listeners() — расширяем Kotest двумя слушателями.

  • wiremockContainer.perProject — контроль за жизненным циклом контейнера, в данном случае один запуск на весь прогон (есть и другие варианты)

Конфигурация готова! Переходим к созданию тестов!

Создаём тестовый класс

У нас будет пример с одним тестовым классом и двумя тестами внутри.

Kotest оперирует понятием спецификация, а не тестовый класс. Мы тоже будем с этого момента использовать понятие спецификация, а в голове держать, что это тоже самое, что тестовый класс. А тест мы будем называть сценарием. Внутри сценария будут шаги в BDD стиле Дано-Когда-Тогда.

Делаем заготовку для двух сценариев:

@Epic("Tproger example")
@Feature("Employee endpoint")
@Story("CRUD")
@Link("tproger.ru", url = "https://tproger.ru/")
class EmployeeSpec : FreeSpec() {

    override fun concurrency(): Int = 2

    init {
        "Scenario: Getting employee by id" - { }

        "Scenario: Creating new employee" - { }
    }
}

Спецификацию наследуем от FreeSpec. Это один из стилей написания тестов, который я предпочитаю использовать. Для простых unit-тестов рекомендую брать StringSpec.

Allure аннотации над тестом нужно для упорядочивания в отчете и связей с внешними системами контроля задач, требованиё и тестовых сценариев (приведены далеко не все):

  • @Epic — верхний уровень иерархии тестов и сущностей в Jira;
  • @Feature — тестируемый функционал;
  • @Story — минимальный законченный кусочек функционала с бизнес смыслом;
  • @Link — ссылка на требования либо несколько ссылок — @Links.

concurrency() — устанавливаем количество одновременно запущенных сценариев. Отмечу, что запускаться они будут в одном потоке, но асинхронно. Как? Благодаря использованию coroutines в Kotest — каждый сценарий и его шаги это suspend-функции и запускаются в корутинах, таким образом Kotlin занимается управлением последовательностью команд в конкурентной среде. Фактически в тесте у нас есть неблокирующий вызов ввода/вывода при выполнении запроса к серверу и ожидании ответа, на который и тратится основное время (и не один). Это время простоя в ожидании ответа от сервера и позволяет выполнять тесты асинхронно в одном потоке и более эффективно использовать процессорное время!

Блок init — в этом блоке мы собираем нашу тестовую модель. Сейчас там два сценария без реализации. Синтаксис «строка» — { } создаёт контейнер для будущих шагов сценария (обратите внимание, что используется оператор minus).

Уже сейчас можно запустить пустую модель и получить отчет, например для проведения review.

Добавляем первый сценарий:

"Scenario: Getting employee by id" - {

            var expectedId = 0
            "Given test environment is up and test data prepared" {
                expectedId = Arb.positiveInts().next()
            }

            lateinit var response: Response<Employee>
            "When client sent request to get the employee by id=$expectedId" {
                response = employeeService.get(expectedId).awaitResponse()
            }

            "Then client received response with status 200 and id=$expectedId" {
                response.asClue {
                    it.code() shouldBe 200
                    it.body().shouldNotBeNull().asClue { employee ->
                        employee.name shouldBe "Max"
                        employee.id shouldBe expectedId
                    }
                }
            }
        }

В первом шаге используем генератор случайных данных Arb из библиотек Kotest.

Во втором шаге берём из Registry объектов employeeService, — вызов get() отдает объект Call<Employee>, а неблокирующий метод awaitResponse() возвращает результат Response<Employee>, который сохраняем в response.

В третьем шаге проверяем ответ:

  • response.asClue — если внутри блока asClue случится ошибка проверки, то в дополнении в ожидаемому и актуальному результату в сообщение AssertionError будет добавлен вывод toString() целого объекта response;
  • it.code() shouldBe 200 — проверяем статус код. shouldBe — базовая проверка из библиотеки ассертов Kotest;
  • it.body().shouldNotBeNull().asClue — получаем тело, проверяем на null, при этом не используем !! , так как хотим получить AssertionError с полноценным описанием ошибки, а не NPE. Далее вызов asClue  необходим, чтобы добавить в сообщение об ошибке весь объект employee, так как toString() вышестоящего response не выводит тело, а лишь метаинформацию;
  • далее проверяем каждое поле employee.

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

Теперь сценарий на создание сотрудника:

"Scenario: Creating new employee" - {

            lateinit var employee: Employee
            "Given test environment is up and test data prepared" {
                employee = Employee(
                    name = Arb.stringPattern("[A-Z]{1}[a-z]{5,10}").next()
                )
            }

            lateinit var response: Response<Employee>
            val basic = Credentials.basic("user", "user")
            "When client sent request to create new $employee" {
                response = employeeService.create(basic, employee).awaitResponse()
            }

            "Then server received request with $employee" {
                wiremockClient.verifyThat(
                    1,
                    WireMock.postRequestedFor(WireMock.urlPathEqualTo("/employee"))
                        .withHeader("Authorization", WireMock.equalTo(basic))
                )
            }

            "And client received response with status 200 and $employee" {
                response.asClue {
                    it.code() shouldBe 200
                    it.body().shouldNotBeNull().asClue { e ->
                        e.name shouldBe employee.name
                        e.id.shouldNotBeNull().shouldBePositive()
                    }
                }
            }
        }

В первом шаге используем генерацию строки по регулярному выражению Arb.stringPattern. Проще подключить Faker для генерации красивых имен, но сейчас нам не нужны лишние зависимости.

Во втором шаге отправляем запрос также как в первом тесте, но добавляем данные Basic авторизации в заголовок — Credentials.basic(«user», «user»)

Третий шаг — проверяем, что пришло на сервер заглушек. Метод  wiremockClient.verifyThat отправляет запрос на сервер Wiremock, чтобы удостовериться, пришёл ли туда ровно один ожидаемый запрос с URL-path равным /employee и заголовком Authorization со значением созданной ранее Basic авторизацией. Часто этой проверкой пренебрегаю. Если Wiremock используется для эмуляции интеграции с другим сервисом, то крайне важно верифицировать все пришедшие запросы! 

И четвертый шаг почти полностью повторяет последний шаг из первого теста.

Запускаем и генерируем отчет

Всё готово к первому запуску. Осталось проверить, что в системе запущен Docker и есть разрешение на монтирование диска, где расположен проект. Ну и установлен JDK не ниже 11 версии. И ещё нужно установить в Idea плагин для Kotest, чтобы наблюдать результаты выполнения в динамике.

Выполняем в нашем проекте задачу gradle test из группы verification через Gradle Tools в Idea и наблюдаем за результатом:

Обратите внимание на ход тестирования — оба сценария будут выполняться одновременно.

Осталось сгенерировать отчет Allure.

Выполняем gradle allureReport из группы other также через Gradle Tools. Результат можно наблюдать в папке build/reports/allure-report, открыв index.html в браузере. Но обязательно через контекстное меню, так как для корректного отображения отчета необходим web-сервер, который нам любезно обеспечивает Idea. Есть вариант выполнить gradle allureServe, — тогда web-сервер запустит Gradle плагин.

ПКМ на index.html

А вот как будет выглядеть отчет о тестировании:

Самый удобный вид для просмотра тестовой модели и результатов — Behaviors в левом меню. Два наших сценария находятся в конце структуры Epic -> Feature -> Story, которые мы описали в виде аннотация в тесте. В правой области расположено содержимое теста, а также присутствует активная ссылка из аннотации @Link и два вложения от allureInterceptor для http клиента: Request и Response. Обычно такие отчёты хранятся в специальном плагине для CI либо на отдельном сервере отчётов, где можно собирать метрики и анализировать результаты.

На этом всё!

Заключение

Готовый проект, с написанным выше тестом, вы найдёте в моём GitHub. В итоге мы создали полноценный проект, готовый к масштабированию на сотни и тысячи API тестов.

Мы настроили Gradle сборку с плагинами для поддержки Kotlin и Allure отчётов. Сконфигурировали запуск тестов и максимально упростили работу с версиями зависимостей. Мы научились настраивать сервер заглушек Wiremock и запускать его в контейнере из кода тестов. Освоили Retrofit и дополнения к нему.

А самое главное научились быстро писать тесты с помощью тестового движка Kotest, генерировать тестовые данные и запускать сценарии параллельно.

Успехов вам в освоении новых технологий!