Kora облачно ориентированный серверный фреймворк написанный на Java для написания Java / Kotlin приложений с упором на производительность, эффективность, прозрачность сделанный выходцами из Т-Банк / Тинькофф

Kora is a cloud-oriented server-side Java framework for writing Java / Kotlin applications with a focus on performance, efficiency and transparency

Перейти к содержанию
V1 V2

Компонентное тестирование в Kora

Это руководство знакомит с основным подходом к тестированию приложений Kora с помощью JUnit. В нем рассматривается, как тестовые аннотации Kora создают управляемые графы приложения, как подмены и изменения графа изолируют компоненты, и как тесты с Testcontainers проверяют поведение на инфраструктуре, близкой к реальной. Вы также увидите, как тесты служб, контроллеров, интеграционные тесты и тесты по принципу черного ящика складываются в одну практичную стратегию тестирования.

Если в процессе захочется сверить результат, используйте готовое рабочее приложение: Kora Java Testing JUnit App.

Если в процессе захочется сверить результат, используйте готовое рабочее приложение: Kora Kotlin Testing JUnit App.

Что вы создадите

Вы создадите полноценный набор тестов, который покрывает:

  • компонентные тесты: проверку взаимодействия служб с настоящими зависимостями
  • интеграционные тесты: проверку с настоящими базами данных через Testcontainers
  • тестовые утилиты: переиспользуемую тестовую инфраструктуру и вспомогательные средства

Что понадобится

  • JDK 25 или новее
  • Gradle 9+ (эталонные приложения используют Gradle Wrapper 9.5.1)
  • текстовый редактор или среда разработки
  • пройденное руководство HTTP-сервер

Требования

Обязательно: пройдите руководство по HTTP-серверу

Это руководство предполагает, что вы уже прошли HTTP-сервер и у вас есть рабочий проект Kora с UserService, UserController, DTO и потоком работы через репозиторий в памяти.

Если вы еще не прошли руководство по HTTP-серверу, сначала сделайте это, потому что здесь тестируется уже существующее поведение службы и контроллера, а не создается приложение с нуля.

Обзор

Тестирование приложения Kora с JUnit начинается с выбора правильной границы для вопроса, на который должен ответить тест. Тест службы может ответить, корректно ли работает бизнес-логика. Интеграционный тест может ответить, работает ли служба с настоящей инфраструктурой. Тест по принципу черного ящика может ответить, корректно ли полноценное приложение ведет себя через открытый API.

Важное отличие от обычного модульного тестирования в том, что код Kora часто определяется графом приложения. Конструкторы, сгенерированные компоненты, конфигурация и модули фреймворка влияют на поведение, поэтому тесты должны явно показывать, какую часть графа они проверяют.

Уровни тестирования

Это руководство вводит основные уровни тестирования, которые используются в руководствах Kora:

  • компонентные тесты строят часть графа Kora и заменяют выбранные зависимости подменами
  • интеграционные тесты оставляют больше настоящих компонентов и добавляют инфраструктуру, например PostgreSQL через Testcontainers
  • тесты по принципу черного ящика запускают полноценное приложение и взаимодействуют с ним только через открытые HTTP API

Эти уровни не конкурируют друг с другом. Они по-разному соотносят скорость, изоляцию и уверенность. Компонентные тесты полезны для быстрой обратной связи и проверки сфокусированного поведения. Интеграционные тесты полезны, когда важны SQL, конфигурация, миграции или настоящие клиенты. Тесты по принципу черного ящика дают самую сильную уверенность на уровне пользователя, потому что включают маршрутизацию, сериализацию, конфигурацию, связывание фреймворка и инфраструктуру.

Тестовые графы Kora

Граф Kora, создаваемый во время компиляции, является важным инструментом тестирования. @KoraAppTest может запустить граф приложения для теста, а аннотации изменения графа могут заменять компоненты, внедрять подмены или добавлять компоненты только для тестов. Так тесты остаются близкими к настоящему связыванию приложения, но при этом не заставляют каждый тест запускать всю среду выполнения.

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

Контракты HTTP-контроллеров, HTTP-клиентов и JDBC-репозиториев в Kora 2.0 синхронные. Для тестов это прямое упрощение: компонентный тест вызывает метод службы и проверяет возвращенное значение — без CompletionStage, без реактивных издателей и без корутинных билдеров вокруг вызова. В Kotlin это значит, что тестовые методы остаются обычными fun, а заглушки MockK пишутся через every { ... } returns ..., а не через корутинный coEvery.

Testcontainers и реалистичность

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

Это руководство закладывает основу для последующих специальных руководств по тестированию: используйте компонентные тесты для сфокусированной обратной связи, интеграционные тесты для границ с инфраструктурой, а тесты по принципу черного ящика — как самую сильную проверку поведения полноценного приложения.

Практический ход такой:

  1. добавить зависимости JUnit, Mockito для Java, MockK для Kotlin и тестовые модули Kora
  2. объявить тестовый граф Kora
  3. заменить выбранные зависимости подменами
  4. проверить поведение службы через компоненты, управляемые графом
  5. добавить переопределения конфигурации для тестовых сценариев
  6. подготовить проект к более глубокому интеграционному тестированию и тестированию как черный ящик

Зависимости

Расширение Kora для JUnit 5 живет в артефакте io.koraframework:test-junit5. Его версия берется из платформы io.koraframework:kora-bom, поэтому BOM должен быть виден не только конфигурации implementation, но и тестовым конфигурациям.

Добавьте следующие тестовые зависимости в build.gradle:

build.gradle
dependencies {
    // ... existing dependencies ...

    testAnnotationProcessor "io.koraframework:annotation-processors" //(1)!

    testImplementation platform("org.junit:junit-bom:6.1.3") //(2)!
    testImplementation "org.junit.jupiter:junit-jupiter"
    testImplementation project(":guide-http-server-app") //(3)!
    testImplementation "io.koraframework:http-server-undertow"

    // Kora testing framework with JUnit 5 integration
    testImplementation "io.koraframework:test-junit5" //(4)!

    // Mocking framework for component testing
    testImplementation "org.mockito:mockito-core:5.23.0" //(5)!
}

test {
    useJUnitPlatform()
    testLogging {
        showStandardStreams(true)
        events("passed", "skipped", "failed")
        exceptionFormat("full")
    }
}
  1. Обработчик аннотаций Kora в тестовой области. Он начинает что-то генерировать только тогда, когда тестовые исходники сами объявляют аннотации Kora — например, собственный @KoraApp или @Repository. Тестовые исходники компилируются отдельной задачей, поэтому запись annotationProcessor основного набора исходников на них не распространяется.
  2. BOM JUnit 6. Kora 2.0 собирается и тестируется с JUnit 6.1.3; пакеты org.junit.jupiter.api при этом не изменились со времен JUnit 5.
  3. Модуль, которому принадлежит тестируемый @KoraApp. Без него графа приложения для сборки просто нет.
  4. Расширение Kora для JUnit 5: @KoraAppTest, @TestComponent, модификаторы конфигурации и графа.
  5. Mockito. test-junit5 не зависит ни от Mockito, ни от MockK, поэтому фреймворк подмен объявляет сам тестовый модуль.

Добавьте следующие тестовые зависимости в build.gradle.kts:

build.gradle.kts
dependencies {
    // ... existing dependencies ...

    testImplementation(platform("org.junit:junit-bom:6.1.3")) //(1)!
    testImplementation("org.junit.jupiter:junit-jupiter")
    testImplementation(project(":guide-http-server-app")) //(2)!
    testImplementation("io.koraframework:http-server-undertow")

    // Kora testing framework with JUnit 5 integration
    testImplementation("io.koraframework:test-junit5") //(3)!

    // Mocking framework for Kotlin component testing
    testImplementation("io.mockk:mockk:1.14.11") //(4)!
}

tasks.test {
    useJUnitPlatform()
    testLogging {
        showStandardStreams = true
        events("passed", "skipped", "failed")
        exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
    }
}
  1. BOM JUnit 6. Kora 2.0 собирается и тестируется с JUnit 6.1.3; пакеты org.junit.jupiter.api при этом не изменились со времен JUnit 5.
  2. Модуль, которому принадлежит тестируемый @KoraApp. Без него графа приложения для сборки просто нет.
  3. Расширение Kora для JUnit 5: @KoraAppTest, @TestComponent, модификаторы конфигурации и графа.
  4. MockK. test-junit5 не зависит ни от MockK, ни от Mockito, поэтому фреймворк подмен объявляет сам тестовый модуль. Mockito тоже работает в Kotlin, и эталонное приложение использует именно его; MockK показан здесь потому, что его DSL избавляет от экранированного вызова `when`.

В этом модуле нет строки kspTest("io.koraframework:symbol-processors"). Обрабатывать KSP здесь нечего: тестовый класс только использует Application и его компоненты, а ApplicationGraph уже был сгенерирован при компиляции модуля HTTP-сервера. Добавляйте kspTest только тогда, когда аннотации Kora объявлены в самих тестовых исходниках — именно так делает руководство Интеграционное тестирование со своим @KoraApp.

Byte Buddy и тулчейн Java 25

Mockito 5.23.0 транзитивно подтягивает net.bytebuddy:byte-buddy 1.17.7, тогда как Kora 2.0 собирается и тестируется с 1.18.11. Подмены генерируются Byte Buddy поверх классов, скомпилированных тулчейном Java 25, поэтому зафиксируйте в тестовом модуле более новую версию и держите ее согласованной с фреймворком. То же относится к MockK, который использует Byte Buddy через mockk-agent. Готовый фрагмент Gradle есть в разделе Устранение неполадок.

Компонентные тесты

Компонентный тест в Kora находится между простой проверкой отдельного класса и полноценным запуском приложения. Вы не создаете UserService руками через new и не собираете его зависимости вручную. Вместо этого тест просит Kora построить небольшой тестовый граф, достать из него нужный компонент и внедрить этот компонент в тестовый класс.

Это важно по двум причинам:

  • тест проверяет тот же способ связывания зависимостей, который используется в приложении
  • граф можно ограничить только теми компонентами, которые нужны конкретному тесту
  • такой обрезанный граф собирается очень быстро, потому что @KoraAppTest не инициализирует лишние ветки приложения
  • по умолчанию тестовый граф создается заново для каждого тестового метода, если вы явно не выбрали другой жизненный цикл JUnit

В этом руководстве мы сначала подключим настоящий UserService как тестовый компонент. Потом заменим его зависимость UserRepository моком и посмотрим, как такая подмена попадает в граф.

Тестовый компонент

Начните с самого простого варианта: попросите Kora дать тесту настоящий компонент UserService.

@KoraAppTest(Application.class) говорит JUnit-расширению Kora, какой @KoraApp использовать как источник графа. Это не означает, что тест обязательно поднимет все приложение целиком. Тестовый граф ограничивается компонентами, которые вы явно запросили через @TestComponent, и зависимостями, которые нужны этим компонентам.

@TestComponent на поле userService означает две вещи одновременно:

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

То есть Kora начинает с UserService, смотрит его конструктор, находит нужные зависимости и добавляет только необходимую часть графа. Если UserService зависит от UserRepository, то репозиторий тоже попадет в тестовый граф. Если HTTP-сервер, контроллеры или другие компоненты не нужны для создания UserService, они не обязаны инициализироваться в таком компонентном тесте.

У @KoraAppTest есть еще два атрибута для случаев, когда типов полей недостаточно: components() принудительно добавляет компоненты в граф, даже если тест на них нигде не ссылается, а modules() дополняет список модулей, найденных по типу @KoraApp. Оба полезны для компонентов, которые существуют только ради выполнения на старте и никуда не внедряются.

Создайте src/test/java/io/koraframework/guide/testingjunit/UserServiceComponentTest.java:

package io.koraframework.guide.testingjunit;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;

import org.junit.jupiter.api.Test;
import io.koraframework.guide.httpserver.Application;
import io.koraframework.guide.httpserver.dto.UserRequest;
import io.koraframework.guide.httpserver.service.UserService;
import io.koraframework.test.extension.junit5.KoraAppTest;
import io.koraframework.test.extension.junit5.TestComponent;

@KoraAppTest(Application.class)
class UserServiceComponentTest {

    @TestComponent
    private UserService userService;

    @Test
    void createUserWithRealGraph() {
        var request = new UserRequest("John", "john@example.com");

        var result = userService.createUser(request);

        assertNotNull(result);
        assertEquals("John", result.name());
        assertEquals("john@example.com", result.email());
    }
}

Создайте src/test/kotlin/io/koraframework/guide/testingjunit/UserServiceComponentTest.kt:

package io.koraframework.guide.testingjunit

import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Test
import io.koraframework.guide.httpserver.Application
import io.koraframework.guide.httpserver.dto.UserRequest
import io.koraframework.guide.httpserver.service.UserService
import io.koraframework.test.extension.junit5.KoraAppTest
import io.koraframework.test.extension.junit5.TestComponent

@KoraAppTest(Application::class)
class UserServiceComponentTest {

    @TestComponent
    lateinit var userService: UserService

    @Test
    fun createUserWithRealGraph() {
        val request = UserRequest("John", "john@example.com")

        val result = userService.createUser(request)

        assertNotNull(result)
        assertEquals("John", result.name)
        assertEquals("john@example.com", result.email)
    }
}

На этом шаге нет мока. UserService настоящий, его зависимости тоже настоящие, если Kora может построить их из приложения. Такой тест полезен, когда зависимость дешевая и полностью локальная: например, репозиторий из HTTP-гайда хранит данные в памяти и не требует внешней базы данных.

Жизненный цикл тестового графа

Когда JUnit запускает тестовый класс с @KoraAppTest, расширение Kora делает несколько вещей до выполнения тестового метода:

  1. берет Application из @KoraAppTest
  2. строит тестовую версию графа приложения
  3. оставляет в графе компоненты, запрошенные через @TestComponent, и их зависимости
  4. применяет тестовые подмены, если они объявлены
  5. инициализирует нужные компоненты графа
  6. внедряет готовые компоненты в поля, конструктор или параметры тестовых методов

После этого выполняется обычный JUnit-тест. Для тестового метода userService уже является не null, а готовым компонентом из графа Kora.

Практически это означает следующее:

  • конструктор UserService вызывается Kora, а не тестом
  • зависимости UserService берутся из того же описания приложения, что и в реальном графе
  • компоненты, которые не нужны запрошенной части графа, не создаются только ради теста
  • если у компонента есть логика жизненного цикла, она выполняется в рамках инициализации тестового графа
  • после завершения тестового контекста Kora закрывает инициализированные ранее компоненты
  • по умолчанию контейнер создается с нуля для каждого тестового метода; документация @KoraAppTest предлагает @TestInstance(TestInstance.Lifecycle.PER_CLASS), когда достаточно одного графа на класс

Такой тест отвечает на вопрос: “может ли Kora построить нужную часть приложения, и ведет ли себя настоящий компонент правильно?” Но иногда настоящая зависимость мешает сфокусированной проверке. Например, вы хотите проверить сортировку, обработку 404 или вызов репозитория, не завися от состояния хранилища. Тогда нужна подмена.

Мок компонента

Мок — это тестовая замена настоящего компонента. Он выглядит для графа как обычный компонент нужного типа, но его поведение задается в тесте.

Конкретный фреймворк моков зависит от языка, но роль подмены остается одинаковой:

  • аннотация мок-фреймворка создает мок нужного типа
  • @TestComponent сообщает Kora, что этот мок является компонентом тестового графа
  • тип поля UserRepository говорит, какой компонент приложения нужно заменить

Самое важное: мок попадает не только в поле тестового класса. Kora также внедряет этот же мок во все компоненты графа, которым нужен UserRepository. Поэтому UserService остается настоящим, но его конструктор получает уже не настоящий in-memory репозиторий, а тестовую подмену.

Граф для такого теста можно представить так:

test field userRepository
        |
        | same mock instance
        v
UserService ---depends on--- UserRepository
   ^                         ^
   |                         |
@TestComponent          mock @TestComponent
real component          replacement component

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

  • через userRepository можно задать ответы зависимости
  • через userService можно вызвать настоящий код службы и проверить результат

Расширение Kora распознает аннотации подмен по имени: @Mock и @Spy из Mockito, @MockK и @SpyK из MockK. Оба фреймворка работают из любого языка; выберите один на модуль и придерживайтесь его. Созданные так моки Mockito по умолчанию работают со строгостью Strictness.WARN, а @MockitoStrictness из io.koraframework.test.extension.junit5.mockito повышает или понижает ее для всего тестового класса. Подробности — в разделе Строгость моков.

В Java используйте Mockito: @Mock создает мок UserRepository, а when(...).thenReturn(...) задает ответ для конкретного вызова зависимости.

Обновите тестовый класс:

package io.koraframework.guide.testingjunit;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import java.time.LocalDateTime;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.mockito.Mock;
import io.koraframework.guide.httpserver.Application;
import io.koraframework.guide.httpserver.dto.UserResponse;
import io.koraframework.guide.httpserver.repository.UserRepository;
import io.koraframework.guide.httpserver.service.UserService;
import io.koraframework.test.extension.junit5.KoraAppTest;
import io.koraframework.test.extension.junit5.TestComponent;

@KoraAppTest(Application.class)
class UserServiceComponentTest {

    @Mock
    @TestComponent
    private UserRepository userRepository;

    @TestComponent
    private UserService userService;

    @Test
    void getUserUsesRepositoryMock() {
        var expected = new UserResponse("1", "John", "john@example.com", LocalDateTime.now());
        when(userRepository.findById("1")).thenReturn(Optional.of(expected));

        var result = userService.getUser("1");

        assertEquals(Optional.of(expected), result);
        verify(userRepository).findById("1");
    }
}

В Kotlin используйте MockK: @MockK создает мок UserRepository, а every { ... } returns ... задает ответ для конкретного вызова зависимости без экранирования ключевых слов Kotlin.

Обновите тестовый класс:

package io.koraframework.guide.testingjunit

import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import io.mockk.every
import io.mockk.impl.annotations.MockK
import io.mockk.verify
import io.koraframework.guide.httpserver.Application
import io.koraframework.guide.httpserver.dto.UserResponse
import io.koraframework.guide.httpserver.repository.UserRepository
import io.koraframework.guide.httpserver.service.UserService
import io.koraframework.test.extension.junit5.KoraAppTest
import io.koraframework.test.extension.junit5.TestComponent
import java.time.LocalDateTime

@KoraAppTest(Application::class)
class UserServiceComponentTest {

    @MockK
    @TestComponent
    lateinit var userRepository: UserRepository

    @TestComponent
    lateinit var userService: UserService

    @Test
    fun getUserUsesRepositoryMock() {
        val expected = UserResponse("1", "John", "john@example.com", LocalDateTime.now())
        every { userRepository.findById("1") } returns expected

        val result = userService.getUser("1")

        assertEquals(expected, result)
        verify { userRepository.findById("1") }
    }
}

Теперь тест проверяет не репозиторий, а поведение UserService при заданном ответе репозитория. Это и есть основная ценность мока: вы фиксируете поведение зависимости и проверяете реакцию компонента, который находится над ней.

Обратите внимание на форму заглушенного вызова. UserRepository.findById — синхронный метод, который возвращает Optional<UserResponse> в Java и UserResponse? в Kotlin, поэтому заглушка возвращает обычное значение. У контрактов репозиториев и HTTP в Kora 2.0 нет асинхронных вариантов, так что ждать в тесте нечего и корутинную область открывать не нужно.

Что именно инициализируется

В тесте с мокнутым UserRepository граф становится меньше и понятнее:

  • UserService создается как настоящий компонент приложения
  • UserRepository заменяется тестовым моком
  • компоненты, которые нужны только настоящему UserRepository, больше не нужны и не попадают в тестовый граф
  • HTTP-сервер и контроллеры не создаются, если вы не запросили их как @TestComponent
  • тестовый класс получает ссылки на оба компонента: настоящий userService и мокнутый userRepository

Это отличается от обычного ручного компонентного теста с моками без Kora. В таком тесте вы часто пишете new UserService(userRepository) руками. В Kora-тесте это делает граф. Поэтому тест одновременно проверяет:

  • что UserService действительно является компонентом графа
  • что Kora умеет собрать его с нужной зависимостью
  • что подмена зависимости применяется в том же месте, где production-граф использовал бы настоящий компонент
  • что бизнес-логика службы работает при заданных ответах зависимости

Не используйте отдельное JUnit-расширение мок-фреймворка вместе с @KoraAppTest. Жизненным циклом моков и их внедрением в граф управляет @KoraAppTest. Если подключить второе JUnit-расширение, становится неочевидно, кто создает мок, кто сбрасывает его состояние и какой экземпляр попадает в граф.

Написание тестов

Теперь добавьте проверки основного поведения службы с подмененным репозиторием. В каждом тесте есть три части: сначала вы задаете поведение зависимости, затем вызываете настоящий UserService, а после этого проверяете результат и факт обращения к репозиторию.

Разберем первый тест. assertNotNull(result) проверяет, что служба вообще вернула ответ, а не null. assertEquals("1", result.id()), assertEquals("John", result.name()) и проверка email фиксируют контракт ответа: идентификатор пришел из репозитория, а имя и почта перенесены из запроса без искажений. Проверка вызова репозитория дополняет asserts: она проверяет уже не значение результата, а взаимодействие с зависимостью.

В примерах ниже используются стандартные assertions из JUnit Jupiter: assertEquals, assertNotNull, assertTrue, assertThrows. Для более выразительных проверок в реальных проектах можно подключить AssertJ и писать проверки в стиле assertThat(result.name()).isEqualTo("John"), assertThat(result).isNotNull() или assertThatThrownBy { ... }.

Отдельного внимания заслуживает случай с 404. UserService.deleteUser сообщает об отсутствующем пользователе, бросая HttpServerResponseException из пакета io.koraframework.http.server.common.response. Метод code() возвращает HTTP-статус, которым ответило бы приложение, поэтому компонентный тест может проверить транспортный итог, не поднимая HTTP-сервер.

В Java-версии поведение мока задается через Mockito: when(...).thenReturn(...) говорит, что должен вернуть репозиторий при конкретном вызове. verify(userRepository).save(...) проверяет, что настоящий UserService действительно обратился к репозиторию с ожидаемыми аргументами. Это полезно, когда результат метода важен, но не менее важно убедиться, что служба использует правильную зависимость и не пропускает нужное действие.

Добавьте импорты:

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.util.List;
import io.koraframework.guide.httpserver.dto.UserRequest;
import io.koraframework.http.server.common.response.HttpServerResponseException;

Добавьте тестовые методы:

@Test
void createUser_ShouldCreateAndReturnUser() {
    var request = new UserRequest("John", "john@example.com");

    when(userRepository.save("John", "john@example.com")).thenReturn("1");

    var result = userService.createUser(request);

    assertNotNull(result);
    assertEquals("1", result.id());
    assertEquals("John", result.name());
    assertEquals("john@example.com", result.email());
    verify(userRepository).save("John", "john@example.com");
}

@Test
void getUser_ShouldReturnUserWhenExists() {
    var expected = new UserResponse("1", "John", "john@example.com", LocalDateTime.now());
    when(userRepository.findById("1")).thenReturn(Optional.of(expected));

    var result = userService.getUser("1");

    assertTrue(result.isPresent());
    assertEquals(expected, result.get());
    verify(userRepository).findById("1");
}

@Test
void getUsers_ShouldReturnPagedUsers() {
    var users = List.of(
            new UserResponse("2", "Jane", "jane@example.com", LocalDateTime.now()),
            new UserResponse("1", "John", "john@example.com", LocalDateTime.now()));
    when(userRepository.findAll()).thenReturn(users);

    var result = userService.getUsers(0, 10, "name");

    assertEquals(2, result.size());
    assertEquals("Jane", result.get(0).name());
    assertEquals("John", result.get(1).name());
    verify(userRepository).findAll();
}

@Test
void updateUser_ShouldUpdateAndReturnUserWhenExists() {
    var request = new UserRequest("John Updated", "john.updated@example.com");
    when(userRepository.update("1", request.name(), request.email())).thenReturn(true);

    var result = userService.updateUser("1", request);

    assertEquals("1", result.id());
    assertEquals("John Updated", result.name());
    assertEquals("john.updated@example.com", result.email());
    verify(userRepository).update("1", request.name(), request.email());
}

@Test
void deleteUser_ShouldCallRepositoryWhenUserExists() {
    when(userRepository.deleteById("1")).thenReturn(true);

    userService.deleteUser("1");

    verify(userRepository).deleteById("1");
}

@Test
void deleteUser_ShouldThrow404WhenUserMissing() {
    when(userRepository.deleteById("missing")).thenReturn(false);

    var exception = assertThrows(HttpServerResponseException.class, () -> userService.deleteUser("missing"));

    assertEquals(404, exception.code());
    verify(userRepository).deleteById("missing");
}

В Kotlin-версии используется MockK. Поведение мока задается через DSL every { ... } returns ...: блок описывает вызов зависимости, а returns задает ответ на этот вызов. Проверка взаимодействия пишется как verify { userRepository.save(...) }. Такой синтаксис хорошо ложится на Kotlin: не нужны экранированные вызовы вроде `when`, а проверяемый вызов остается обычным кодом Kotlin внутри блока.

Добавьте импорты:

import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertThrows
import io.koraframework.guide.httpserver.dto.UserRequest
import io.koraframework.http.server.common.response.HttpServerResponseException

Добавьте тестовые методы:

@Test
fun createUserShouldCreateAndReturnUser() {
    val request = UserRequest("John", "john@example.com")

    every { userRepository.save("John", "john@example.com") } returns "1"

    val result = userService.createUser(request)

    assertNotNull(result)
    assertEquals("1", result.id)
    assertEquals("John", result.name)
    assertEquals("john@example.com", result.email)
    verify { userRepository.save("John", "john@example.com") }
}

@Test
fun getUserShouldReturnUserWhenExists() {
    val expected = UserResponse("1", "John", "john@example.com", LocalDateTime.now())
    every { userRepository.findById("1") } returns expected

    val result = userService.getUser("1")

    assertEquals(expected, result)
    verify { userRepository.findById("1") }
}

@Test
fun getUsersShouldReturnPagedUsers() {
    val users = listOf(
        UserResponse("2", "Jane", "jane@example.com", LocalDateTime.now()),
        UserResponse("1", "John", "john@example.com", LocalDateTime.now())
    )
    every { userRepository.findAll() } returns users

    val result = userService.getUsers(0, 10, "name")

    assertEquals(2, result.size)
    assertEquals("Jane", result[0].name)
    assertEquals("John", result[1].name)
    verify { userRepository.findAll() }
}

@Test
fun updateUserShouldUpdateAndReturnUserWhenExists() {
    val request = UserRequest("John Updated", "john.updated@example.com")
    every { userRepository.update("1", request.name, request.email) } returns true

    val result = userService.updateUser("1", request)

    assertEquals("1", result.id)
    assertEquals("John Updated", result.name)
    assertEquals("john.updated@example.com", result.email)
    verify { userRepository.update("1", request.name, request.email) }
}

@Test
fun deleteUserShouldCallRepositoryWhenUserExists() {
    every { userRepository.deleteById("1") } returns true

    userService.deleteUser("1")

    verify { userRepository.deleteById("1") }
}

@Test
fun deleteUserShouldThrow404WhenUserMissing() {
    every { userRepository.deleteById("missing") } returns false

    val exception = assertThrows(HttpServerResponseException::class.java) {
        userService.deleteUser("missing")
    }

    assertEquals(404, exception.code())
    verify { userRepository.deleteById("missing") }
}

Интеграционные тесты

Интеграционное тестирование с настоящим PostgreSQL, TestApplication и UserServiceIntegrationPostgresTest рассматривается в отдельном руководстве: Интеграционное тестирование.

Это руководство по JUnit сосредоточено на компонентном тестировании с @KoraAppTest, @TestComponent, @Mock для Java и @MockK для Kotlin.

Переопределения конфигурации

Полные правила тестовой конфигурации Kora описаны в разделе настройки конфигурации для тестов.

Тестовый класс может реализовать KoraAppTestConfigModifier и вернуть KoraConfigModification из метода config(). Это подменяет конфигурацию, которую читает граф, не трогая ни application.conf, ни код приложения. Основные способы создания модификации:

  • KoraConfigModification.ofString(...) — встроенный фрагмент HOCON
  • KoraConfigModification.ofResourceFile(...) — файл из тестового каталога resources
  • KoraConfigModification.ofSystemProperty(key, value) — когда нужны только системные свойства
  • withSystemProperty(...) и withSystemProperties(...) — чтобы заполнить подстановки ${PLACEHOLDER} внутри фрагмента

Конфигурация здесь должна использовать ключи Kora 2.0. Порты — это httpServer.port для публичного сервера и httpServer.system.port для системного сервера, который отдает пробы и метрики; пул JDBC настраивается в секции jdbc.

@KoraAppTest(Application.class)
class UserServiceConfigTest implements KoraAppTestConfigModifier {

    @TestComponent
    private UserService userService;

    @Override
    public KoraConfigModification config() {
        return KoraConfigModification.ofString("""
                httpServer {
                  port = 0
                  system.port = 0
                  telemetry.logging.enabled = true
                }
                """);
    }
}
@KoraAppTest(Application::class)
class UserServiceConfigTest : KoraAppTestConfigModifier {

    @TestComponent
    lateinit var userService: UserService

    override fun config(): KoraConfigModification {
        return KoraConfigModification.ofString(
            """
            httpServer {
              port = 0
              system.port = 0
              telemetry.logging.enabled = true
            }
            """.trimIndent()
        )
    }
}

Порт 0 просит у операционной системы любой свободный порт, что избавляет параллельные запуски тестов от конфликтов на 8080 и 8085. Это важно только тогда, когда тестовый граф действительно поднимает HTTP-сервер: компонентный тест, который запрашивает лишь UserService, вообще не занимает порт.

Шаблоны переопределения конфигурации

Распространенные шаблоны переопределения:

  • отключать внешние службы для компонентных тестов
  • использовать базы данных в памяти для более быстрых тестов
  • переопределять настройки подключения для тестовой инфраструктуры
  • отключать кеширование или фоновые задачи во время тестирования
  • настраивать другие порты или конечные точки

Запуск тестов

Запустите тесты через Gradle:

# Run all tests
./gradlew test

# Run with detailed output
./gradlew test --info

# Run tests in parallel
./gradlew test --parallel

Тестовое покрытие

Проекты Kora включают встроенные отчеты о тестах и покрытии:

# Generate test reports
./gradlew test

# Generate coverage reports
./gradlew jacocoTestReport

# View HTML coverage report
open build/jacocoHtml/index.html

Лучшие практики

Стратегия тестирования:

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

Организация тестов:

  • Один тестовый класс на промышленный класс: UserServiceUserServiceComponentTest
  • Понятные имена тестов: createUser_ShouldCreateAndReturnUser
  • Структура Given-When-Then: четкие фазы теста
  • Строители тестовых данных: единообразное создание тестовых данных

Изоляция тестов:

  • Свежая база данных для каждого теста: используйте Testcontainers с жизненным циклом на каждый метод
  • Никаких зависимостей между тестами: тесты должны выполняться независимо
  • Чистое состояние: сбрасывайте состояние между тестами
  • Освобождение ресурсов: корректно освобождайте контейнеры и соединения

Соображения производительности:

  • Параллельное выполнение: запускайте тесты параллельно, когда это возможно
  • Общие контейнеры: используйте совместное использование контейнеров для более быстрого запуска
  • Выборочное тестирование: запускайте только нужные тесты во время разработки
  • Быстрая обратная связь: компонентные тесты для быстрой проверки

Итоги

Вы изучили полноценные стратегии тестирования приложений Kora:

  • Компонентные тесты: проверка взаимодействий компонентов с помощью DI-фреймворка Kora
  • Интеграционные тесты: проверка с настоящими базами данных через Testcontainers
  • Тесты по принципу черного ящика: проверка поведения полноценного приложения через HTTP API

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

Тестовый фреймворк использует внедрение зависимостей Kora для удобных подмен и настройки, Testcontainers для реалистичного тестирования инфраструктуры и стандартные шаблоны JUnit 5 для привычной структуры тестов.

Ключевые понятия

Обзор стратегии тестирования:

  • Компонентные тесты: быстрое изолированное тестирование отдельных компонентов с использованием внедрения зависимостей Kora
  • Интеграционные тесты: реалистичное тестирование с настоящей инфраструктурой через Testcontainers
  • Тесты по принципу черного ящика: сквозное тестирование поведения полноценного приложения

Тестовый фреймворк Kora:

  • @KoraAppTest: аннотация, которая поднимает граф зависимостей Kora для теста, с необязательными components() и modules()
  • @TestComponent: помечает поле или параметр, который нужно взять из графа, и делает его корнем тестового графа
  • KoraAppTestConfigModifier и KoraAppTestGraphModifier: точки входа для изменения конфигурации и графа
  • KoraConfigModification и KoraGraphModification: построители этих изменений

Интеграция Testcontainers:

  • Настоящая инфраструктура: PostgreSQL, Redis и другие службы в Docker-контейнерах
  • Автоматический жизненный цикл: контейнеры автоматически запускаются и останавливаются вместе с выполнением тестов
  • Настройка сети: автоматическое внедрение строки подключения

Лучшие практики:

  • Изоляция тестов: каждый тест выполняется в полной изоляции со свежими контейнерами
  • Быстрая обратная связь: компонентные тесты для быстрой проверки во время разработки
  • Освобождение ресурсов: автоматическая очистка контейнеров и соединений
  • Параллельное выполнение: тесты могут выполняться параллельно для ускорения

Устранение неполадок

Создание моков падает на тулчейне Java 25:

И Mockito, и MockK генерируют подмены через Byte Buddy. Kora 2.0 фиксирует net.bytebuddy:byte-buddy и net.bytebuddy:byte-buddy-agent на версии 1.18.11, тогда как Mockito 5.23.0 транзитивно подтягивает 1.17.7. Зафиксируйте согласованную версию в тестовом модуле:

build.gradle
configurations.testRuntimeClasspath {
    resolutionStrategy {
        force "net.bytebuddy:byte-buddy:1.18.11"
        force "net.bytebuddy:byte-buddy-agent:1.18.11"
    }
}
build.gradle.kts
configurations.testRuntimeClasspath {
    resolutionStrategy {
        force("net.bytebuddy:byte-buddy:1.18.11")
        force("net.bytebuddy:byte-buddy-agent:1.18.11")
    }
}

@KoraAppTest не находит компонент:

  • Проверьте, что тип @KoraApp, переданный в @KoraAppTest, действительно объявляет или наследует модуль, который дает этот компонент
  • Проверьте, что тестовый модуль зависит от модуля с @KoraApp, иначе сгенерированный ApplicationGraph не попадает в тестовый classpath
  • Добавьте компонент в @KoraAppTest(components = ...), если тест не ссылается на него напрямую
  • Помните, что компонент с @Tag находится только тогда, когда на тестовом поле стоит такой же @Tag

Обработка аннотаций не запускается для тестовых исходников:

  • В Java используйте testAnnotationProcessor, а не annotationProcessor: тестовые исходники компилируются отдельной задачей
  • В Kotlin используйте kspTest, а не ksp, и добавьте build/generated/ksp/test/kotlin в тестовый набор исходников
  • И то и другое нужно только тогда, когда аннотации Kora объявлены в самих тестовых исходниках

Мок создается, но граф использует настоящий компонент:

  • Убедитесь, что на поле стоят обе аннотации: мок-фреймворка и @TestComponent
  • Убедитесь, что к классу не подключено второе JUnit-расширение мок-фреймворка
  • Проверьте объявленный тип поля: Kora сопоставляет подмену по типу, поэтому поле с типом реализации не заменит компонент, опубликованный как интерфейс

Ошибки о неиспользованных заглушках:

  • Под @KoraAppTest моки Mockito по умолчанию работают со строгостью Strictness.WARN
  • Поднимите ее до @MockitoStrictness(Strictness.STRICT_STUBS), если неиспользованные заглушки должны ронять тест, или опустите до Strictness.LENIENT для общей подготовки данных

Переопределения конфигурации не применяются:

  • Тестовый класс должен реализовывать KoraAppTestConfigModifier; одного метода config() недостаточно
  • Подстановки ${PLACEHOLDER} внутри ofString(...) разрешаются из системных свойств, поэтому каждой подстановке нужен свой withSystemProperty(...)
  • Сверьте имена ключей с конфигурацией Kora 2.0: httpServer.port, httpServer.system.port, jdbc

Проблемы выполнения тестов:

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

Что дальше?

Помощь

Если возникли проблемы: