Integration Testing with Kora¶
This guide introduces integration testing for Kora JDBC applications. It covers how to run the application graph against real PostgreSQL infrastructure, how Testcontainers supplies database connection settings, and how repositories, migrations, configuration, and services are verified together. You will also see how integration tests catch wiring and persistence issues that unit tests intentionally avoid.
If you want to check your progress along the way, use the finished working example: Kora Java Testing Integration App.
If you want to check your progress along the way, use the finished working example: Kora Kotlin Testing Integration App.
What You'll Build¶
You'll create integration tests that cover:
- Real Database Validation: Run tests against a real PostgreSQL instance
- Migration Verification: Ensure Flyway migrations are applied correctly
- Service + Repository Integration: Verify complete persistence workflows
- Configuration Override Testing: Inject runtime configuration from containers
- Deterministic Test Isolation: Clean and repeatable test behavior
What You'll Need¶
- JDK 17 or later
- Gradle 7+
- Docker (for Testcontainers)
- A text editor or IDE
- Completed Database Integration guide
Prerequisites¶
Required: Complete Database JDBC Guide
This guide assumes you have completed Database Integration and already have a JDBC repository implementation, Flyway migrations in db/migration, UserService wired to the real JDBC repository, and working CRUD behavior in your database-backed application.
If you haven't completed the database JDBC guide yet, do that first, because this guide verifies the real database-backed service flow with Testcontainers.
Overview¶
Integration testing validates how application code behaves when it works with real infrastructure. It sits between component tests and black-box tests: broader than a mocked service test, but narrower than starting the entire application as an external process.
The key difference from a component test is that infrastructure is part of the behavior being tested. A repository method is not fully proven until its SQL runs against the same kind of database the application uses.
Integration Boundary¶
In this guide, the integration boundary is the service and repository layer backed by a real PostgreSQL database. The test still runs inside the JUnit process and uses the Kora test graph, but the database is not mocked. That lets the test validate behavior that only exists when SQL, migrations, connection configuration, and row mapping all work together.
Integration tests are especially valuable for:
- real SQL execution against PostgreSQL
- record and column mapping
- Flyway migration compatibility with repository code
- pagination, sorting, update, and delete behavior against real data
- service logic that depends on persistence semantics
Tests and Testcontainers¶
For more on extended test graphs, component replacement, and container modification, see Test graph and Container modification.
Kora provides the application graph; Testcontainers provides disposable infrastructure. The test starts a PostgreSQL container, passes its connection values into the graph, and then exercises application components with real database state.
This combination is powerful because the repository code is generated and wired the same way it is in the application, while the database is isolated per test run. You get realistic persistence behavior without requiring a manually prepared local database.
Integration or Black Box¶
Integration tests usually call components directly. Black-box tests call the public API of the running application. That means integration tests are better for focused persistence feedback, while black-box tests are better for proving the full request path.
Use integration tests when the question is "does this application logic work with real infrastructure?" Use black-box tests when the question is "does the deployed application behave correctly from a client's point of view?"
The practical flow is:
- add Kora test and Testcontainers dependencies
- start PostgreSQL with Testcontainers
- pass container connection settings into the Kora graph
- run migrations against the test database
- call graph-managed services and repositories
- verify persistence behavior with real database state
Dependencies¶
In this guide, tests live in a separate Gradle module instead of the application module itself. That is why the dependency list is longer than it would be for a usual src/test placed next to
production code: the test module must explicitly depend on the application and on the Kora modules required to build the test graph, read configuration, use JDBC, run Flyway, work with JSON, include
HTTP modules, and initialize logging.
These dependencies do not arrive transitively from the service as a complete test runtime. The service module exposes its API and compiled code, but a separate test module still declares which parts
should be present in the test runtime. If these integration tests lived directly inside the application module, most of these dependencies would already be available from the main build.gradle, so
you would not need to repeat them in this amount.
Add the following test dependencies to your build.gradle:
dependencies {
testImplementation platform("org.junit:junit-bom:5.14.3")
testRuntimeOnly "org.postgresql:postgresql:42.7.7"
testImplementation "org.junit.jupiter:junit-jupiter"
testImplementation project(":guide-database-jdbc-app")
testImplementation "ru.tinkoff.kora:config-hocon"
testImplementation "ru.tinkoff.kora:database-flyway"
testImplementation "ru.tinkoff.kora:database-jdbc"
testImplementation "ru.tinkoff.kora:http-client-common"
testImplementation "ru.tinkoff.kora:http-server-undertow"
testImplementation "ru.tinkoff.kora:json-module"
testImplementation "ru.tinkoff.kora:logging-logback"
testImplementation "ru.tinkoff.kora:test-junit5"
testImplementation "org.testcontainers:junit-jupiter:1.21.4"
testImplementation "org.testcontainers:postgresql:1.21.4"
}
test {
useJUnitPlatform()
filter {
excludeTestsMatching '*$*'
excludeTestsMatching "*TestApplication"
}
testLogging {
showStandardStreams(true)
events("passed", "skipped", "failed")
exceptionFormat("full")
}
}
Add the following test dependencies to your build.gradle.kts:
dependencies {
testImplementation(platform("org.junit:junit-bom:5.14.3"))
testRuntimeOnly("org.postgresql:postgresql:42.7.7")
testImplementation("org.junit.jupiter:junit-jupiter")
testImplementation(project(":guide-database-jdbc-app"))
testImplementation("ru.tinkoff.kora:config-hocon")
testImplementation("ru.tinkoff.kora:database-flyway")
testImplementation("ru.tinkoff.kora:database-jdbc")
testImplementation("ru.tinkoff.kora:http-client-common")
testImplementation("ru.tinkoff.kora:http-server-undertow")
testImplementation("ru.tinkoff.kora:json-module")
testImplementation("ru.tinkoff.kora:logging-logback")
testImplementation("ru.tinkoff.kora:test-junit5")
testImplementation("org.testcontainers:junit-jupiter:1.21.4")
testImplementation("org.testcontainers:postgresql:1.21.4")
}
tasks.test {
useJUnitPlatform()
filter {
excludeTestsMatching('*$*')
excludeTestsMatching("*TestApplication")
}
testLogging {
showStandardStreams = true
events("passed", "skipped", "failed")
exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
}
}
Enable Submodule Generation In JDBC Application
Add submodule generation to the real application graph (guide-database-jdbc-app), not to test compilation.
Add to guide-database-jdbc-app/build.gradle:
Test Graph¶
Before writing integration test methods, create a dedicated TestApplication.
It extends your production Application, but adds a test-only repository with deleteAll() for cleanup.
This keeps production UserRepository focused on application behavior and moves test utilities into test scope.
Create src/test/java/ru/tinkoff/kora/guide/testingintegration/TestApplication.java:
package ru.tinkoff.kora.guide.testingintegration;
import java.util.List;
import ru.tinkoff.kora.common.KoraApp;
import ru.tinkoff.kora.common.Tag;
import ru.tinkoff.kora.common.annotation.Root;
import ru.tinkoff.kora.database.common.annotation.Query;
import ru.tinkoff.kora.database.common.annotation.Repository;
import ru.tinkoff.kora.database.jdbc.JdbcRepository;
import ru.tinkoff.kora.guide.databasejdbc.Application;
import ru.tinkoff.kora.guide.databasejdbc.repository.UserDAO;
@KoraApp
public interface TestApplication extends Application {
@Repository
interface TestUserRepository extends JdbcRepository {
@Query("SELECT id, name, email, created_at FROM users ORDER BY id")
List<UserDAO> findAll();
@Query("DELETE FROM users")
void deleteAll();
}
@Tag(TestApplication.class)
@Root
default String testRoot(TestUserRepository ignored) {
return "test-root";
}
}
Create src/test/kotlin/ru/tinkoff/kora/guide/testingintegration/TestApplication.kt:
package ru.tinkoff.kora.guide.testingintegration
import ru.tinkoff.kora.common.KoraApp
import ru.tinkoff.kora.common.Tag
import ru.tinkoff.kora.common.annotation.Root
import ru.tinkoff.kora.database.common.annotation.Query
import ru.tinkoff.kora.database.common.annotation.Repository
import ru.tinkoff.kora.database.jdbc.JdbcRepository
import ru.tinkoff.kora.guide.databasejdbc.Application
import ru.tinkoff.kora.guide.databasejdbc.repository.UserDAO
@KoraApp
interface TestApplication : Application {
@Repository
interface TestUserRepository : JdbcRepository {
@Query("SELECT id, name, email, created_at FROM users ORDER BY id")
fun findAll(): List<UserDAO>
@Query("DELETE FROM users")
fun deleteAll()
}
@Tag(TestApplication::class)
@Root
fun testRoot(ignored: TestUserRepository): String = "test-root"
}
Now create the integration test foundation with:
@Testcontainersto manage container lifecyclePostgreSQLContaineras a real database for integration checks- explicit startup timeout and container log consumer for easier debugging
@KoraAppTest(TestApplication...)for bootstrapping the test graph- runtime config override using container JDBC values
Create src/test/java/ru/tinkoff/kora/guide/testingintegration/UserServiceIntegrationPostgresTest.java:
package ru.tinkoff.kora.guide.testingintegration;
import java.time.Duration;
import org.jetbrains.annotations.NotNull;
import org.junit.jupiter.api.BeforeEach;
import org.slf4j.LoggerFactory;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.containers.output.Slf4jLogConsumer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import ru.tinkoff.kora.guide.databasejdbc.service.UserService;
import ru.tinkoff.kora.guide.testingintegration.TestApplication.TestUserRepository;
import ru.tinkoff.kora.test.extension.junit5.KoraAppTest;
import ru.tinkoff.kora.test.extension.junit5.KoraAppTestConfigModifier;
import ru.tinkoff.kora.test.extension.junit5.KoraConfigModification;
import ru.tinkoff.kora.test.extension.junit5.TestComponent;
@Testcontainers
@KoraAppTest(TestApplication.class)
class UserServiceIntegrationPostgresTest implements KoraAppTestConfigModifier {
@Container
static final PostgreSQLContainer<?> POSTGRES =
new PostgreSQLContainer<>("postgres:16-alpine")
.withStartupTimeout(Duration.ofSeconds(30))
.withLogConsumer(new Slf4jLogConsumer(LoggerFactory.getLogger(PostgreSQLContainer.class)));
@TestComponent
private UserService userService;
@TestComponent
private TestUserRepository testUserRepository;
@NotNull
@Override
public KoraConfigModification config() {
return KoraConfigModification.ofString("""
db {
jdbcUrl = ${POSTGRES_JDBC_URL}
username = ${POSTGRES_USER}
password = ${POSTGRES_PASS}
poolName = "kora-test"
}
flyway {
locations = "db/migration"
}
""")
.withSystemProperty("POSTGRES_JDBC_URL", POSTGRES.getJdbcUrl())
.withSystemProperty("POSTGRES_USER", POSTGRES.getUsername())
.withSystemProperty("POSTGRES_PASS", POSTGRES.getPassword());
}
@BeforeEach
void cleanup() {
testUserRepository.deleteAll();
}
}
Create src/test/kotlin/ru/tinkoff/kora/guide/testingintegration/UserServiceIntegrationPostgresTest.kt:
package ru.tinkoff.kora.guide.testingintegration
import java.time.Duration
import org.junit.jupiter.api.BeforeEach
import org.slf4j.LoggerFactory
import org.testcontainers.containers.PostgreSQLContainer
import org.testcontainers.containers.output.Slf4jLogConsumer
import org.testcontainers.junit.jupiter.Container
import org.testcontainers.junit.jupiter.Testcontainers
import ru.tinkoff.kora.guide.databasejdbc.service.UserService
import ru.tinkoff.kora.guide.testingintegration.TestApplication.TestUserRepository
import ru.tinkoff.kora.test.extension.junit5.KoraAppTest
import ru.tinkoff.kora.test.extension.junit5.KoraAppTestConfigModifier
import ru.tinkoff.kora.test.extension.junit5.KoraConfigModification
import ru.tinkoff.kora.test.extension.junit5.TestComponent
@Testcontainers
@KoraAppTest(TestApplication::class)
class UserServiceIntegrationPostgresTest : KoraAppTestConfigModifier {
companion object {
@Container
@JvmStatic
val POSTGRES = PostgreSQLContainer("postgres:16-alpine")
.withStartupTimeout(Duration.ofSeconds(30))
.withLogConsumer(Slf4jLogConsumer(LoggerFactory.getLogger(PostgreSQLContainer::class.java)))
}
@TestComponent
lateinit var userService: UserService
@TestComponent
lateinit var testUserRepository: TestUserRepository
override fun config(): KoraConfigModification {
return KoraConfigModification.ofString(
"""
db {
jdbcUrl = \${POSTGRES_JDBC_URL}
username = \${POSTGRES_USER}
password = \${POSTGRES_PASS}
poolName = "kora-test"
}
flyway {
locations = "db/migration"
}
""".trimIndent()
)
.withSystemProperty("POSTGRES_JDBC_URL", POSTGRES.jdbcUrl)
.withSystemProperty("POSTGRES_USER", POSTGRES.username)
.withSystemProperty("POSTGRES_PASS", POSTGRES.password)
}
@BeforeEach
fun cleanup() {
testUserRepository.deleteAll()
}
}
config() in this test replaces configuration, not application code. KoraConfigModification.ofString(...) first adds a small HOCON fragment with the db and flyway settings required by the JDBC
pool and migrations. The connection values are not hardcoded into the config string; they are expressed as ${POSTGRES_JDBC_URL}, ${POSTGRES_USER}, and ${POSTGRES_PASS} placeholders.
Then withSystemProperty(...) provides real values from the running PostgreSQLContainer. Testcontainers may allocate a different port, username, or password for each run, so the test should not
assume a fixed localhost:5432. When Kora reads configuration, these placeholders are resolved through system properties, and the graph receives a normal JdbcDatabase connected to the disposable
database of this specific test run.
This is useful in several ways: production configuration does not change for tests, tests do not depend on a developer's local database, and the same application code is verified against real PostgreSQL and real migrations. At the same time, you can override only the settings that matter without rewriting the entire configuration file.
Write tests¶
Now add real integration test methods to the same UserServiceIntegrationPostgresTest class.
The container is intentionally configured with explicit startup timeout and attached logs to make startup issues diagnosable.
These methods validate service behavior and persisted state against real PostgreSQL.
Add imports:
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.List;
import org.junit.jupiter.api.Test;
import ru.tinkoff.kora.guide.databasejdbc.dto.UserRequest;
Add test methods:
@Test
void createUser_ShouldPersistUserInDatabase() {
var result = userService.createUser(new UserRequest("John", "john@example.com"));
assertEquals("John", result.name());
assertTrue(Long.parseLong(result.id()) > 0);
assertEquals(1, testUserRepository.findAll().size());
}
@Test
void getUsers_WithPagination_ShouldReturnCorrectPage() {
List.of(
new UserRequest("Alice", "alice@example.com"),
new UserRequest("Bob", "bob@example.com"),
new UserRequest("Charlie", "charlie@example.com"),
new UserRequest("David", "david@example.com"))
.forEach(userService::createUser);
var result = userService.getUsers(1, 2, "name");
assertEquals(2, result.size());
assertEquals("Charlie", result.get(0).name());
assertEquals("David", result.get(1).name());
}
@Test
void updateUser_ShouldUpdateUserInDatabase() {
var created = userService.createUser(new UserRequest("John", "john@example.com"));
var updated = userService.updateUser(created.id(), new UserRequest("John Updated", "john.updated@example.com"));
assertEquals("John Updated", updated.name());
}
@Test
void deleteUser_ShouldRemoveUserFromDatabase() {
var created = userService.createUser(new UserRequest("John", "john@example.com"));
userService.deleteUser(created.id());
assertEquals(0, testUserRepository.findAll().size());
}
Add imports:
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import ru.tinkoff.kora.guide.databasejdbc.dto.UserRequest
Add test methods:
@Test
fun createUserShouldPersistUserInDatabase() {
val result = userService.createUser(UserRequest("John", "john@example.com"))
assertEquals("John", result.name())
assertTrue(result.id().toLong() > 0)
assertEquals(1, testUserRepository.findAll().size)
}
@Test
fun getUsersWithPaginationShouldReturnCorrectPage() {
listOf(
UserRequest("Alice", "alice@example.com"),
UserRequest("Bob", "bob@example.com"),
UserRequest("Charlie", "charlie@example.com"),
UserRequest("David", "david@example.com")
).forEach(userService::createUser)
val result = userService.getUsers(1, 2, "name")
assertEquals(2, result.size)
assertEquals("Charlie", result[0].name())
assertEquals("David", result[1].name())
}
@Test
fun updateUserShouldUpdateUserInDatabase() {
val created = userService.createUser(UserRequest("John", "john@example.com"))
val updated = userService.updateUser(created.id(), UserRequest("John Updated", "john.updated@example.com"))
assertEquals("John Updated", updated.name())
}
@Test
fun deleteUserShouldRemoveUserFromDatabase() {
val created = userService.createUser(UserRequest("John", "john@example.com"))
userService.deleteUser(created.id())
assertEquals(0, testUserRepository.findAll().size)
}
Testing¶
Run your integration tests using Gradle:
Execution Notes
- Docker must be running before test start.
- The first run is usually slower due to image pulls.
- Keep test logging enabled to simplify startup and migration diagnostics.
Test Coverage¶
Use standard Gradle reporting for integration test diagnostics:
# Execute tests and generate reports
./gradlew test
# Generate JaCoCo coverage report
./gradlew jacocoTestReport
Integration failures are typically easiest to debug from:
build/reports/tests/test/index.html- container startup logs in Gradle output
- SQL/migration logs from Flyway and JDBC components
Flyway Migrations in Tests
You can run Flyway migrations directly in test lifecycle instead of relying on Flyway startup inside the application. This approach is useful when you want stricter control over schema setup per test suite or per test class. In this guide we keep Flyway migration in application startup for simplicity, but both approaches are valid.
Best Practices¶
Integration Test Design:
- Keep test scenarios business-focused (create, read, update, delete, pagination)
- Validate both service response and database state
- Use deterministic ordering fields for pagination checks
- Avoid hidden coupling between tests
Data Isolation:
- Clean test data in
@BeforeEach - Use unique test records where collisions are possible
- Do not depend on IDs from previous test methods
- Keep each test independently executable
Infrastructure Stability:
- Use explicit startup timeouts for containers
- Always inject JDBC URL/user/password from container getters
- Keep Flyway locations explicit in test config
- Prefer container defaults over hardcoded DB credentials
Summary¶
Integration testing gives high confidence that your Kora JDBC application behaves correctly with real PostgreSQL and real migrations. It validates the persistence layer, DI wiring, and service behavior under realistic conditions while remaining faster and narrower than full black box API testing.
In this guide you configured:
- Testcontainers-based PostgreSQL setup
- Kora configuration overrides for runtime container values
- Real
UserServiceintegration validation with test-only repository helpers - Repeatable cleanup and deterministic test execution
Key Concepts¶
Integration Testing Scope:
- Real infrastructure, real SQL, real migrations
- Focus on service + repository + DB behavior
- Strong confidence for persistence workflows
Kora Test Infrastructure:
@KoraAppTestfor bootstrapping real application graph@TestComponentfor injecting tested componentsKoraAppTestConfigModifierfor runtime configuration overrides
Container-Driven Configuration:
- Pull connection details from
PostgreSQLContainer - Provide values via
withSystemProperty(...) - Keep config portable across environments
Troubleshooting¶
Container Startup Fails:
- Ensure Docker daemon is running
- Check port/resource conflicts in container logs
- Increase startup timeout if environment is slow
Migration Errors:
- Verify migrations are under
src/main/resources/db/migration - Ensure
flyway.locations = "db/migration"is present in test config - Check Flyway output in Gradle logs
Database Connectivity Issues:
- Use JDBC URL/credentials from container getters only
- Avoid hardcoded localhost credentials in test config
- Ensure PostgreSQL driver is available in test runtime
- Add explicit database-jdbc and database-flyway test dependencies when TestApplication extends another module's app graph
Flaky or Hanging Tests:
- Keep
testLoggingwithshowStandardStreams(true) - Use your IDE's test runner for focused debugging when needed
- Validate cleanup logic and test isolation assumptions
Expected @KoraApp as SubModule Warning:
If your test module extends Application from another module and you see warnings like:
Expected @KoraApp as SubModule, but Submodule implementation not found
enable submodule generation on the source application module:
Add to guide-database-jdbc-app/build.gradle:
JUnit Discovers Generated $TestApplicationImpl:
If test discovery fails before execution (for example with NoClassDefFoundError coming from generated classes), exclude generated classes using Gradle test filter:
AccessDeniedException in Gradle Cache:
On Windows this may happen when cached JARs are temporarily locked by another process.
Try in order:
- Stop daemons:
./gradlew --stop - Re-run build:
./gradlew test - If lock persists, run with isolated cache for the session:
GRADLE_USER_HOME=.gradle-user-home ./gradlew test
What's Next?¶
- Black Box Testing to move from graph-level integration tests to packaged application tests.
- Observability to monitor the same database-backed app with metrics, traces, logs, and probes.
- Advanced JDBC if you want more repository, transaction, mapper, and projection scenarios to test.
- Caching when repeated database reads need a performance layer.
Help¶
If you encounter issues:
- compare integration tests with Kora Java Database JDBC App and Kora Kotlin Database JDBC App
- check the JUnit5 documentation
- check the Database JDBC documentation
- check the Database Migration documentation
- read the Testcontainers documentation