Skip to content

Best Practices

This guide covers best practices for writing effective end-to-end tests with Stove.

Test Organization

Use Dedicated Source Set for E2E Tests

Instead of placing e2e tests in the regular src/test folder, create a dedicated src/test-e2e source set. This provides better separation between unit/integration tests and e2e tests:

src/
├── main/kotlin/           # Application code
├── test/kotlin/           # Unit tests
└── test-e2e/kotlin/       # E2E tests with Stove
    ├── config/
    │   └── TestConfig.kt
    ├── setup/
    │   └── TestInitializer.kt
    ├── features/
    │   ├── OrderE2ETest.kt
    │   ├── UserE2ETest.kt
    │   └── ProductE2ETest.kt
    └── shared/
        ├── TestData.kt
        └── Assertions.kt

Gradle Configuration for test-e2e

Configure the test-e2e source set in your build.gradle.kts:

sourceSets {
    @Suppress("LocalVariableName")
    val `test-e2e` by creating {
        compileClasspath += sourceSets.main.get().output
        runtimeClasspath += sourceSets.main.get().output
    }

    val testE2eImplementation by configurations.getting {
        extendsFrom(configurations.testImplementation.get())
    }
    configurations["testE2eRuntimeOnly"].extendsFrom(configurations.runtimeOnly.get())
}

// Register e2e test task
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)
    }
}

// Configure IDEA to recognize test-e2e as test sources
idea {
    module {
        testSources.from(sourceSets["test-e2e"].allSource.sourceDirectories)
        testResources.from(sourceSets["test-e2e"].resources.sourceDirectories)
    }
}

Running E2E Tests

# Run only e2e tests
./gradlew e2eTest

# Run unit tests (doesn't include e2e)
./gradlew test

# Run all tests
./gradlew test e2eTest

Benefits of Separate Source Set

Benefit Description
Isolation E2E tests run independently from unit tests
CI Flexibility Run unit tests quickly, e2e tests separately or in parallel
Resource Management Different JVM settings for e2e tests (more memory, longer timeouts)
Clear Boundaries Developers know exactly where e2e tests live

See Examples

Check the recipes folder for complete working examples with this structure.

Single Setup, Multiple Tests

Configure Stove once for all tests:

// ✅ Good: Single configuration for all tests
class TestConfig : AbstractProjectConfig() {
    override suspend fun beforeProject() {
        TestSystem()
            .with { /* configuration */ }
            .run()
    }

    override suspend fun afterProject() {
        TestSystem.stop()
    }
}

// ❌ Bad: Configuration per test class
class MyTest : FunSpec({
    beforeSpec {
        TestSystem().with { /* */ }.run()  // Don't do this!
    }
})

Test Data Management

Use Unique Test Data

Generate unique identifiers to prevent test interference:

// ✅ Good: Unique data per test
test("should create order") {
    val orderId = UUID.randomUUID().toString()
    val userId = "user-${UUID.randomUUID()}"

    TestSystem.validate {
        http {
            postAndExpectBody<OrderResponse>(
                uri = "/orders",
                body = CreateOrderRequest(id = orderId, userId = userId).some()
            ) { /* assertions */ }
        }
    }
}

// ❌ Bad: Hardcoded IDs that may conflict
test("should create order") {
    val orderId = "order-123"  // May conflict with other tests
    // ...
}

Isolate Shared Infrastructure Resources

When using provided instances (shared infrastructure in CI/CD), use unique prefixes for all resources to prevent parallel test runs from interfering with each other:

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}_"
}

// Use unique names in configuration
TestSystem()
    .with {
        postgresql {
            PostgresqlOptions.provided(
                databaseName = TestRunContext.databaseName,
                // ...
            )
        }
        springBoot(
            withParameters = listOf(
                "kafka.topic.orders=${TestRunContext.topicPrefix}orders",
                "elasticsearch.index.products=${TestRunContext.indexPrefix}products"
            )
        )
    }

Detailed Guide

See Provided Instances - Test Isolation for comprehensive examples for each system.

Use Cleanup Functions

Clean up test data to maintain isolation. The cleanup parameter is passed inside the options:

TestSystem()
    .with {
        couchbase {
            CouchbaseSystemOptions(
                defaultBucket = "bucket",
                cleanup = { cluster ->
                    // Clean test data after tests complete
                    cluster.query("DELETE FROM `bucket` WHERE type = 'test'")
                },
                configureExposedConfiguration = { cfg ->
                    listOf(
                        "couchbase.hosts=${cfg.hostsWithPort}",
                        "couchbase.username=${cfg.username}",
                        "couchbase.password=${cfg.password}"
                    )
                }
            )
        }

        kafka {
            KafkaSystemOptions(
                cleanup = { admin ->
                    // Delete test topics after tests complete
                    val testTopics = admin.listTopics().names().get()
                        .filter { it.startsWith("test-") }
                    if (testTopics.isNotEmpty()) {
                        admin.deleteTopics(testTopics).all().get()
                    }
                },
                configureExposedConfiguration = { cfg ->
                    listOf(
                        "kafka.bootstrapServers=${cfg.bootstrapServers}",
                        "kafka.interceptorClasses=${cfg.interceptorClass}"
                    )
                }
            )
        }
    }
    .run()

Test Data Builders

Create reusable test data builders:

object TestData {
    fun createUser(
        id: String = UUID.randomUUID().toString(),
        name: String = "Test User",
        email: String = "test-${UUID.randomUUID()}@example.com"
    ) = User(id = id, name = name, email = email)

    fun createProduct(
        id: String = UUID.randomUUID().toString(),
        name: String = "Test Product",
        price: Double = 99.99
    ) = Product(id = id, name = name, price = price)
}

// Usage in tests
test("should create user") {
    val user = TestData.createUser(name = "John Doe")
    // ...
}

Assertions

Be Specific with Assertions

Test specific behaviors, not just successful responses:

// ✅ Good: Specific assertions
TestSystem.validate {
    http {
        postAndExpectBody<OrderResponse>(
            uri = "/orders",
            body = CreateOrderRequest(id = orderId, amount = 99.99).some()
        ) { response ->
            response.status shouldBe 201
            response.body().id shouldBe orderId
            response.body().amount shouldBe 99.99
            response.body().status shouldBe "CREATED"
            response.body().createdAt shouldNotBe null
        }
    }
}

// ❌ Bad: Only checking status code
TestSystem.validate {
    http {
        postAndExpectBodilessResponse("/orders", body = order.some()) { response ->
            response.status shouldBe 201  // Not enough!
        }
    }
}

Verify Side Effects

Test the complete flow including side effects:

test("should process order completely") {
    val orderId = UUID.randomUUID().toString()

    TestSystem.validate {
        // 1. Make the request
        http {
            postAndExpectBody<OrderResponse>(
                uri = "/orders",
                body = CreateOrderRequest(id = orderId).some()
            ) { response ->
                response.status shouldBe 201
            }
        }

        // 2. Verify database state
        couchbase {
            shouldGet<Order>("orders", orderId) { order ->
                order.status shouldBe "CREATED"
            }
        }

        // 3. Verify event was published
        kafka {
            shouldBePublished<OrderCreatedEvent>(atLeastIn = 10.seconds) {
                actual.orderId == orderId
            }
        }

        // 4. Verify search index updated
        elasticsearch {
            shouldGet<Order>(index = "orders", key = orderId) { order ->
                order.status shouldBe "CREATED"
            }
        }

        // 5. Verify cache populated
        redis {
            client().connect().sync().get("order:$orderId") shouldNotBe null
        }
    }
}

Performance

Use keepDependenciesRunning for Development

Speed up local development:

TestSystem {
    keepDependenciesRunning()  // Containers stay running between test runs
}.with {
    // ...
}.run()

Tip

Disable keepDependenciesRunning() in CI/CD for clean environments.

Configure Appropriate Timeouts

Set realistic timeouts for your environment:

// HTTP client timeout
http {
    HttpClientSystemOptions(
        baseUrl = "http://localhost:8080",
        timeout = 30.seconds  // Adjust based on your app's response times
    )
}

// Kafka assertion timeout
kafka {
    shouldBePublished<OrderCreatedEvent>(atLeastIn = 20.seconds) {
        // Allow enough time for async processing
        actual.orderId == orderId
    }
}

Run Tests in Parallel (With Care)

If running tests in parallel, ensure proper isolation:

// Use unique data per test
test("test 1") {
    val id = UUID.randomUUID().toString()  // Unique per test
    // ...
}

test("test 2") {
    val id = UUID.randomUUID().toString()  // Different ID
    // ...
}

External Services

Mock External Dependencies

Use WireMock for external services:

// ✅ Good: Mock external services
TestSystem.validate {
    wiremock {
        mockPost(
            url = "/payments/charge",
            statusCode = 200,
            responseBody = PaymentResult(success = true, transactionId = "tx-123").some()
        )
    }

    http {
        postAndExpectBody<OrderResponse>(
            uri = "/orders",
            body = CreateOrderRequest(amount = 99.99).some()
        ) { response ->
            response.body().paymentStatus shouldBe "PAID"
        }
    }
}

// ❌ Bad: Calling real external services in tests
// - Tests become flaky
// - Tests are slow
// - May incur costs
// - Can't test edge cases

Test Error Scenarios

Test how your application handles failures:

test("should handle payment failure gracefully") {
    TestSystem.validate {
        wiremock {
            mockPost(
                url = "/payments/charge",
                statusCode = 500,
                responseBody = ErrorResponse("Payment service unavailable").some()
            )
        }

        http {
            postAndExpectBody<OrderResponse>(
                uri = "/orders",
                body = CreateOrderRequest(amount = 99.99).some()
            ) { response ->
                response.status shouldBe 503
                response.body().status shouldBe "PAYMENT_FAILED"
            }
        }
    }
}

test("should retry on transient failures") {
    TestSystem.validate {
        wiremock {
            behaviourFor("/payments/charge", WireMock::post) {
                initially {
                    aResponse().withStatus(503)
                }
                then {
                    aResponse().withStatus(503)
                }
                then {
                    aResponse()
                        .withStatus(200)
                        .withBody(it.serialize(PaymentResult(success = true)))
                }
            }
        }

        // Application should retry and eventually succeed
        http {
            postAndExpectBody<OrderResponse>(
                uri = "/orders",
                body = CreateOrderRequest(amount = 99.99).some()
            ) { response ->
                response.status shouldBe 201
            }
        }
    }
}

Serialization

Align Serializers

Ensure Stove uses the same serialization as your application:

// If your app uses custom Jackson configuration
val customObjectMapper = ObjectMapper().apply {
    registerModule(JavaTimeModule())
    disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
    setSerializationInclusion(JsonInclude.Include.NON_NULL)
}

TestSystem()
    .with {
        http {
            HttpClientSystemOptions(
                baseUrl = "http://localhost:8080",
                contentConverter = JacksonConverter(customObjectMapper)
            )
        }

        kafka {
            KafkaSystemOptions(
                serde = StoveSerde.jackson.anyByteArraySerde(customObjectMapper)
            ) { /* config */ }
        }

        wiremock {
            WireMockSystemOptions(
                serde = StoveSerde.jackson.anyByteArraySerde(customObjectMapper)
            )
        }
    }
    .run()

Application Configuration

Make Configuration Testable

Your application should accept configuration from various sources:

// ✅ Good: Configurable properties
@Configuration
class KafkaConfig(
    @Value("\${kafka.bootstrapServers}") private val bootstrapServers: String,
    @Value("\${kafka.offset:latest}") private val offset: String,
    @Value("\${kafka.autoCreateTopics:false}") private val autoCreate: Boolean
) {
    // Stove can override these via command line args
}

External Service URLs Must Be Configurable

When using WireMock, all external service URLs must point to WireMock's URL:

// ✅ Good: External service URLs are configurable
@Configuration
class ExternalServicesConfig(
    @Value("\${payment.service.url}") val paymentUrl: String,
    @Value("\${inventory.service.url}") val inventoryUrl: String
)

// In tests, pass WireMock URL for all external services
TestSystem()
    .with {
        wiremock {
            WireMockSystemOptions(port = 9090)
        }
        springBoot(
            withParameters = listOf(
                "payment.service.url=http://localhost:9090",
                "inventory.service.url=http://localhost:9090"
            )
        )
    }
// ❌ Bad: Hardcoded URLs won't be intercepted by WireMock
class PaymentClient {
    private val url = "http://payment-service.com"  // WireMock can't intercept this!
}
// ❌ Bad: Hardcoded values
@Configuration
class KafkaConfig {
    private val bootstrapServers = "localhost:9092"  // Can't change in tests!
}

Use Test Profiles Wisely

Minimize differences between test and production:

springBoot(
    runner = { params -> myApp.run(params) },
    withParameters = listOf(
        "server.port=8080",
        "spring.profiles.active=default",  // Use default profile when possible
        "logging.level.root=warn",
        // Override only what's necessary
        "kafka.bootstrapServers=${kafkaConfig.bootstrapServers}"
    )
)

Debugging

Enable Verbose Logging When Needed

springBoot(
    runner = { params -> myApp.run(params) },
    withParameters = listOf(
        "logging.level.root=debug",  // For debugging
        "logging.level.org.springframework.web=trace"
    )
)

Use Container Inspection

Debug container issues:

TestSystem.validate {
    mongodb {
        val info = inspect()
        println("Container ID: ${info?.containerId}")
        println("Network: ${info?.network}")
        println("IP: ${info?.ipAddress}")
    }
}

Access Application Beans

Debug by accessing application components:

TestSystem.validate {
    using<OrderRepository> {
        val order = findById(orderId)
        println("Order state: $order")
    }

    using<OrderService, PaymentService> { orderService, paymentService ->
        // Debug complex scenarios
    }
}

CI/CD Considerations

Use Provided Instances in CI

For faster CI builds, use pre-provisioned infrastructure:

val isCI = System.getenv("CI") == "true"

TestSystem()
    .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}")
                }
            }
        }
    }
    .run()

Configure Docker Registry

For corporate environments:

// Set globally for all components
DEFAULT_REGISTRY = System.getenv("DOCKER_REGISTRY") ?: "docker.io"

Handle Resource Constraints

Configure for CI resource limits:

TestSystem()
    .with {
        couchbase {
            CouchbaseSystemOptions(
                container = CouchbaseContainerOptions(
                    containerFn = { container ->
                        container.withCreateContainerCmdModifier { cmd ->
                            cmd.hostConfig?.withMemory(512 * 1024 * 1024)  // 512MB limit
                        }
                    }
                )
            ) { /* config */ }
        }
    }
    .run()

Common Anti-Patterns

❌ Testing Implementation Details

// Bad: Testing internal implementation
using<OrderRepository> {
    save(order)
}
shouldGet<Order>(orderId) { /* verify */ }

// Good: Test through the API
http {
    postAndExpectBody<OrderResponse>("/orders", body = order.some()) { /* verify */ }
}
couchbase {
    shouldGet<Order>("orders", orderId) { /* verify */ }
}

❌ Sleeping Instead of Waiting

// Bad: Fixed sleep
http { post("/async-operation") }
Thread.sleep(5000)  // Fragile!
kafka { shouldBeConsumed<Event> { true } }

// Good: Poll with timeout
kafka {
    shouldBePublished<Event>(atLeastIn = 10.seconds) {
        actual.id == expectedId
    }
}

❌ Sharing State Between Tests

// Bad: Shared mutable state
var createdUserId: String? = null

test("create user") {
    createdUserId = createUser()
}

test("get user") {
    getUser(createdUserId!!)  // Depends on test order!
}

// Good: Independent tests
test("create and get user") {
    val userId = createUser()
    getUser(userId)
}

❌ Overly Broad Assertions

// Bad: Too vague
response.status shouldBe 200

// Good: Specific assertions
response.status shouldBe 200
response.body().id shouldBe expectedId
response.body().status shouldBe "ACTIVE"
response.body().createdAt shouldNotBe null

Summary

Do Don't
Use unique test data Use hardcoded IDs
Test through public APIs Test implementation details
Mock external services Call real external services
Use appropriate timeouts Use fixed sleeps
Clean up test data Leave test artifacts
Keep tests independent Share state between tests
Be specific in assertions Use vague assertions
Test error scenarios Only test happy paths