Компонентное тестирование в 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, и при этом сохраняет окружение одноразовым и повторяемым.
Это руководство закладывает основу для последующих специальных руководств по тестированию: используйте компонентные тесты для сфокусированной обратной связи, интеграционные тесты для границ с инфраструктурой, а тесты по принципу черного ящика — как самую сильную проверку поведения полноценного приложения.
Практический ход такой:
- добавить зависимости JUnit, Mockito для Java, MockK для Kotlin и тестовые модули Kora
- объявить тестовый граф Kora
- заменить выбранные зависимости подменами
- проверить поведение службы через компоненты, управляемые графом
- добавить переопределения конфигурации для тестовых сценариев
- подготовить проект к более глубокому интеграционному тестированию и тестированию как черный ящик
Зависимости¶
Расширение Kora для JUnit 5 живет в артефакте io.koraframework:test-junit5. Его версия берется из платформы io.koraframework:kora-bom, поэтому BOM должен быть виден не только конфигурации
implementation, но и тестовым конфигурациям.
Добавьте следующие тестовые зависимости в 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")
}
}
- Обработчик аннотаций Kora в тестовой области. Он начинает что-то генерировать только тогда, когда тестовые исходники сами объявляют аннотации Kora — например, собственный
@KoraAppили@Repository. Тестовые исходники компилируются отдельной задачей, поэтому записьannotationProcessorосновного набора исходников на них не распространяется. - BOM JUnit 6. Kora 2.0 собирается и тестируется с JUnit
6.1.3; пакетыorg.junit.jupiter.apiпри этом не изменились со времен JUnit 5. - Модуль, которому принадлежит тестируемый
@KoraApp. Без него графа приложения для сборки просто нет. - Расширение Kora для JUnit 5:
@KoraAppTest,@TestComponent, модификаторы конфигурации и графа. - Mockito.
test-junit5не зависит ни от Mockito, ни от MockK, поэтому фреймворк подмен объявляет сам тестовый модуль.
Добавьте следующие тестовые зависимости в 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
}
}
- BOM JUnit 6. Kora 2.0 собирается и тестируется с JUnit
6.1.3; пакетыorg.junit.jupiter.apiпри этом не изменились со времен JUnit 5. - Модуль, которому принадлежит тестируемый
@KoraApp. Без него графа приложения для сборки просто нет. - Расширение Kora для JUnit 5:
@KoraAppTest,@TestComponent, модификаторы конфигурации и графа. - 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 делает несколько вещей до выполнения тестового метода:
- берет
Applicationиз@KoraAppTest - строит тестовую версию графа приложения
- оставляет в графе компоненты, запрошенные через
@TestComponent, и их зависимости - применяет тестовые подмены, если они объявлены
- инициализирует нужные компоненты графа
- внедряет готовые компоненты в поля, конструктор или параметры тестовых методов
После этого выполняется обычный 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(...)— встроенный фрагмент HOCONKoraConfigModification.ofResourceFile(...)— файл из тестового каталогаresourcesKoraConfigModification.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
Лучшие практики¶
Стратегия тестирования:
- Отдавайте приоритет тестам по принципу черного ящика: они дают наибольшую уверенность
- Используйте компонентные тесты для логики: быстрая обратная связь во время разработки
- Интеграционные тесты для инфраструктуры: проверка взаимодействия с настоящей базой данных
- Тестирование алгоритмов: сложная бизнес-логика со сфокусированной изоляцией, когда это нужно
Организация тестов:
- Один тестовый класс на промышленный класс:
UserService→UserServiceComponentTest - Понятные имена тестов:
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. Зафиксируйте согласованную версию в тестовом модуле:
@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
Проблемы выполнения тестов:
- Проверьте журналы тестов: в них есть подробные сообщения об ошибках
- Убедитесь, что все зависимости правильно внедрены
- Проверьте изоляцию тестов: между тестами не должно быть общего состояния
Что дальше?¶
- База данных JDBC, если хотите перейти к тестам, которым нужны PostgreSQL, Flyway и миграции репозиториев.
- Интеграционное тестирование после базы данных JDBC, чтобы тестировать репозитории, миграции и внешние зависимости через Testcontainers.
- Тестирование как черный ящик после базы данных JDBC, чтобы проверять упакованное HTTP-приложение от начала до конца.
- Наблюдаемость, чтобы добавить метрики, трассировки, журналы и пробы, которые тоже можно проверять в тестах.
- Шаблоны устойчивости, чтобы потренироваться тестировать сбои и резервное поведение.
Помощь¶
Если возникли проблемы:
- сравните с тестовыми классами в соответствующем модуле
guides/* - проверьте документацию JUnit5
- вернитесь к HTTP-серверу, чтобы свериться с базовой формой графа
- прочитайте документацию Testcontainers по проблемам жизненного цикла контейнеров