Best Practices¶
Patterns that keep Stove suites fast, diagnosable, and safe to run repeatedly. Treat them as defaults unless your runtime or CI environment gives you a concrete reason to diverge.
The short list Dedicated e2e source set · Stove configured once per suite · unique IDs per run · time-bounded waits · external services mocked at the boundary · aligned serializers · specific assertions · cleanup for shared infrastructure.
Test organization¶
Use a dedicated source set¶
Put e2e tests in src/test-e2e/ so unit tests keep their fast feedback loop and CI can run the e2e suite as a separate verification task.
src/
├── main/kotlin/
├── test/kotlin/ unit tests
└── test-e2e/kotlin/ Stove tests
├── setup/StoveConfig.kt
├── features/OrderE2ETest.kt
└── shared/{TestData,Assertions}.kt
Gradle wiring (build.gradle.kts):
sourceSets {
val `test-e2e` by creating {
compileClasspath += sourceSets.main.get().output
runtimeClasspath += sourceSets.main.get().output
}
configurations["testE2eImplementation"].extendsFrom(configurations.testImplementation.get())
configurations["testE2eRuntimeOnly"].extendsFrom(configurations.runtimeOnly.get())
}
tasks.register<Test>("e2eTest") {
description = "Runs e2e tests."
group = "verification"
testClassesDirs = sourceSets["test-e2e"].output.classesDirs
classpath = sourceSets["test-e2e"].runtimeClasspath
useJUnitPlatform()
reports { junitXml.required.set(true); html.required.set(true) }
}
idea {
module {
testSources.from(sourceSets["test-e2e"].allSource.sourceDirectories)
testResources.from(sourceSets["test-e2e"].resources.sourceDirectories)
}
}
Benefits: isolated Gradle task, CI parallelism, separate JVM tuning, and a clear boundary between unit tests and runtime tests.
Configure Stove once, not per test¶
Test data¶
Unique IDs per run¶
Shared-infra isolation (CI)¶
When using Provided Instances, prefix every shared resource with a run ID:
object TestRunContext {
val runId: String = System.getenv("CI_JOB_ID")
?: UUID.randomUUID().toString().take(8)
val databaseName = "testdb_$runId"
val topicPrefix = "test_${runId}_"
val indexPrefix = "test_${runId}_"
}
Stove().with {
postgresql { PostgresqlOptions.provided(databaseName = TestRunContext.databaseName, /* ... */) }
springBoot(withParameters = listOf(
"kafka.topic.orders=${TestRunContext.topicPrefix}orders",
"elasticsearch.index.products=${TestRunContext.indexPrefix}products"
))
}
See Provided Instances · isolation for per-system patterns.
Cleanup hooks¶
Database, messaging, cache, and other stateful system options expose cleanup where supported. Stove calls cleanup when
the suite stops, before the system runtime is disposed. Use it on shared infrastructure; for disposable Testcontainers,
unique test data is often enough.
couchbase {
CouchbaseSystemOptions(
defaultBucket = "bucket",
cleanup = { cluster ->
cluster.query("DELETE FROM `bucket` WHERE type = 'test'")
},
configureExposedConfiguration = { cfg -> listOf(/* ... */) }
)
}
kafka {
KafkaSystemOptions(
cleanup = { admin ->
admin.listTopics().names().get()
.filter { it.startsWith("test-") }
.takeIf { it.isNotEmpty() }
?.let { admin.deleteTopics(it).all().get() }
},
configureExposedConfiguration = { cfg -> listOf(/* ... */) }
)
}
Test data builders¶
Centralize defaults so each test reads as the behavior being exercised, not the object construction needed to reach it.
object TestData {
fun user(id: String = UUID.randomUUID().toString(), name: String = "Test User") =
User(id, name, email = "$id@example.com")
}
Assertions¶
Be specific¶
Verify side effects, not just the response¶
A useful e2e assertion follows the observable side effects of the use case: request -> DB row -> published event -> search index -> cache.
test("order is fully processed") {
val orderId = UUID.randomUUID().toString()
stove {
http {
postAndExpectBody<OrderResponse>(
"/orders",
CreateOrderRequest(orderId).some()
) {
it.status shouldBe 201
}
}
couchbase {
shouldGet<Order>("orders", orderId) {
it.status shouldBe "CREATED"
}
}
kafka {
shouldBePublished<OrderCreatedEvent> {
actual.orderId == orderId
}
}
elasticsearch {
shouldGet<Order>(index = "orders", key = orderId) {
it.status shouldBe "CREATED"
}
}
redis {
client().connect().sync().get("order:$orderId") shouldNotBe null
}
}
}
Wait with timeout, never sleep¶
External boundaries¶
Mock at the edge with WireMock¶
Cover error scenarios¶
test("graceful degradation on payment 500") {
stove {
wiremock { mockPost("/payments/charge", 500, ErrorResponse("down").some()) }
http { postAndExpectBody<OrderResponse>("/orders", req) {
it.status shouldBe 503
it.body().status shouldBe "PAYMENT_FAILED"
} }
}
}
test("retry on transient 503s") {
stove {
wiremock {
behaviourFor("/payments/charge", WireMock::post) {
initially { aResponse().withStatus(503) }
then { aResponse().withStatus(503) }
then { aResponse().withStatus(200)
.withBody(it.serialize(PaymentResult(success = true))) }
}
}
http { postAndExpectBody<OrderResponse>("/orders", req) {
it.status shouldBe 201 // retried and succeeded
} }
}
}
External URLs must be configurable¶
WireMock can only stand in for an external service if the application reads the service URL from configuration.
Serialization¶
Stove's serializers must match the application's serializers. If HTTP, Kafka, or WireMock use a different mapper from the app, failures often show up as null fields, unknown properties, or date/time parsing errors instead of clear domain errors.
val mapper = ObjectMapper().apply {
registerModule(JavaTimeModule())
disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
setSerializationInclusion(JsonInclude.Include.NON_NULL)
}
Stove().with {
http {
HttpClientSystemOptions(
baseUrl = "...",
contentConverter = JacksonConverter(mapper)
)
}
kafka {
KafkaSystemOptions(
serde = StoveSerde.jackson.anyByteArraySerde(mapper)
) { cfg -> listOf(/* ... */) }
}
wiremock {
WireMockSystemOptions(
serde = StoveSerde.jackson.anyByteArraySerde(mapper)
)
}
}
Performance¶
Keep containers warm during dev¶
Disable this in CI when each job should start from a clean dependency runtime.
Configure realistic timeouts¶
http {
HttpClientSystemOptions(baseUrl = "...", timeout = 30.seconds)
}
kafka {
shouldBePublished<Event> {
actual.id == id
}
}
Parallel-safe only when data is unique¶
Parallel execution is safe when tests do not share identifiers, topics, schemas, indexes, or mutable in-memory state. Use per-test IDs locally and per-run prefixes on shared infrastructure.
Debugging tools¶
Verbose logging when you need it:
springBoot(withParameters = listOf(
"logging.level.root=debug",
"logging.level.org.springframework.web=trace"
))
Inspect containers from inside a test:
Reach into DI via Bridge:
stove {
using<OrderRepository> { println(findById(orderId)) }
using<OrderService, PaymentService> { svc, pay -> /* ... */ }
}
CI/CD¶
Pick provided over containers when CI has shared infra¶
val isCI = System.getenv("CI") == "true"
Stove().with {
kafka {
if (isCI) {
KafkaSystemOptions.provided(
bootstrapServers = System.getenv("KAFKA_SERVERS"),
configureExposedConfiguration = { cfg ->
listOf("kafka.bootstrapServers=${cfg.bootstrapServers}")
}
)
} else {
KafkaSystemOptions { listOf("kafka.bootstrapServers=${it.bootstrapServers}") }
}
}
}
Corporate registry mirror¶
Resource caps¶
couchbase {
CouchbaseSystemOptions(
container = CouchbaseContainerOptions(
containerFn = { c ->
c.withCreateContainerCmdModifier { cmd ->
cmd.hostConfig?.withMemory(512L * 1024 * 1024) // 512MB
}
}
)
) { /* ... */ }
}
Anti-patterns to retire¶
Summary cheat sheet¶
| Do | Don't |
|---|---|
| Unique IDs per run | Hardcoded IDs |
| Test through public APIs | Test implementation details |
| Mock external services | Call real third parties |
atLeastIn = N.seconds |
Thread.sleep(...) |
| Cleanup on shared infra | Leave artifacts |
| Independent tests | Share state between tests |
| Specific assertions on full payload | status shouldBe 200 and done |
| Test failure paths | Only test happy paths |
Aligned StoveSerde |
Mismatched mappers |
Dedicated test-e2e source set |
Mixing unit + e2e |