Kora Testing Black-Box — E2E via HTTP API
Kora sub-skill — obey the kora-v2 meta rules on every task: R0 ground the workspace on Kora 2.0 refs before starting (framework source at tag
2.0.0.RC1+kora-examplesatmigration/2.0;kora-docsis 1.x only) · R1 read this sub-skill before writing code · R2 Kora 2.0 APIs only — no Spring/Micronaut/Quarkus, no Kora 1.x APIs, no invented annotations or config keys · R3 journal any incorrect Kora usage. Add comments/Javadoc only if asked.
| BOM | io.koraframework:kora-bom, koraVersion=2.0.0.RC1 from plain mavenCentral() |
| Java / Kotlin | JDK 25 (hard floor) · Kotlin 2.4 + KSP 2.3 |
| JUnit | org.junit:junit-bom:6.1.3 |
| Testcontainers | 1.21.4 — junit-jupiter, testcontainers, postgresql (see coordinates) |
| Runtime image | eclipse-temurin:25-jre-jammy · native: ghcr.io/graalvm/native-image-community:25 |
| Ports | 8080 public API · 8085 system API (/system/readiness, /system/liveness, /metrics) |
Black-box tests run the packaged application (the distTar artifact, or a GraalVM native
binary) inside a Docker container and exercise it only through its HTTP API. The test never
injects into or modifies the Kora graph — it runs exactly the bytes that ship.
Kora builds its dependency graph at compile time, so startup is fast enough to make black-box
tests a primary confidence source, not just a smoke suite. They catch what narrower tests miss:
routing, @Json (de)serialization, @Valid validation, config key names, migrations, probes and
reachability metadata all exercised together.
Kora ships no Testcontainers wrapper. Use the
org.testcontainers:*API directly. For in-process tests with@KoraAppTest/@TestComponent, usekora-testing-junit-javaorkora-testing-junit-kotlininstead.
Contents
- Quick Start — deps, Dockerfile, AppContainer, first test
- The two servers and their ports
- Testcontainers coordinates
- Asserting on
/metrics - GraalVM native images
- What's in references/ and assets/
- When to use vs NOT
- Core patterns
- Common pitfalls
Quick Start
Pin every Kora artifact through the io.koraframework:kora-bom platform; never version an
io.koraframework:* dependency individually. kora-parent does not exist in 2.0.
1. Dependencies (build.gradle)
configurations {
koraBom
annotationProcessor.extendsFrom(koraBom)
compileOnly.extendsFrom(koraBom)
implementation.extendsFrom(koraBom)
testImplementation.extendsFrom(koraBom)
testAnnotationProcessor.extendsFrom(koraBom)
}
dependencies {
koraBom platform("io.koraframework:kora-bom:$koraVersion") // koraVersion=2.0.0.RC1
annotationProcessor "io.koraframework:annotation-processors"
testAnnotationProcessor "io.koraframework:annotation-processors"
testImplementation platform("org.junit:junit-bom:$junitVersion") // junitVersion=6.1.3
testImplementation "org.junit.jupiter:junit-jupiter"
testImplementation project(":my-service-app") // build ordering only
testImplementation "org.json:json:20231013"
testImplementation "org.testcontainers:junit-jupiter:1.21.4"
testImplementation "org.testcontainers:testcontainers:1.21.4"
testImplementation "org.testcontainers:postgresql:1.21.4"
}
test {
// Build the archive the Dockerfile copies, before tests run.
dependsOn ":my-service-app:distTar"
inputs.file("../my-service-app/Dockerfile")
inputs.file("../my-service-app/build/distributions/application.tar")
useJUnitPlatform()
}
Kotlin swaps the processors for ksp "io.koraframework:symbol-processors".
Two notes on that block. The project(":my-service-app") dependency is there for build ordering,
not for code — a black-box test must not import application classes; if it does, it has stopped
being a black-box test. And io.koraframework:test-junit5 is the in-process @KoraAppTest
extension: the migrated black-box guides declare it out of habit but never import from it, and
nothing here builds a graph in the test JVM.
The application module must produce the archive the Dockerfile unpacks:
application {
applicationName = "application"
mainClass = "com.example.Application"
}
distTar { archiveFileName = "application.tar" }
2. Dockerfile (in the application module)
FROM eclipse-temurin:25-jre-jammy
ARG TARGET_DIR=/opt/app
COPY build/distributions/application.tar /application.tar
RUN mkdir -p ${TARGET_DIR}
RUN tar -xf /application.tar -C ${TARGET_DIR}
RUN rm /application.tar
ARG DOCKER_USER=app
RUN groupadd -r ${DOCKER_USER} && useradd -rg ${DOCKER_USER} ${DOCKER_USER}
USER ${DOCKER_USER}
EXPOSE 8080/tcp
EXPOSE 8085/tcp
CMD ["/opt/app/application/bin/application"]
JDK 25 is a hard floor: kora-bom declares java.version = 25 and the published jars are
class-file major 69. A 21-jre base image fails with UnsupportedClassVersionError.
3. AppContainer wrapper
AppContainer is your class, not a Kora type — a GenericContainer subclass that keeps image
construction, port exposure and readiness gating out of the test class.
package com.example.blackbox;
import java.net.URI;
import java.nio.file.Path;
import java.time.Duration;
import org.slf4j.LoggerFactory;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.output.Slf4jLogConsumer;
import org.testcontainers.containers.wait.strategy.Wait;
import org.testcontainers.images.builder.ImageFromDockerfile;
final class AppContainer extends GenericContainer<AppContainer> {
AppContainer() {
super(new ImageFromDockerfile("my-service-black-box")
.withDockerfile(Path.of("../my-service-app/Dockerfile")));
withExposedPorts(8080, 8085);
withStartupTimeout(Duration.ofSeconds(30));
waitingFor(Wait.forHttp("/system/readiness").forPort(8085).forStatusCode(200));
withLogConsumer(new Slf4jLogConsumer(LoggerFactory.getLogger(AppContainer.class)));
}
URI getURI() {
return URI.create("http://" + getHost() + ":" + getMappedPort(8080));
}
URI getSystemURI() {
return URI.create("http://" + getHost() + ":" + getMappedPort(8085));
}
}
Never wait on a log line. Wait.forLogMessage(...) binds the test to Kora's startup message
wording, which is not part of its contract and changed between 1.x and 2.0. Wait.forHttp on the
readiness probe is the only stable gate.
4. Black-box test with PostgreSQL
Network.SHARED lets the application reach PostgreSQL by alias; withEnv(...) supplies the values
the application's HOCON/YAML reads through ${POSTGRES_JDBC_URL} substitutions.
package com.example.blackbox;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.UUID;
import org.json.JSONObject;
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.Network;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
@Testcontainers
class BlackBoxTests {
@Container
private static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine")
.withNetwork(Network.SHARED)
.withNetworkAliases("postgres");
@Container
private static final AppContainer APP = new AppContainer()
.withNetwork(Network.SHARED)
.dependsOn(POSTGRES)
.withEnv("POSTGRES_JDBC_URL", "jdbc:postgresql://postgres:5432/" + POSTGRES.getDatabaseName())
.withEnv("POSTGRES_USER", POSTGRES.getUsername())
.withEnv("POSTGRES_PASS", POSTGRES.getPassword());
@Test
void createUser_ShouldReturn201() throws Exception {
var body = new JSONObject().put("name", "John Doe").put("email", uniqueEmail("john"));
var request = HttpRequest.newBuilder()
.POST(HttpRequest.BodyPublishers.ofString(body.toString()))
.uri(APP.getURI().resolve("/users"))
.header("Content-Type", "application/json")
.timeout(Duration.ofSeconds(10))
.build();
var response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString());
assertEquals(201, response.statusCode());
assertTrue(new JSONObject(response.body()).has("id"));
}
@Test
void getUser_NotFound_ShouldReturn404() throws Exception {
var request = HttpRequest.newBuilder()
.GET()
.uri(APP.getURI().resolve("/users/999999"))
.timeout(Duration.ofSeconds(10))
.build();
var response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString());
assertEquals(404, response.statusCode());
}
private String uniqueEmail(String prefix) {
return prefix + "-" + UUID.randomUUID() + "@example.com";
}
}
Contracts in Kora 2.0 are synchronous — no Mono/Flux, no CompletionStage, no suspend
controllers — so the assertions above see a fully materialised response. There is no Context type
anywhere in the framework to propagate.
5. Build and run
./gradlew test # distTar runs first via the dependsOn above
Flyway/Liquibase migrations run inside the application container on startup, so the schema is ready before the first request reaches the API.
The two servers and their ports
Kora 2.0 runs two HTTP servers from separate config sections and separate Undertow modules:
| Server | Module | Config path | Default port |
|---|---|---|---|
| Public API | UndertowPublicHttpServerModule |
httpServer |
8080 |
| System API | UndertowSystemHttpServerModule |
httpServer.system |
8085 |
SystemHttpServerConfig overrides port() to 8085; it does not inherit the public default.
Its other defaults are readinessPath = /system/readiness, livenessPath = /system/liveness,
metricsPath = /metrics — all served by the system server, so all on 8085 unless reconfigured.
httpServer {
port = 8080
system.port = 8085
}
Renamed keys — a silent failure, not a build error
| 1.x key | 2.0 key |
|---|---|
httpServer.publicApiHttpPort |
httpServer.port |
httpServer.privateApiHttpPort |
httpServer.system.port |
httpServer.privateApiHttpReadinessPath |
httpServer.system.readinessPath |
httpServer.privateApiHttpLivenessPath |
httpServer.system.livenessPath |
httpServer.privateApiHttpMetricsPath |
httpServer.system.metricsPath |
A leftover 1.x key is an unrecognised HOCON key: it is ignored without a warning, and each
server falls back to its own default. A service that deliberately ran on custom ports therefore
comes up green on 8080/8085 instead. In a black-box test that shows up as a readiness wait that
never succeeds, or HTTP/1.1 header parser received no bytes from a request aimed at a port
nothing is listening on. Grep the application's config — and any inline HOCON in test sources —
for the old keys before blaming the container.
Testcontainers coordinates
Use 1.21.4 with the classic module names. kora-bom constrains only io.koraframework:*
artifacts, so it has no opinion on Testcontainers — the version is entirely the application's
choice, and 1.21.4 is what the migrated Kora 2.0 examples run against JDK 25:
testImplementation "org.testcontainers:junit-jupiter:1.21.4"
testImplementation "org.testcontainers:testcontainers:1.21.4"
testImplementation "org.testcontainers:postgresql:1.21.4"
If you move to Testcontainers 2.x, the database and broker modules were renamed —
org.testcontainers:testcontainers-postgresql,testcontainers-kafka,testcontainers-cassandra(Kora's own internal test fixtures use those at2.0.5). The oldorg.testcontainers:postgresql/:kafka/:cassandracoordinates do not resolve on the 2.x line. Kora's catalog pins only the core and those three modules, and says nothing aboutjunit-jupiter— resolve that one's version separately rather than assuming a single property covers every module. Verify the API you use against the version you actually declare, and never mix a 1.x coordinate with a 2.x version.
Some migrated examples reach for io.goodforgod:testcontainers-extensions-*:0.15.0 instead of the
raw modules; that is a convenience layer for provisioning and migrations, not a Kora requirement.
See testcontainers-reference.md.
Asserting on /metrics
A 200 from /metrics proves nothing. With no MetricsScraper in the graph the handler still
answers 200, with the literal body # Metric Scraper disabled. Two things must be true before a
metrics assertion means anything:
io.koraframework:micrometer-moduleis on the application's runtime classpath — that is what supplies the Prometheus registry, the JVM binders and thekora_upgauge.- Component metrics are off by default in 2.0 (
telemetry.metrics.enableddefaults tofalse). Enable them per component in the container's config:
httpServer {
port = 8080
system.port = 8085
telemetry.metrics.enabled = true
}
Then assert on real series, not on the status code:
var response = HttpClient.newHttpClient().send(
HttpRequest.newBuilder().GET().uri(APP.getSystemURI().resolve("/metrics")).build(),
HttpResponse.BodyHandlers.ofString());
assertEquals(200, response.statusCode());
assertFalse(response.body().contains("Metric Scraper disabled"));
assertTrue(response.body().contains("kora_up")); // registry is live
assertTrue(response.body().contains("http_server_request_duration")); // component metrics on
Note that tracing, which defaults to true elsewhere, is overridden to false under
httpServer.system — the system server does not trace its own probe traffic.
GraalVM native images
Black-box tests are the cheapest way to prove a native image actually works, because
a green nativeCompile proves nothing: missing or misnamed reachability metadata builds
cleanly and fails only at runtime. Treat a native module as verified only when all five hold:
- the binary starts and is still alive a few seconds later;
GET /system/readiness→ 200 — the graph initialised in full;GET /metricsreturns real series, not the# Metric Scraper disabledstub and not a 500;- the module's scenario runs against a real dependency (database, broker), not mocks;
- no stack traces in the startup log — often the only sign a subsystem silently dropped out.
The same AppContainer drives it; only the Dockerfile differs (multi-stage
ghcr.io/graalvm/native-image-community:25 builder → slim runtime, exposing 8080 and 8085). Native
startup is slower than JVM startup, so raise the timeout — the migrated native examples allow
50–60 s. Full Dockerfile in docker-reference.md.
Application-owned metadata file names are load-bearing: only reflect-config.json,
resource-config.json, proxy-config.json, serialization-config.json, jni-config.json,
native-image.properties and reachability-metadata.json are read. reflection-config.json —
with the extra ion — is silently ignored, and the build stays green.
What's in references/
| Document | Use it for |
|---|---|
| blackbox-integration-reference.md | Full AppContainer pattern, CRUD, RestAssured, Kafka, error scenarios, Awaitility, probe/metrics assertions, DB verification |
| testcontainers-reference.md | Testcontainers coordinates and API: PostgreSQL, Kafka, wait strategies, container lifecycle |
| docker-reference.md | Dockerfile strategies (JRE, multi-stage, GraalVM native), APP_IMAGE reuse, CI/CD |
| docker-compose-reference.md | Compose as a local/CI environment, health checks, multi-service stacks |
What's in assets/
| Asset | Purpose |
|---|---|
BlackBoxTest.java.template / .kt.template |
HttpClient black-box test skeleton (AppContainer + PostgreSQL on Network.SHARED) |
BlackBoxTest-RestAssured.java.template / .kt.template |
RestAssured DSL black-box test skeleton |
Dockerfile.template |
Runtime image over a prebuilt distTar archive |
Dockerfile.self-build.template / -kotlin.template |
Multi-stage build inside Docker (no JDK on the host) |
When to use vs NOT
Use black-box when:
- Validating the real Docker artifact end-to-end (routing + JSON + validation + migrations + probes)
- Verifying HTTP contracts: status codes, headers, JSON bodies a client actually sees
- Proving a GraalVM native image is more than a green build
- Testing async side effects (Kafka consumer, scheduled jobs) observed through the API
- Reproducing deployment problems: wrong ports, stale config keys, broken packaging, missing env
Do NOT use black-box (use kora-testing-junit-java / -kotlin) when:
- You want fast feedback on business logic in a single component
- You need to mock a collaborator with
@TestComponent+ Mockito/MockK - You need to inject into the Kora graph or override config with
KoraConfigModification
Core patterns
- Readiness gating:
Wait.forHttp("/system/readiness").forPort(8085).forStatusCode(200). The probe lives on the system server. NeverWait.forListeningPort(), neverWait.forLogMessage. - Both ports exposed:
withExposedPorts(8080, 8085)— the wait strategy needs 8085 mapped, and/metricsassertions need it too. - Shared network: every container on
Network.SHAREDwithwithNetworkAliases("postgres"); the app reaches it aspostgres:5432(container port), while the test usesgetMappedPort(...). - Config injection:
withEnv("POSTGRES_JDBC_URL", ...)— names must match the${VAR}substitution keys in the application's config, not magic Testcontainers names. - Startup ordering:
.dependsOn(POSTGRES)so infrastructure starts first. - CI image reuse: branch the
AppContainerconstructor on an env var (APP_IMAGE) to skip the Dockerfile build — see docker-reference.md. - Async assertions: wrap polling reads in Awaitility
await().atMost(...).untilAsserted(...).
Common pitfalls
| Symptom | Cause | Fix |
|---|---|---|
| Container never becomes ready | Waiting on the public port, forListeningPort(), or a log line |
Wait.forHttp("/system/readiness").forPort(8085).forStatusCode(200) |
| Readiness wait times out, app log looks healthy | 8085 not in withExposedPorts |
Expose both 8080 and 8085 |
| Probes/scrapers hit nothing, app is "green" | Stale publicApiHttpPort / privateApiHttpPort silently ignored |
Rename to httpServer.port / httpServer.system.port |
UnsupportedClassVersionError on container start |
Base image below JRE 25 | eclipse-temurin:25-jre-jammy |
application.tar not found at image build |
distTar did not run |
test { dependsOn ":app:distTar" }, and COPY build/distributions/*.tar needs distTar, not installDist |
Config expected value, but got null at path: 'ROOT.jdbc.username' |
1.x db { ... } section |
Rename the section to jdbc { ... } |
| App cannot reach the DB | Per-test Network.newNetwork(), or the host-mapped port used inside the network |
Network.SHARED + alias, container port 5432 |
/metrics returns # Metric Scraper disabled |
No micrometer-module in the app |
Add io.koraframework:micrometer-module |
/metrics is 200 but has no http_server_* series |
telemetry.metrics.enabled defaults to false |
Set httpServer.telemetry.metrics.enabled = true |
| Native image builds green, dies at runtime | Metadata file named reflection-config.json |
Rename to reflect-config.json, then re-run the 5-point checklist |
| Duplicate-key failures across tests | Shared static container + fixed test data | Generate unique values (unique emails) |
Trying @KoraAppTest with GenericContainer |
Mixing in-process and black-box paradigms | Pick one: black-box runs the image, @KoraAppTest runs in-process |