Writing Custom Systems¶
One of Stove's most powerful features is its extensibility. You can create your own custom systems to integrate with any component or capture any behavior specific to your application.
Why Write Custom Systems?¶
Custom systems are useful when you need to:
- Capture application events in memory for testing
- Integrate with schedulers like db-scheduler, Quartz, or custom job runners
- Test domain-specific behavior that isn't covered by built-in components
- Advance time or control time-bounded operations
- Access custom application components during tests
Core Concepts¶
PluggedSystem Interface¶
All Stove systems implement the PluggedSystem interface:
Lifecycle Interfaces¶
Stove provides several lifecycle interfaces your system can implement:
| Interface | Method | When Called |
|---|---|---|
RunAware |
run() |
Before application starts |
AfterRunAware |
afterRun() |
After application starts |
AfterRunAwareWithContext<T> |
afterRun(context: T) |
After application starts, with DI context |
ExposesConfiguration |
configuration() |
When collecting app configuration |
Registration Functions¶
To make your system available in the DSL, you need:
- Registration function - Adds system to TestSystem
- Getter function - Retrieves system from TestSystem
- DSL extension functions - For
WithDslandValidationDsl
Example 1: Db-Scheduler Integration¶
Here's a complete example of integrating with db-scheduler:
Step 1: Create the Event Listener¶
First, create a listener that captures scheduler events:
import com.github.kagkarlsson.scheduler.task.ExecutionComplete
import com.github.kagkarlsson.scheduler.task.TaskInstanceId
import java.time.Instant
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ConcurrentMap
import kotlin.reflect.KClass
import kotlin.time.Duration
import kotlinx.coroutines.*
class StoveDbSchedulerListener : AbstractSchedulerListener() {
private val completedExecutions: ConcurrentMap<String, ExecutionComplete> = ConcurrentHashMap()
private val scheduledExecutions: ConcurrentMap<String, Instant> = ConcurrentHashMap()
override fun onExecutionComplete(executionComplete: ExecutionComplete) {
completedExecutions[executionComplete.execution.taskInstance.id] = executionComplete
}
override fun onExecutionScheduled(taskInstanceId: TaskInstanceId, executionTime: Instant) {
scheduledExecutions[taskInstanceId.id] = executionTime
}
@Suppress("UNCHECKED_CAST")
suspend fun <T : Any> waitUntilObserved(
atLeastIn: Duration,
clazz: KClass<T>,
condition: (T) -> Boolean
) = coroutineScope {
val getExecutions = { completedExecutions.map { it.value } }
val getExecutionData = { getExecutions().mapNotNull { it.execution.taskInstance?.data } }
getExecutionData.waitUntilConditionMet(atLeastIn, "While OBSERVING ${clazz.java.simpleName}") {
when {
clazz.java.isAssignableFrom(it.javaClass) -> condition(it as T)
else -> false
}
}
}
private suspend fun <T> (() -> Collection<T>).waitUntilConditionMet(
duration: Duration,
subject: String,
condition: (T) -> Boolean
): Collection<T> = runCatching {
val collectionFunc = this
withTimeout(duration) {
while (!collectionFunc().any { condition(it) }) {
delay(50)
}
}
return collectionFunc().filter { condition(it) }
}.recoverCatching {
when (it) {
is TimeoutCancellationException -> throw AssertionError("GOT A TIMEOUT: $subject.")
is ConcurrentModificationException ->
Result.success(waitUntilConditionMet(duration, subject, condition))
else -> throw it
}.getOrThrow()
}.getOrThrow()
}
Step 2: Create the System¶
Create the system class that integrates with Stove:
import com.trendyol.stove.testing.e2e.system.TestSystem
import com.trendyol.stove.testing.e2e.system.abstractions.*
import org.springframework.context.ApplicationContext
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
import kotlinx.coroutines.coroutineScope
class DbSchedulerSystem(
override val testSystem: TestSystem
) : PluggedSystem, AfterRunAwareWithContext<ApplicationContext> {
lateinit var listener: StoveDbSchedulerListener
override suspend fun afterRun(context: ApplicationContext) {
// Get the listener bean from Spring context
listener = context.getBean(StoveDbSchedulerListener::class.java)
}
/**
* Assert that a task was executed with the given condition.
*/
suspend inline fun <reified T : Any> shouldBeExecuted(
atLeastIn: Duration = 5.seconds,
noinline condition: T.() -> Boolean
): DbSchedulerSystem = coroutineScope {
listener.waitUntilObserved(atLeastIn, T::class, condition)
}.let { this }
override fun close() {
// Cleanup if needed
}
}
Step 3: Create DSL Extensions¶
Create extension functions for the Stove DSL:
import arrow.core.getOrElse
import com.trendyol.stove.testing.e2e.system.*
import com.trendyol.stove.testing.e2e.system.abstractions.*
import com.trendyol.stove.testing.e2e.system.annotations.StoveDsl
/**
* Registers the DbSchedulerSystem with TestSystem.
*/
@StoveDsl
fun TestSystem.withDbSchedulerListener(): TestSystem =
getOrRegister(DbSchedulerSystem(this)).let { this }
/**
* Gets the DbSchedulerSystem from TestSystem.
*/
@StoveDsl
fun TestSystem.dbScheduler(): DbSchedulerSystem =
getOrNone<DbSchedulerSystem>().getOrElse {
throw SystemNotRegisteredException(DbSchedulerSystem::class)
}
/**
* Configuration DSL extension.
*/
@StoveDsl
fun WithDsl.dbScheduler(): TestSystem =
this.testSystem.withDbSchedulerListener()
/**
* Validation DSL extension.
*/
@StoveDsl
suspend fun ValidationDsl.tasks(
validation: suspend DbSchedulerSystem.() -> Unit
): Unit = validation(this.testSystem.dbScheduler())
Step 4: Register the Listener Bean¶
In your test initializer, register the listener as a Spring bean:
class TestInitializer : BaseApplicationContextInitializer({
bean<StoveDbSchedulerListener>(isPrimary = true)
})
fun SpringApplication.addTestDependencies() {
this.addInitializers(TestInitializer())
}
Step 5: Use in Tests¶
// Configuration
TestSystem()
.with {
httpClient { HttpClientSystemOptions(...) }
postgresql { PostgresqlOptions(...) }
dbScheduler() // Register the custom system
springBoot(
runner = { params ->
myApp.run(params) { addTestDependencies() }
}
)
}
.run()
// In tests
test("should execute scheduled task") {
TestSystem.validate {
// Trigger task scheduling
http {
postAndExpectBodilessResponse("/schedule-task", body = TaskRequest(...).some()) {
it.status shouldBe 200
}
}
// Assert task was executed
tasks {
shouldBeExecuted<MyScheduledTaskData>(atLeastIn = 10.seconds) {
taskId == expectedTaskId &&
status == "COMPLETED"
}
}
}
}
Example 2: In-Memory Event Capture System¶
Here's another example for capturing domain events published by your application:
Step 1: Create Event Listener¶
import org.springframework.context.event.EventListener
import java.util.concurrent.ConcurrentLinkedQueue
import kotlin.reflect.KClass
/**
* Captures all domain events in memory for testing.
*/
class StoveDomainEventListener {
private val capturedEvents = ConcurrentLinkedQueue<Any>()
@EventListener
fun onEvent(event: Any) {
capturedEvents.add(event)
}
fun getAllEvents(): List<Any> = capturedEvents.toList()
@Suppress("UNCHECKED_CAST")
fun <T : Any> getEventsOfType(clazz: KClass<T>): List<T> =
capturedEvents
.filter { clazz.java.isAssignableFrom(it.javaClass) }
.map { it as T }
fun clear() = capturedEvents.clear()
}
Step 2: Create the System¶
import com.trendyol.stove.testing.e2e.system.TestSystem
import com.trendyol.stove.testing.e2e.system.abstractions.*
import kotlinx.coroutines.*
import org.springframework.context.ApplicationContext
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
class DomainEventSystem(
override val testSystem: TestSystem
) : PluggedSystem, AfterRunAwareWithContext<ApplicationContext> {
private lateinit var listener: StoveDomainEventListener
override suspend fun afterRun(context: ApplicationContext) {
listener = context.getBean(StoveDomainEventListener::class.java)
}
/**
* Assert that an event of type T was published matching the condition.
*/
suspend inline fun <reified T : Any> shouldBePublished(
atLeastIn: Duration = 5.seconds,
crossinline condition: T.() -> Boolean
): DomainEventSystem = coroutineScope {
waitUntilEventObserved(atLeastIn, T::class) { condition(it) }
this@DomainEventSystem
}
/**
* Assert that no event of type T was published matching the condition.
*/
inline fun <reified T : Any> shouldNotBePublished(
condition: T.() -> Boolean
): DomainEventSystem {
val events = listener.getEventsOfType(T::class)
val matchingEvents = events.filter { condition(it) }
if (matchingEvents.isNotEmpty()) {
throw AssertionError(
"Expected no ${T::class.simpleName} matching condition, " +
"but found ${matchingEvents.size}: $matchingEvents"
)
}
return this
}
/**
* Get all captured events of type T.
*/
inline fun <reified T : Any> getEvents(): List<T> =
listener.getEventsOfType(T::class)
/**
* Clear all captured events.
*/
fun clearEvents(): DomainEventSystem {
listener.clear()
return this
}
@Suppress("UNCHECKED_CAST")
suspend fun <T : Any> waitUntilEventObserved(
atLeastIn: Duration,
clazz: KClass<T>,
condition: (T) -> Boolean
): T = withTimeout(atLeastIn) {
while (true) {
val events = listener.getEventsOfType(clazz)
val matching = events.find { condition(it) }
if (matching != null) {
return@withTimeout matching
}
delay(50)
}
@Suppress("UNREACHABLE_CODE")
throw AssertionError("Should not reach here")
}
override fun close() {
// Cleanup if needed
}
}
Step 3: Create DSL Extensions¶
import arrow.core.getOrElse
import com.trendyol.stove.testing.e2e.system.*
import com.trendyol.stove.testing.e2e.system.abstractions.*
import com.trendyol.stove.testing.e2e.system.annotations.StoveDsl
@StoveDsl
fun TestSystem.withDomainEvents(): TestSystem =
getOrRegister(DomainEventSystem(this)).let { this }
@StoveDsl
fun TestSystem.domainEvents(): DomainEventSystem =
getOrNone<DomainEventSystem>().getOrElse {
throw SystemNotRegisteredException(DomainEventSystem::class)
}
@StoveDsl
fun WithDsl.domainEvents(): TestSystem =
this.testSystem.withDomainEvents()
@StoveDsl
suspend fun ValidationDsl.domainEvents(
validation: suspend DomainEventSystem.() -> Unit
): Unit = validation(this.testSystem.domainEvents())
Step 4: Register and Use¶
// TestInitializer
class TestInitializer : BaseApplicationContextInitializer({
bean<StoveDomainEventListener>(isPrimary = true)
})
// Configuration
TestSystem()
.with {
httpClient { HttpClientSystemOptions(...) }
domainEvents() // Register custom system
springBoot(
runner = { params -> myApp.run(params) { addTestDependencies() } }
)
}
.run()
// Tests
test("should publish UserCreatedEvent when user is created") {
TestSystem.validate {
val userId = UUID.randomUUID().toString()
http {
postAndExpectBody<UserResponse>(
uri = "/users",
body = CreateUserRequest(id = userId, name = "John").some()
) { response ->
response.status shouldBe 201
}
}
domainEvents {
shouldBePublished<UserCreatedEvent>(atLeastIn = 5.seconds) {
this.userId == userId &&
this.name == "John"
}
shouldNotBePublished<UserDeletedEvent> {
this.userId == userId
}
}
}
}
Example 3: Time Control System¶
Control time-bounded operations in your tests:
import java.time.Clock
import java.time.Instant
import java.time.ZoneId
class StoveTestClock : Clock() {
@Volatile
private var instant: Instant = Instant.now()
private val zone: ZoneId = ZoneId.systemDefault()
override fun instant(): Instant = instant
override fun withZone(zone: ZoneId): Clock = this
override fun getZone(): ZoneId = zone
fun advance(duration: java.time.Duration) {
instant = instant.plus(duration)
}
fun setTime(newInstant: Instant) {
instant = newInstant
}
fun reset() {
instant = Instant.now()
}
}
class TimeSystem(
override val testSystem: TestSystem
) : PluggedSystem, AfterRunAwareWithContext<ApplicationContext> {
private lateinit var clock: StoveTestClock
override suspend fun afterRun(context: ApplicationContext) {
clock = context.getBean(StoveTestClock::class.java)
}
fun advance(duration: kotlin.time.Duration): TimeSystem {
clock.advance(java.time.Duration.ofMillis(duration.inWholeMilliseconds))
return this
}
fun setTime(instant: Instant): TimeSystem {
clock.setTime(instant)
return this
}
fun reset(): TimeSystem {
clock.reset()
return this
}
override fun close() {}
}
// DSL Extensions
@StoveDsl
fun WithDsl.timeControl(): TestSystem =
testSystem.getOrRegister(TimeSystem(testSystem)).let { testSystem }
@StoveDsl
suspend fun ValidationDsl.time(
action: suspend TimeSystem.() -> Unit
): Unit = action(testSystem.getOrNone<TimeSystem>().getOrElse {
throw SystemNotRegisteredException(TimeSystem::class)
})
// Usage in tests
test("should expire session after 30 minutes") {
TestSystem.validate {
// Create session and capture session ID
var sessionId: String = ""
http {
postAndExpectBody<SessionResponse>("/login", body = credentials.some()) { response ->
sessionId = response.body().sessionId
}
}
// Advance time by 31 minutes
time {
advance(31.minutes)
}
// Session should be expired
http {
getResponse<ErrorResponse>("/protected", headers = mapOf("Session-ID" to sessionId)) { response ->
response.status shouldBe 401
}
}
}
}
Implementing ExposesConfiguration¶
If your system needs to provide configuration to the application:
class MyCustomSystem(
override val testSystem: TestSystem,
private val options: MySystemOptions
) : PluggedSystem, RunAware, ExposesConfiguration {
private lateinit var config: MyExposedConfig
override suspend fun run() {
// Initialize and prepare configuration
config = MyExposedConfig(
host = "localhost",
port = findAvailablePort()
)
}
override fun configuration(): List<String> {
// Return configuration properties for the application
return options.configureExposedConfiguration(config)
}
override fun close() {}
}
// Configuration will be collected and passed to the application
TestSystem()
.with {
myCustomSystem {
MySystemOptions(
configureExposedConfiguration = { cfg ->
listOf(
"my.service.host=${cfg.host}",
"my.service.port=${cfg.port}"
)
}
)
}
}
.run()
Extending Existing Systems¶
Beyond creating entirely new systems, you can also extend existing Stove systems with custom DSL extensions. This is useful when you want to add domain-specific functionality to built-in systems like HttpSystem, KafkaSystem, etc.
Example: Adding GraphQL Support to HttpSystem¶
Here's a complete example of extending HttpSystem with GraphQL query capabilities:
Step 1: Define Response Types¶
import com.fasterxml.jackson.databind.JsonNode
/**
* Represents a GraphQL error from the response.
*/
data class GraphQLError(
val message: String,
val locations: List<Map<String, Int>>? = null,
val path: List<Any?>? = null,
val extensions: Map<String, Any?>? = null
)
/**
* The standard GraphQL response envelope.
*/
data class GraphQLEnvelope(
val data: JsonNode?,
val errors: List<GraphQLError> = emptyList()
)
Step 2: Create Extension Functions¶
import com.trendyol.stove.testing.e2e.http.HttpSystem
import com.trendyol.stove.testing.e2e.system.annotations.StoveDsl
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
/**
* Executes a GraphQL query/mutation and deserializes the response.
*
* @param operationName The name of the GraphQL operation (must match the query/mutation name)
* @param body The complete GraphQL request body as JSON string
* @param token Optional authorization token
* @param assert Assertion function to validate the response
*/
@StoveDsl
suspend inline fun <reified T> HttpSystem.graphql(
operationName: String,
body: String,
token: String? = null,
crossinline assert: (T) -> Unit
) {
val value: T = executeGraphQL(operationName, body, token)
assert(value)
}
/**
* Executes a GraphQL query/mutation expecting it to fail with an error.
*
* @param body The complete GraphQL request body as JSON string
* @param token Optional authorization token
* @param assert Assertion function to validate the error
*/
@StoveDsl
suspend fun HttpSystem.graphqlExpectingError(
body: String,
token: String? = null,
assert: (GraphQLError) -> Unit
) {
client { urlBuilder ->
val url = urlBuilder.apply { appendPathSegments("graphql") }.build().toString()
post(url) {
contentType(ContentType.Application.Json)
token?.let { headers { append("Authorization", "Bearer $it") } }
setBody(body)
}.let { response ->
if (response.status != HttpStatusCode.OK) {
throw AssertionError(
"GraphQL operation failed with HTTP error: ${response.status}\n" +
"Body:\n${response.bodyAsText()}"
)
}
val envelope = objectMapper.readValue(response.bodyAsText(), GraphQLEnvelope::class.java)
if (envelope.errors.isEmpty()) {
throw AssertionError(
"Expected GraphQL errors but got none. Data:\n${envelope.data?.toPrettyString() ?: "<null>"}"
)
}
assert(envelope.errors.first())
}
}
}
/**
* Executes a GraphQL query/mutation with dynamic JSON navigation.
*/
@StoveDsl
suspend fun HttpSystem.graphqlDynamic(
operationName: String,
body: String,
token: String? = null,
assert: (GraphQLNode) -> Unit
) {
val node = executeGraphQLInternal(operationName, body, token) { fieldNode ->
GraphQLNode(fieldNode)
}
assert(node)
}
private suspend inline fun <reified T> HttpSystem.executeGraphQL(
operationName: String,
body: String,
token: String?
): T = executeGraphQLInternal(operationName, body, token) { fieldNode ->
objectMapper.readValue(fieldNode.toString(), T::class.java)
}
private suspend fun <R> HttpSystem.executeGraphQLInternal(
operationName: String,
body: String,
token: String?,
decode: (JsonNode) -> R
): R {
var result: R? = null
client { urlBuilder ->
val url = urlBuilder.apply { appendPathSegments("graphql") }.build().toString()
post(url) {
contentType(ContentType.Application.Json)
token?.let { headers { append("Authorization", "Bearer $it") } }
setBody(body)
}.let { response ->
if (response.status != HttpStatusCode.OK) {
throw AssertionError(
"GraphQL operation($operationName) failed: ${response.status}\n" +
"Body:\n${response.bodyAsText()}"
)
}
val envelope = objectMapper.readValue(response.bodyAsText(), GraphQLEnvelope::class.java)
if (envelope.errors.isNotEmpty()) {
val errorText = envelope.errors.joinToString("\n") { e ->
"• ${e.message}" +
(e.path?.let { " | path: $it" } ?: "") +
(e.extensions?.let { " | ext: $it" } ?: "")
}
throw AssertionError(
"GraphQL operation($operationName) returned errors:\n$errorText\n\n" +
"Data:\n${envelope.data?.toPrettyString() ?: "<null>"}"
)
}
val dataNode = envelope.data
?: throw AssertionError(
"GraphQL operation($operationName) returned no 'data'. " +
"Response:\n${response.bodyAsText()}"
)
val fieldNode = dataNode.get(operationName)
?: throw AssertionError(
"GraphQL response has no field '$operationName'. " +
"Available: ${dataNode.fieldNames().asSequence().toList()}"
)
result = decode(fieldNode)
}
}
return result ?: throw AssertionError("GraphQL operation mapping failed")
}
Step 3: Create Dynamic Navigation Helper (Optional)¶
For flexible JSON navigation without strict typing:
import com.fasterxml.jackson.databind.JsonNode
import kotlin.reflect.KProperty1
/**
* A wrapper for dynamic GraphQL response navigation.
*/
@JvmInline
value class GraphQLNode(val node: JsonNode?) {
fun exists() = node != null && !node.isNull
operator fun get(key: String): GraphQLNode {
val obj = node ?: error("Expected object but was <null>")
if (!obj.isObject) error("Expected object to access key '$key' but was ${obj.nodeType}")
return GraphQLNode(obj.get(key))
}
operator fun get(index: Int): GraphQLNode {
val arr = node ?: error("Expected array but was <null>")
if (!arr.isArray) error("Expected array to access [$index] but was ${arr.nodeType}")
return GraphQLNode(arr.get(index))
}
/**
* Navigate using dot notation: "user.address.city" or "items[0].name"
*/
fun at(path: String): GraphQLNode {
var current = this
val tokens = parsePath(path)
for (token in tokens) {
current = when (token) {
is PathToken.Key -> current[token.name]
is PathToken.Index -> current[token.index]
}
}
return current
}
fun asNodes(): List<GraphQLNode> {
val arr = node ?: error("Expected array but was <null>")
if (!arr.isArray) error("Expected array but was ${arr.nodeType}")
return arr.map { GraphQLNode(it) }
}
inline fun <reified T> asType(): T {
val n = node ?: error("Value is <null>, expected ${T::class.simpleName}")
return when (T::class) {
String::class -> n.asText() as T
Int::class -> n.asInt() as T
Long::class -> n.asLong() as T
Boolean::class -> n.asBoolean() as T
Double::class -> n.asDouble() as T
else -> objectMapper.convertValue(n, T::class.java)
}
}
fun string() = asType<String>()
fun int() = asType<Int>()
fun long() = asType<Long>()
fun bool() = asType<Boolean>()
fun double() = asType<Double>()
fun raw(): JsonNode? = node
private sealed interface PathToken {
data class Key(val name: String) : PathToken
data class Index(val index: Int) : PathToken
}
private fun parsePath(path: String): List<PathToken> {
if (path.isBlank()) return emptyList()
val tokens = mutableListOf<PathToken>()
var i = 0
val buffer = StringBuilder()
fun flushKey() {
if (buffer.isNotEmpty()) {
tokens += PathToken.Key(buffer.toString())
buffer.setLength(0)
}
}
while (i < path.length) {
when (val c = path[i]) {
'.' -> { flushKey(); i++ }
'[' -> {
flushKey()
val end = path.indexOf(']', i + 1)
val idx = path.substring(i + 1, end).toInt()
tokens += PathToken.Index(idx)
i = end + 1
}
else -> { buffer.append(c); i++ }
}
}
flushKey()
return tokens
}
}
// Property-based access extensions
operator fun <T> GraphQLNode.get(prop: KProperty1<*, T>): GraphQLNode = this[prop.name]
inline fun <reified T> GraphQLNode.valueOf(prop: KProperty1<*, T>): T = this[prop].asType()
inline fun <reified T> List<GraphQLNode>.findBy(prop: KProperty1<*, T>, value: T): GraphQLNode? =
firstOrNull { it.valueOf(prop) == value }
inline fun <reified T> List<GraphQLNode>.requireBy(prop: KProperty1<*, T>, value: T): GraphQLNode =
findBy(prop, value) ?: error("No element where ${prop.name} == $value")
Step 4: Use in Tests¶
data class Product(
val id: String,
val name: String,
val price: Double,
val category: String
)
data class User(
val id: String,
val name: String,
val email: String
)
test("should query products by category") {
TestSystem.validate {
http {
val query = """
{
"query": "query { productsByCategory(category: \"ELECTRONICS\") { id name price category } }"
}
""".trimIndent()
graphql<List<Product>>("productsByCategory", query) { products ->
products.shouldNotBeEmpty()
products.forEach { product ->
product.category shouldBe "ELECTRONICS"
}
}
}
}
}
test("should query current user") {
TestSystem.validate {
http {
val query = """
{
"query": "query { me { id name email } }"
}
""".trimIndent()
graphql<User>("me", query, token = "user-jwt-token") { user ->
user.id shouldNotBe null
user.email shouldContain "@"
}
}
}
}
test("should handle GraphQL errors") {
TestSystem.validate {
http {
val query = """
{
"query": "query { invalidField }"
}
""".trimIndent()
graphqlExpectingError(query) { error ->
error.message shouldContain "Cannot query field"
}
}
}
}
test("should navigate dynamic response") {
TestSystem.validate {
http {
val query = """
{
"query": "query { searchResults { items { id title metadata { tags } } totalCount } }"
}
""".trimIndent()
graphqlDynamic("searchResults", query) { result ->
// Navigate using dot notation
result["totalCount"].int() shouldBeGreaterThan 0
// Navigate arrays
val items = result["items"].asNodes()
items.shouldNotBeEmpty()
// Access nested fields
val firstItem = result.at("items[0]")
firstItem["title"].string() shouldNotBe null
// Property-based navigation
val found = items.findBy(Product::id, "expected-id")
found shouldNotBe null
}
}
}
}
Benefits of Extending Existing Systems¶
- Reuse existing infrastructure - No need to create a full system class
- Domain-specific DSL - Create readable, expressive test code
- Type safety - Leverage Kotlin's type system for assertions
- Composability - Combine with other Stove systems seamlessly
Other Extension Ideas¶
You can apply this pattern to extend any Stove system:
// Kafka: Custom message publishing with headers
@StoveDsl
suspend fun KafkaSystem.publishWithCorrelationId(
topic: String,
message: Any,
correlationId: String = UUID.randomUUID().toString()
) {
publish(
topic = topic,
message = message,
headers = mapOf("X-Correlation-ID" to correlationId)
)
}
// Couchbase: Query with retry logic
@StoveDsl
suspend inline fun <reified T> CouchbaseSystem.shouldQueryWithRetry(
query: String,
maxRetries: Int = 3,
crossinline assertion: (List<T>) -> Unit
) {
var lastException: Exception? = null
repeat(maxRetries) {
try {
shouldQuery<T>(query, assertion)
return
} catch (e: Exception) {
lastException = e
delay(500)
}
}
throw lastException ?: AssertionError("Query failed after $maxRetries retries")
}
// PostgreSQL: Insert test data helper
@StoveDsl
suspend fun PostgresqlSystem.insertTestUser(
id: String = UUID.randomUUID().toString(),
name: String = "Test User",
email: String = "test-${UUID.randomUUID()}@example.com"
): String {
shouldExecute(
"""
INSERT INTO users (id, name, email)
VALUES ('$id', '$name', '$email')
""".trimIndent()
)
return id
}
Best Practices for Custom Systems¶
1. Use Concurrent Data Structures¶
When capturing data from multiple threads:
private val events = ConcurrentLinkedQueue<Event>()
private val executionMap = ConcurrentHashMap<String, Execution>()
2. Handle Timeouts Gracefully¶
Provide meaningful error messages:
suspend fun <T> waitFor(
duration: Duration,
description: String,
condition: () -> T?
): T = withTimeout(duration) {
while (true) {
condition()?.let { return@withTimeout it }
delay(50)
}
throw AssertionError("Timeout waiting for: $description")
}
3. Make Systems Chainable¶
Return this for fluent API:
fun doSomething(): MySystem {
// operation
return this
}
// Allows chaining
mySystem {
doSomething()
.doSomethingElse()
.verify { ... }
}
4. Annotate DSL Functions¶
Use @StoveDsl for IDE support:
@StoveDsl
suspend fun ValidationDsl.mySystem(
validation: suspend MySystem.() -> Unit
): Unit = validation(this.testSystem.mySystem())
5. Document Your System¶
Provide clear KDoc comments:
/**
* System for testing scheduled task execution.
*
* Example usage:
* ```kotlin
* tasks {
* shouldBeExecuted<MyTask>(atLeastIn = 10.seconds) {
* status == "COMPLETED"
* }
* }
* ```
*/
class DbSchedulerSystem(...)
Summary¶
Stove offers two powerful ways to extend its functionality:
Creating New Systems¶
For integrating with components not covered by built-in systems:
- Create a listener/component that captures the behavior you want to test
- Create a System class implementing
PluggedSystemand appropriate lifecycle interfaces - Create DSL extensions for
WithDslandValidationDsl - Register beans in your test initializer
- Use in tests with the fluent DSL
Extending Existing Systems¶
For adding domain-specific functionality to built-in systems:
- Create extension functions on existing system classes (e.g.,
HttpSystem,KafkaSystem) - Use
@StoveDslannotation for IDE support - Leverage existing infrastructure without creating new system classes
- Compose with other systems seamlessly
Both approaches make Stove adaptable to virtually any testing scenario in your application, whether you need to integrate with external components like db-scheduler, capture domain events, add GraphQL support, or create custom assertion helpers.