Skip to content

Writing Custom Systems

Built-in systems cover databases, Kafka, HTTP, gRPC, and more. Your app may still have its own observable surfaces: job schedulers, domain events, a custom protocol, or time control. Wrap those surfaces in a Stove system when you need reusable setup, assertions, or failure snapshots.

In 30 seconds Three pieces. System class (implements PluggedSystem plus the lifecycle interfaces it needs), DSL extensions (one for registration, one for validation), and optional bean registration if the system needs a hook inside the AUT.

Goal pattern. tasks { } is a custom system you wrote:

test("welcome email after signup") {
  stove {
    http {
      post<UserResponse>("/users", createUserRequest) {
        it.status shouldBe 201
      }
    }

    tasks {
      shouldBeExecuted<SendEmailPayload>(atLeastIn = 10.seconds) {
        recipientEmail == "new-user@example.com"
      }
    }
  }
}

The three pieces

1. System class

Implement PluggedSystem and pick lifecycle interfaces based on when your code must run:

Interface When called
RunAware Before AUT starts (spin up infra)
AfterRunAware After AUT starts
AfterRunAwareWithContext<T> After AUT starts, with DI container (ApplicationContext, etc.)
ExposesConfiguration During setup, after system startup, before a Stove-started AUT runner receives parameters

Example: a db-scheduler-backed task system that reads from the Spring context.

class DbSchedulerSystem(
  override val stove: Stove
) : PluggedSystem, AfterRunAwareWithContext<ApplicationContext> {

  private lateinit var listener: StoveDbSchedulerListener

  override suspend fun afterRun(context: ApplicationContext) {
    listener = context.getBean(StoveDbSchedulerListener::class.java)
  }

  suspend inline fun <reified T : Any> shouldBeExecuted(
    atLeastIn: Duration = 5.seconds,
    noinline condition: T.() -> Boolean
  ): DbSchedulerSystem {
    listener.waitUntilObserved(atLeastIn, T::class, condition)
    return this
  }

  override fun close() {}
}

2. DSL extensions

Two extension functions wire it into Stove's DSL. One registers, one validates.

@StoveDsl
fun WithDsl.dbScheduler(): Stove =
  this.stove.getOrRegister(DbSchedulerSystem(this.stove)).let { this.stove }

@StoveDsl
suspend fun ValidationDsl.tasks(
  validation: suspend DbSchedulerSystem.() -> Unit
): Unit = validation(
  this.stove.getOrNone<DbSchedulerSystem>().getOrElse {
    throw SystemNotRegisteredException(DbSchedulerSystem::class)
  }
)

Usage:

Stove().with {
  dbScheduler()  // registration
  // ...
}.run()

stove {
  tasks {       // validation
    shouldBeExecuted<SendEmailPayload>(10.seconds) { /* ... */ }
  }
}

3. Bean registration (optional)

If your system needs a hook inside the app (a listener, an interceptor, a captor), register it as a test bean:

springBoot(
  runner = { params ->
    runApplication<MyApp>(*params) {
      addTestDependencies {
        bean<StoveDbSchedulerListener>(isPrimary = true)
      }
    }
  }
)

That is the core pattern. Everything else is domain-specific observation and assertion logic.

Ideas

Scheduled tasks

Listen for job executor completion. Assert payloads with timeout.

Domain events

Spring @EventListener into a ConcurrentLinkedQueue. Poll with timeout.

Time control

Inject a StoveTestClock bean. Expose advance() / setTime() in DSL.

Custom protocol

Use RunAware + ExposesConfiguration to spin up infra and feed connection details to the AUT.

Scheduled tasks (test view)

stove {
  http {
    postAndExpectBodilessResponse("/orders", body = orderRequest.some()) {
      it.status shouldBe 200
    }
  }

  tasks {
    shouldBeExecuted<SendOrderConfirmationPayload>(atLeastIn = 10.seconds) {
      orderId == expectedOrderId && recipientEmail == "customer@example.com"
    }
  }
}

Full reference: spring-showcase recipe.

Domain event capture (test view)

stove {
  http {
    post<UserResponse>("/users", createUserRequest) {
      it.status shouldBe 201
    }
  }

  domainEvents {
    shouldBePublished<UserCreatedEvent> {
      userId == expectedId && name == "John"
    }
    shouldNotBePublished<UserDeletedEvent> {
      userId == expectedId
    }
  }
}

Implementation: an @EventListener bean queues events; the system polls with timeout.

Time control (test view)

stove {
  http {
    post<SessionResponse>("/login", credentials) {
      sessionId = it.sessionId
    }
  }

  time {
    advance(31.minutes)
  }

  http {
    getResponseBodiless("/protected", headers = mapOf("Session-ID" to sessionId)) {
      it.status shouldBe 401  // session expired
    }
  }
}

Implementation: a StoveTestClock (extends java.time.Clock) injected as a Spring bean. advance() / setTime() mutate it.

Exposing configuration

If your system starts infra and needs to hand connection details to the AUT:

class MySystem(
  override val stove: Stove,
  private val options: MySystemOptions
) : PluggedSystem, RunAware, ExposesConfiguration {

  private lateinit var config: MyExposedConfig

  override suspend fun run() {
    config = MyExposedConfig(host = "localhost", port = startContainer())
  }

  override fun configuration(): List<String> =
    options.configureExposedConfiguration(config)

  override fun close() {}
}

For AUTs started by Stove runners, Stove collects every registered system's configuration() after systems start and before the runner is called. The merged key=value list is passed to the AUT runner together with static withParameters. providedApplication() is different: Stove only checks readiness and runs assertions; the already-running app must be configured externally.

Extending built-in systems

Sometimes a full system is overkill. An extension function on an existing system is enough.

@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)
  )
}

// Usage
kafka {
  publishWithCorrelationId("orders.created", orderEvent)
}

Works for any built-in system (HttpSystem, KafkaSystem, PostgresqlSystem, ...). Use @StoveDsl for IDE autocomplete + DSL marker safety.

  • Bridge. If all you need is DI access, you may not need a custom system at all
  • Multiple Systems. Register multiple keyed instances of your custom system
  • Best Practices. Patterns that apply to custom systems too