Skip to content

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)
    }
}
./gradlew test          # unit only
./gradlew e2eTest       # e2e only
./gradlew test e2eTest  # both

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

class StoveConfig : AbstractProjectConfig() {
  override suspend fun beforeProject() {
    Stove().with { /* ... */ }.run()
  }
  override suspend fun afterProject() = Stove.stop()
}
class MyTest : FunSpec({
  beforeSpec {
    Stove().with { /* ... */ }.run()
  }
})
Per-test setup starts a new dependency graph and application runtime. That is slower and makes failures harder to compare.

Test data

Unique IDs per run

val orderId = UUID.randomUUID().toString()
val userId  = "user-${UUID.randomUUID()}"
val orderId = "order-123"  // collides on re-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

http {
  postAndExpectBody<OrderResponse>("/orders", body) {
    it.status shouldBe 201
    it.body().id shouldBe orderId
    it.body().status shouldBe "CREATED"
    it.body().createdAt shouldNotBe null
  }
}
http {
  postAndExpectBodilessResponse("/orders", body) {
    it.status shouldBe 201   // proves the route responded, not that the flow completed
  }
}

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

kafka {
  shouldBePublished<Event> {
    actual.id == expectedId
  }
}
http { post("/async-op") }

Thread.sleep(5_000)   // flaky + slow

kafka {
  shouldBeConsumed<Event> { true }
}

External boundaries

Mock at the edge with WireMock

wiremock {
  mockPost("/payments/charge", 200,
    PaymentResult(success = true).some())
}
http { /* drive your app */ }
Call real third-party services in tests. This makes tests slower, less deterministic, and unable to simulate failure modes on demand.

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.

@Configuration
class ExternalServicesConfig(
  @Value("\${payment.url}") val paymentUrl: String,
  @Value("\${inventory.url}") val inventoryUrl: String,
)
Test wires both to WireMock:
springBoot(withParameters = listOf(
  "payment.url=http://localhost:9090",
  "inventory.url=http://localhost:9090"
))
class PaymentClient {
  private val url = "http://payment-service.com"
  // WireMock can't intercept this
}

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

Stove {
  keepDependenciesRunning()
}.with { /* ... */ }.run()

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:

stove {
  mongodb {
    val info = inspect()
    println("id=${info?.containerId} ip=${info?.ipAddress}")
  }
}

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

DEFAULT_REGISTRY = System.getenv("DOCKER_REGISTRY") ?: "docker.io"

Resource caps

couchbase {
  CouchbaseSystemOptions(
    container = CouchbaseContainerOptions(
      containerFn = { c ->
        c.withCreateContainerCmdModifier { cmd ->
          cmd.hostConfig?.withMemory(512L * 1024 * 1024)  // 512MB
        }
      }
    )
  ) { /* ... */ }
}

Anti-patterns to retire

**Test through public APIs.**
http {
  post<OrderResponse>("/orders", body) { /* assertions */ }
}

couchbase {
  shouldGet<Order>("orders", id) { /* assertions */ }
}
**Don't test by writing directly to repos.**
using<OrderRepository> { save(order) }
shouldGet<Order>(id) { /* ... */ }
You're testing your test setup, not the app.
**Independent tests.**
test("create + get user") {
  val id = createUser()
  getUser(id)
}
**Shared mutable state.**
var createdId: String? = null
test("create") { createdId = createUser() }
test("get")    { getUser(createdId!!) }  // order-dependent

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