Connekt — это HTTP-клиент с открытым исходным кодом, который удобно встраивается в IDE на базе IntelliJ IDEA. Поставляется вместе с плагином Amplicode. Он помогает тестировать crud-приложения с помощью скриптов и готовить тестовые данные для дальнейшего ручного тестирования. Connekt призван расширить возможности привычных нам Postman и HTTP-клиента от Jet Brains. Postman имеет похожие возможности, но тесты там пишут на JavaScript, что для кого-то может быть неудобно. Кроме того, в Postman нет тесной связи с IDE. HTTP-клиент от Jet Brains не позволяет делать сложные тесты с использованием результатов предыдущих запросов, в нём отсутствует удобный Kotlin DSL. Connekt поддерживает сложные сценарии OAuth2-авторизации, переключая вас прямо в браузер, а также использование SSL-сертификатов, скачивание и загрузку файлов.

Инструмент находится на ранней стадии разработки (на момент написания статьи — версия 0.2.10 от 17 июля 2025 г.), тем не менее он уже обеспечивает удобство при работе в IDE и помогает оперативно решать потребности тестирования. Выполнение написанных сценариев можно также включать в ваш конвейер CI\CD.

Установка

Установить его очень просто: скачайте Amplicode с их сайта. Обратите внимание, что Connekt работает только в версии IDE 2025 г.

Скрипты можно создать в любом месте клиента, они получают расширение .connekt.kts.

Интерфейс

Интерфейс
Интерфейс

Интерфейс позволяет запустить все или выборочные сценарии из файла, выбрать окружение с переменными, импортировать Postman-коллекцию или запрос в форматe Intellij Client, справа есть ссылка на примеры кода. Также можно прямо из скрипта переходить к конечной точке вашего приложения.

Начнём с простых примеров.

Первый запрос

Разумеется, для использования API нужно получить токен. Если у вас есть постоянный токен, добавьте его в ваше окружение (если вам нужна авторизация, сделать это просто).

Для начала зарегистрируйте пользователя. Запрос для этого можно легко сгенерировать из приложения:

Генерирование запросов прямо из класса с конечной точкой
Генерирование запросов прямо из класса с конечной точкой

Получаем ответ со всеми заголовками и телом:

POST http://localhost:8080/api/auth/signup
Content-Type: application/json 
User-Agent: connekt/0.0.1 
Content-Length: 146 
Host: localhost:8080 
Connection: Keep-Alive 
Accept-Encoding: gzip

{
    "firstName": "Alex",
    "lastName": "Pushkin",
    "username": "AlexPushkin",
    "email": "apushkin@habr.ru",
    "password": "password"
}

HTTP/1.1 201 
Location: http://localhost:8080/api/users/1 
X-Content-Type-Options: nosniff 
X-XSS-Protection: 1; mode=block 
Cache-Control: no-cache, no-store, max-age=0, must-revalidate 
Pragma: no-cache 
Expires: 0 
X-Frame-Options: DENY 
Content-Type: application/json;charset=UTF-8 
Transfer-Encoding: chunked 
Date: Tue, 04 Nov 2025 11:32:15 GMT

{
  "success" : true,
  "message" : "User registered successfully"
}

Response file saved.
> C:\Users\ART PRONKIN\.connekt\response\2025-11-04T143215.json

Переменные окружения

Connekt поддерживает окружения, которые можно сохранять в обычном JSON-формате. Поэтому по всем канонам спрячем туда логин и пароль.

Меню выбора и редактирования окружения
Меню выбора и редактирования окружения
{
  "local": {
    "email": "apushkin@habr.ru",
    "password": "password"
  }
}

В консоли видим наш запрос и ответ сервера со всеми заголовками. Также прилагается ссылка на сохранённый ответ.

Теперь с помощью указанного ниже запроса мы можем получить токен:

val email: String by env
val password: String by env

val token by POST("http://localhost:8080/api/auth/signin") {
    header("Content-Type", "application/json")
    body(
        """
        {
            "usernameOrEmail": "$email",
            "password": "$password"
        }
        """.trimIndent()
    )
    } then {
        jsonPath().readString("$.accessToken")
    }

Получаем ответ:

{
  "accessToken" : "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwiaWF0IjoxNzYyMjU2MjcxLCJleHAiOjE3NjIyNTk4NzF9.3sj-ZtIWbkImWLPiLreR2hPKUx9DMSOl9QzzaoDH2tQLutOfDeNp_k3WRPruzIK5UWMqJEIqWekvaV7bRf4doQ",
  "tokenType" : "Bearer"
}

Парсинг ответов с помощью JsonPath

С помощью jsonPath можно легко извлечь нужную часть ответа, не прибегая к сериализации всего объекта. Ссылка на документацию JsonPath.

jsonPath().readString("$.accessToken")

Кеширование запросов

Обратите внимание, что для сохранения ответа мы используем by, а не =.

val token by POST("http://localhost:8080/api/auth/signin")

Использование = здесь не сработает так, как вы, возможно, ожидаете. Если написать:

val token = POST("http://localhost:8080/api/auth/signin")

то в переменную сохранится не строка с токеном, а объект типа io.amplicode.connekt.MappedRequestHolder, что, по сути, является неким callback нашего запроса, отложенным вызовом. Это сделано для кеширования и ленивой инициализации переменных. Самостоятельно вызвать этот callback в скрипте не удастся. При необходимости выполнить запрос из коллекции, зависящий от данных другого запроса (например, токен авторизации), не требуется инициализация переменной. Надо просто вызвать ваш запрос и Connekt всё сделает сам. Результат сохранится в env вашего Connekt-скрипта, и повторные запросы не будут выполняться. В этом мы можем убедиться по журналу вызова. Если выполнить последовательно несколько вызовов, токен останется без изменений.

Авторизация

Для дальнейшего использования токена есть специальная функция bearerAuth, но вы можете указывать и обычный заголовок:

GET("http://localhost:8080/api/users/me") {
    bearerAuth(token)
}

Каждый раз мы будем видеть тот же токен:

GET http://localhost:8080/api/users/me
Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwiaWF0IjoxNzYyMjU2MjcxLCJleHAiOjE3NjIyNTk4NzF9.3sj-ZtIWbkImWLPiLreR2hPKUx9DMSOl9QzzaoDH2tQLutOfDeNp_k3WRPruzIK5UWMqJEIqWekvaV7bRf4doQ 
{
  "id" : 1,
  "username" : "alexpushkin",
  "firstName" : "alex",
  "lastName" : "pushkin"
}

Принудительное обновление переменных

Если вам потребуется инициализировать переменную заново, следует просто выполнить запрос с получением её значения:

val token by POST("http://localhost:8080/api/auth/signin")

После его выполнения токен обновится.

Далее понадобится создать категорию для наших постов. Добавляем и сохраняем:

val categoryId by POST("http://localhost:8080/api/categories") {
    header("Content-Type", "application/json")
    bearerAuth(token)
    body(
        """
        {
            "name": "Books"
        }
        """.trimIndent()
    )

    } then {
        jsonPath().readInt("$.id")
    }

Теперь, наконец, напишем тест, создадим новый пост, используя существующую категорию, и выполним проверки с помощью библиотеки Assertj (документация).

Тесты

Параметры postBody и postTitle я передам в виде аргументов из env. Согласно бизнес-логике нашего приложения, создавать посты с одинаковым заголовками не получится, поэтому можно легко добавить генерирование случайного числа для главы.


val postTitle: String by env
val postBody: String by env

POST("http://localhost:8080/api/posts") {
    header("Content-Type", "application/json")
    bearerAuth(token)
    body(
        """
        {
            "title": "$postTitle Глава ${Random.nextInt(0, 50)}",
            "body": "$postBody",
            "categoryId": $categoryId,
            "tags": ["Пушкин"]
        }
        """.trimIndent()
    )
    } then {
        Assertions.assertThat(code).isEqualTo(201)
        jsonPath().doRead("$.category").also { Assertions.assertThat(it).isEqualTo("Books") }
        jsonPath().doRead>("$.tags").also { Assertions.assertThat(it).contains("Пушкин") }
    }

Сериализация в Kotlin-классы

Не обязательно использовать jsonPath. Можно целиком сериализовать наш ответ для дальнейших проверок. Для это создадим класс или просто скопируем его из нашего приложения. К сожалению, импортировать из соседних скриптов пока невозможно, поэтому придётся описать объект в этом же файле.

class PostResponse {
    val title: String? = null
    val body: String? = null
    val category: String? = null
    var tags: List? = null
}

POST("http://localhost:8080/api/posts") {
    header("Content-Type", "application/json")
    bearerAuth(token)
    body(
        """
        {
            "title": "$postTitle Глава ${Random.nextInt()}",
            "body": "$postBody",
            "categoryId": $categoryId,
            "tags": ["Пушкин"]
        }
        """.trimIndent()
    )
    } then {
        Assertions.assertThat(code).isEqualTo(201)
        jsonPath().doRead("$").also { post ->
            Assertions.assertThat(post.category).isEqualTo("Books")
            Assertions.assertThat(post.tags).contains("Пушкин")
        }
}

Использование useCase и функций

Теперь рассмотрим функцию useCase. Ей можно дать имя, описать в ней сценарий и запустить целиком. Важно отметить, что эта возможность не описана в документации к Connekt: мы можем обернуть наши запросы в функции, что позволит параметризировать их и использовать повторно.

Помимо тестирования, Connekt может служить для агрегации данных из нескольких источников, автоматизации рутинной работы с API и анализа ответов этих API. Например, в нашем приложении не реализован поиск по тегам и категориям постов, а для POST-запросов всегда требуется указывать идентификатор нужной категории поста.

Без рабочего фронтенда подготовка данных, ручное тестирование и поиск по JSON-ответам могут быть утомительным занятием. Поэтому напишем сценарий, который решает эту задачу и покажет, как эффективно подготовить ваше приложение к тестированию, предварительно наполнив его данными.

Код
useCase("Поиск по тегу и категории") {

    data class Tag(
        val name: String
    )

    data class Category(
        val name: String,
        val id: Long
    )

    data class Post(
        val tags: List,
        val category: Category,
        val title: String
    )

    val token by POST("http://localhost:8080/api/auth/signin") {
        header("Content-Type", "application/json")
        body(
            """
        {
            "usernameOrEmail": "example@mail.ru",
            "password": "password"
        }
        """.trimIndent()
        )
        } then {
            jsonPath().readString("$.accessToken")
        }

    fun addCategory(category: String): Category {
        return POST("http://localhost:8080/api/categories") {
            header("Content-Type", "application/json")
            bearerAuth(token)
            body(
                """
            {
                "name": "$category"
            }
            """.trimIndent()
                )
            } then {
                jsonPath().doRead("$")
            }
    }

    fun findByTagAndName(categoryName: String, tagName: String): List {
        return GET("http://localhost:8080/api/posts") {
            bearerAuth(token)
            queryParam("page", "0")
            queryParam("size", "10")
            } then {
                jsonPath().doRead>("$.content")
                    .filter {
                        it.tags.any { tag -> tag.name == tagName } && it.category.name == categoryName
                    }
            }
    }

    fun getAllCategory(): List {
        return GET("http://localhost:8080/api/categories") {
            bearerAuth(token)
            queryParam("page", "0")
            queryParam("size", "10")
            } then {
                jsonPath().doRead>("$.content")
            }
    }

    fun findCategory(category: String) = getAllCategory().firstOrNull { it.name == category }

    fun findPostByCategory(category: String): List? {
        return findCategory(category)?.let {
            GET("http://localhost:8080/api/posts/category/{id}") {
                bearerAuth(token)
                pathParam("id", it.id)
                queryParam("page", "0")
                queryParam("size", "10")
                } then {
                    jsonPath().doRead>("$.content")
                }
        }

    }

    fun addPost(postTitle: String, postBody: String, categoryName: String, tag: String) {
        val category = findCategory(categoryName) ?: addCategory(categoryName)
        POST("http://localhost:8080/api/posts") {
            header("Content-Type", "application/json")
            bearerAuth(token)
            body(
                """
            {
                "title": "$postTitle Глава ${Random.nextInt(0, 20)}",
                "body": "$postBody",
                "categoryId": ${category.id},
                "tags": ["$tag"]
            }
            """.trimIndent()
                )
            }
    }

    addCategory("It")
    addCategory("Business")
    addCategory("Life-style")


    addPost("Сергей Александрович Есенин", postBody, "Стихи", "Поэзия")
    addPost("Как начать свой бизнес", postBody, "Business", "История")
    addPost("История первых ЭВМ", postBody, "It", "История")
    addPost("Unix системы", postBody, "It", "Unix")

    val allCategory = getAllCategory()

    val resultByCategory = findPostByCategory("It")

    val resultByBooks = findByTagAndName("Стихи", "Поэзия")

    val resultByIt = findByTagAndName("It", "История")

    println("Все категории : ")
    allCategory.forEach { category -> println(category.name) }
    println("Поиск по тегу It : ")
    resultByCategory?.forEach { post -> println(post.tags.map { it.name } + post.title) }
    println("Поиск по категории Стихи и тегу Поэзия  : ")
    resultByBooks.forEach { post -> println(post.title) }
    println("Поиск по категории It и тегу История  : ")
    resultByIt.forEach { post -> println(post.title) }
}

Получаем следующий ответ:

Все категории : 
It
Business
Life-style
Стихи
Поиск по тегу It : 
[Unix, Unix системы Глава 3]
[История, История первых ЭВМ Глава 6]
Поиск по категории Стихи и тегу Поэзия  : 
Сергей Александрович Есенин Глава 17
Поиск по категории It и тегу История  : 
История первых ЭВМ Глава 6

Итоги

К достоинствам можно отнести:

  • Возможность работы прямо в IDE

  • Быстрое генерирование запросов прямо из класса с конечными точками

  • Интеграцию с отладчиком

  • Низкий порог входа

Однако есть и недостатки:

  • На текущем этапе невозможно импортировать код из других скриптов, что вынуждает нас писать всё в одном файле

  • Импортирование Postman-коллекций и других форматов реализовано неудовлетворительно

  • Документация слабо развита, а готовых примеров в сети практически нет

  • Периодически встречаются баги

Несмотря на эти недостатки, Connekt представляется интересным и перспективным проектом. Принципиальных проблем не обнаружено, и все выявленные моменты могут быть устранены по мере развития. Было бы очень интересно увидеть интеграцию Connekt и Kotlin notebook.

Рекомендации

Если результат запроса не требуется для дальнейших действий в сценарии, но важно убедиться в его корректности, всегда добавляйте проверку кода ответа. Это предотвратит сбои на более поздних этапах и сократит время на диагностику и отладку. В случае ошибки проверка остановит сценарий и зафиксирует несоответствие в журнале.

Привычный вид ошибки в журнале
Привычный вид ошибки в журнале

Чтобы избежать путанницы, всегда давайте вашим сценариям уникальные имена.

Если у вас есть опыт написания хороших промптов, попробуйте передать ИИ-агенту документацию Connekt и примеры из этой статьи для генерации сценариев тестирования. У меня это получалось, но не всегда стабильно.

Полезные ссылки

Более подробную информацию о Connekt вы можете получить на сайте Amplicode и в видеоразборе.

Инструкция по интеграции с CI/CD есть на Github.

Ссылка на код проекта.

Комментарии (0)