Cucumber feature tests with suspend steps¶
Use Gherkin feature files to describe cases and Kotlin suspend lambdas to execute Stove's existing validation DSL. The Cucumber adapter carries the active Stove instance, scenario identity, and trace through suspensions and child coroutines.
Incubating in 0.28.0
The adapter is incubating in the 0.28.0 source tree. Start with the local example and check module availability before using a published Stove version. The example is verified with Cucumber 8.0.4 and JUnit Platform 6.1.3.
Run the example¶
The inventory example
starts a real Ktor HTTP application with in-memory stock. It needs no Docker or
external application services. Observability is enabled by default; start stove
to view the dashboard. From a Stove checkout:
Four Scenario Outline rows exercise successful reservations, insufficient stock, reserving all stock, and invalid quantities. They run in parallel with up to four workers; the intentional failure demo is excluded by default. Feature files define the cases, and Kotlin steps provide reusable operations.
Dependencies and discovery¶
The example uses local Stove projects and the repository's version catalog:
plugins {
id("com.trendyol.stove.tracing")
}
stoveTracing {
serviceName.set("cucumber-example")
otelAgentVersion.set(libs.versions.opentelemetry.instrumentation.get())
profiling {
enabled.set(true)
agentVersion.set(libs.versions.pyroscope.get())
allocations.set(true)
liveAllocations.set(true)
lockThreshold.set("10ms")
}
}
dependencies {
implementation(libs.ktor.server)
implementation(libs.ktor.server.cio)
testImplementation(projects.testExtensions.stoveExtensionsCucumber)
testImplementation(projects.starters.ktor.stoveKtor)
testImplementation(projects.lib.stoveHttp)
testImplementation(projects.lib.stoveDashboard)
testImplementation(projects.lib.stoveTracing)
testImplementation(libs.cucumber.engine)
testImplementation(libs.cucumber.picocontainer)
testImplementation(libs.junit.platform.suite)
testImplementation(libs.logback.classic)
testRuntimeOnly(libs.jackson.jdk8)
}
// Only the suite engine discovers the suite; its selector delegates features to Cucumber.
// Keeping the Cucumber engine out of top-level discovery prevents duplicate execution.
tasks.test {
useJUnitPlatform { includeEngines("junit-platform-suite") }
// Exercise the hint workaround even on fast machines if it is accidentally removed.
systemProperty("cucumber.glue.hint.threshold", "PT0S")
systemProperty("cucumber.filter.tags", providers.gradleProperty("cucumber.tags").getOrElse("not @failure-demo"))
}
When adapting this to another project, use a Stove BOM version that includes
stove-extensions-cucumber, along with the runner and systems your application
needs. The adapter supplies Cucumber’s Java8 lambda backend, Stove’s annotated-step backend,
and Kotlin reflection. The
additional test dependencies serve these roles:
| Dependency | Purpose |
|---|---|
io.cucumber:cucumber-junit-platform-engine |
Feature discovery and execution |
org.junit.platform:junit-platform-suite |
Launch the Cucumber suite from Gradle or an IDE |
io.cucumber:cucumber-picocontainer |
Scenario-scoped constructor injection in this example |
io.kotest:kotest-assertions-core |
Assertions such as shouldBe; supplied by this repository's test conventions |
com.fasterxml.jackson.datatype:jackson-datatype-jdk8 |
Supports the example's Jackson 2 runtime for Cucumber HTML/JSON reports |
Keep Cucumber artifacts aligned, and use a supported JSON provider for Cucumber 8. Reuse an existing Cucumber object factory if your project already has one.
The suite selects feature resources in src/test/resources/features:
package stove.cucumber.example
import org.junit.platform.suite.api.IncludeEngines
import org.junit.platform.suite.api.SelectPackages
import org.junit.platform.suite.api.Suite
@Suite
@IncludeEngines("cucumber")
@SelectPackages("features")
class CucumberSuite
The Gradle task selects only junit-platform-suite at the top level; the suite
selects the Cucumber engine. This avoids discovering the same features twice.
If this task also runs Kotest or Jupiter, include their engines alongside
junit-platform-suite, keeping cucumber out of top-level discovery. Separate
tasks are also supported. For a custom source set, configure the actual Test
task's classes, resources, and runtime classpath.
Add junit-platform.properties to the test resources:
cucumber.glue=stove.cucumber.example,com.trendyol.stove.extensions.cucumber
# Cucumber 8.0.4's optional hints inspect incompatible Kotlin inline-coroutine metadata.
cucumber.glue.hint.enabled=false
cucumber.execution.parallel.enabled=true
cucumber.execution.parallel.config.strategy=fixed
cucumber.execution.parallel.config.fixed.parallelism=4
cucumber.execution.parallel.config.fixed.max-pool-size=4
cucumber.filter.tags=not @failure-demo
cucumber.plugin=pretty,html:build/reports/cucumber.html,json:build/reports/cucumber.json,com.trendyol.stove.extensions.cucumber.StoveCucumberPlugin
cucumber.glue includes both your steps and Stove's lifecycle hooks. The plugin
manages scenario reporting; the backend is discovered automatically through Java's
service loader. Update the glue package for your application. The cucumber.tags
Gradle property in the example overrides cucumber.filter.tags; the test resources
provide the default when launching the suite directly in an IDE.
Keep cucumber.glue.hint.enabled=false with Cucumber 8.0.4. Its optional
glue-loading performance advisor can throw IncompatibleClassChangeError when
inspecting Kotlin inline-coroutine classes. The advisor runs only after a timing
threshold, so this may appear only on slower CI workers. Disabling hints leaves
step discovery, execution, and reporting unchanged.
Start Stove once per run¶
Use native Cucumber run-level hooks to start and stop Stove. These hooks remain
ordinary functions; startup uses runBlocking because Stove.run() suspends.
package stove.cucumber.example
import com.trendyol.stove.dashboard.DashboardSystemOptions
import com.trendyol.stove.dashboard.dashboard
import com.trendyol.stove.http.HttpClientSystemOptions
import com.trendyol.stove.http.httpClient
import com.trendyol.stove.ktor.ktor
import com.trendyol.stove.system.PortFinder
import com.trendyol.stove.system.Stove
import com.trendyol.stove.system.stove
import com.trendyol.stove.tracing.tracing
import io.cucumber.java.After
import io.cucumber.java.AfterAll
import io.cucumber.java.BeforeAll
import io.cucumber.java.Scenario
import kotlinx.coroutines.runBlocking
object InventoryEnvironment {
@JvmStatic
@BeforeAll
fun start(): Unit = runBlocking {
val port = PortFinder.findAvailablePort()
Stove().with {
tracing { enableSpanReceiver() }
dashboard { DashboardSystemOptions(appName = "cucumber-example") }
httpClient { HttpClientSystemOptions(baseUrl = "http://127.0.0.1:$port") }
ktor(runner = { startInventoryApplication(port) })
}.run()
}
@JvmStatic
@AfterAll
fun stop() = Stove.stop()
}
/** Wait for asynchronous export before Stove closes the scenario and its diagnostics. */
class InventoryObservability {
@After
fun verifyTraces(scenario: Scenario): Unit = runBlocking {
stove {
tracing {
// Three requests, each with one client span and one Ktor server span.
waitForSpans(expectedCount = 6, timeoutMs = 5000)
if (!scenario.isFailed) {
spanCountShouldBeAtLeast(6)
shouldContainSpan("PUT /inventory/{sku}")
shouldContainSpan("POST /inventory/{sku}/reserve")
shouldContainSpan("GET /inventory/{sku}")
}
}
}
}
}
startInventoryApplication is the example's
Ktor application.
It starts with wait = false. For another application, use its existing Stove
runner and systems. Keep startup out of individual steps.
Feature files and suspend steps¶
Feature: Reserve inventory
Customers can reserve available stock without overselling a product.
@reservation
Scenario Outline: Reserve stock
Given a product with <available> units in stock
When I reserve <requested> units
Then the reservation response is <status>
And <remaining> units remain in stock
Examples:
| available | requested | status | remaining |
| 10 | 3 | 204 | 7 |
| 2 | 3 | 409 | 2 |
| 5 | 5 | 204 | 0 |
| 5 | 0 | 400 | 5 |
@failure-demo
Scenario: Show Stove diagnostics for an incorrect stock expectation
Given a product with 10 units in stock
When I reserve 3 units
Then 999 units remain in stock
Extend StoveSteps({ ... }) and register steps in its constructor lambda. Each step
body is a suspend lambda with a ValidationDsl receiver, so systems such as http {} are directly available:
package stove.cucumber.example
import arrow.core.some
import com.trendyol.stove.extensions.cucumber.StoveSteps
import com.trendyol.stove.http.http
import io.kotest.matchers.shouldBe
import java.util.UUID
/** PicoContainer supplies a new instance for each scenario, including each outline row. */
class InventoryScenario {
val sku: String = UUID.randomUUID().toString()
var responseStatus: Int? = null
}
class InventorySteps(scenario: InventoryScenario) : StoveSteps({
Given<Int>("a product with {int} units in stock") { quantity ->
http {
putAndExpectBodilessResponse("/inventory/${scenario.sku}", body = quantity.toString().some()) {
it.status shouldBe 204
}
}
}
When<Int>("I reserve {int} units") { quantity ->
http {
postAndExpectBodilessResponse("/inventory/${scenario.sku}/reserve", body = quantity.toString().some()) {
scenario.responseStatus = it.status
}
}
}
Then<Int>("the reservation response is {int}") { status ->
scenario.responseStatus shouldBe status
}
And<Int>("{int} units remain in stock") { quantity ->
http {
get<String>("/inventory/${scenario.sku}") {
it shouldBe quantity.toString()
}
}
}
})
Given, When, Then, And, and But share the same registration API and
accept zero to nine typed arguments. Write Given<Int>("...") { quantity -> ... }
or let Kotlin infer the type from Given("...") { quantity: Int -> ... }.
As with native Cucumber steps, keyword spelling does not constrain matching.
Cucumber's Java8 backend discovers StoveSteps subclasses and creates their glue
per scenario using the configured object factory. Step registration uses its native
expression matching and argument conversion; Stove adds the suspend execution
boundary and validation receiver. Keep application operations inside step bodies,
not directly in the registration lambda. The StoveSteps() { init { ... } } form
is also supported. Constructor lambdas can capture injected constructor parameters
and declare local scenario state; they run during base-class initialization, before
subclass properties are initialized.
PicoContainer creates a fresh InventoryScenario and InventorySteps for each
scenario, including each outline row. The scenario owns its unique SKU and response
state; parallel scenarios share the application without sharing those values.
Stove system cleanup runs at suite shutdown, not between scenarios. Per-scenario
cleanup should target only the data that scenario owns.
Reuse suspend helpers¶
A helper can use the same DSL without taking a Stove instance or repeating setup:
import com.trendyol.stove.http.http
import com.trendyol.stove.system.ValidationDsl
import io.kotest.matchers.shouldBe
suspend fun ValidationDsl.assertStock(sku: String, expected: Int) {
http {
get<String>("/inventory/$sku") { it shouldBe expected.toString() }
}
}
Call assertStock(sku, expected) directly inside a lambda step, or from stove {}
in a Kotest or Jupiter test. The session's StoveContext, reporting context, and
trace propagate through suspensions and structured child coroutines, including
withContext(Dispatchers.Default). The backend awaits the step and its structured
children before returning to Cucumber. Exceptions and cancellation remain scenario
failures. Detached coroutine scopes do not inherit this lifecycle automatically.
Tables, docstrings, and custom parameters¶
Native @ParameterType converters work with typed lambda arguments. For tables,
accept io.cucumber.datatable.DataTable and convert explicitly:
Given<DataTable>("the following products exist") { table ->
val products = table.asMaps(String::class.java, String::class.java)
// Seed products through the relevant Stove system.
}
Use String for a plain docstring, or io.cucumber.docstring.DocString for explicit
conversion. Cucumber's lambda backend resolves raw argument classes, so generic
element types in List/Map arguments are lost. Use DataTable.asList, asLists,
asMap, asMaps, or convert for explicit conversion. For transposed data, call table.transpose() first.
The same scenario: lambdas and annotations¶
Both styles below implement this feature against the inventory application. Choose one implementation for your glue package; registering both makes the steps ambiguous.
Feature: Inventory
Scenario: Read the stock we added
Given product apple starts with 10 units
Then product apple has 10 units
Both implementations use these imports:
Lambda style¶
import com.trendyol.stove.extensions.cucumber.StoveSteps
class InventorySteps : StoveSteps({
Given<String, Int>(
"product {word} starts with {int} units"
) { sku, quantity ->
http {
putAndExpectBodilessResponse(
"/inventory/$sku",
body = quantity.toString().some()
) { it.status shouldBe 204 }
}
}
Then<String, Int>(
"product {word} has {int} units"
) { sku, quantity ->
http {
get<String>("/inventory/$sku") {
it shouldBe quantity.toString()
}
}
}
})
Each step body is already a suspend ValidationDsl block. Call http {} or
other configured system DSLs directly.
Annotation style¶
import com.trendyol.stove.extensions.cucumber.Given
import com.trendyol.stove.extensions.cucumber.Then
import com.trendyol.stove.system.stove
class InventorySteps {
@Given("product {word} starts with {int} units")
suspend fun seed(sku: String, quantity: Int) =
stove {
http {
putAndExpectBodilessResponse(
"/inventory/$sku",
body = quantity.toString().some()
) { it.status shouldBe 204 }
}
}
@Then("product {word} has {int} units")
suspend fun stock(sku: String, quantity: Int) =
stove {
http {
get<String>("/inventory/$sku") {
it shouldBe quantity.toString()
}
}
}
}
Each method is a suspend function. stove {} supplies its ValidationDsl receiver.
Use Stove's step annotation imports as shown above.
How the Stove DSL is embedded¶
The outer StoveSteps({ ... }) lambda registers steps synchronously, once per
scenario. The lambda passed to Given, When, or Then has type
suspend ValidationDsl.(...) -> Unit. When Cucumber executes it, Stove enters
the scenario's coroutine context and invokes the body inside the standard
stove {} DSL. That is why http {}, other system DSLs, and suspend validation
helpers are available directly in a lambda step.
Annotated methods enter the same scenario context, then call stove {} explicitly.
Both styles reuse the same suspend executor and reporting lifecycle; changing styles
does not add another session, tracing layer, or report. Suspension and structured
child coroutines complete before Cucumber moves to the next step.
Annotated suspend steps¶
The annotated API remains supported. Use it when you prefer named methods or want
native generic table conversion and @Transpose on parameters.
Stove's Given, When, Then, And, and But annotations alias Step. Use public
suspend class members; top-level, static, inherited, and extension-receiver annotated
step definitions are unsupported. Shared suspend helpers can be top-level functions.
Native io.cucumber.java.en.* annotations treat Kotlin's synthetic Continuation
as another argument on suspend functions. Use Stove's annotations for those methods.
JUnit Platform launches Cucumber; Jupiter's coroutine support does not handle its steps.
Both styles use the same scenario session and suspend executor. Native synchronous steps can coexist, and hooks and parameter/table/docstring converters retain their native Cucumber imports. Hooks remain synchronous; suspend support applies to step bodies. Do not register the same expression in both styles.
Parallel scenarios¶
The example enables cucumber.execution.parallel.enabled=true, with both fixed
parallelism and maximum pool size set to four. Cucumber can run scenarios from the same or different feature
files concurrently; each Scenario Outline row is a separate scenario. Steps within
one scenario remain ordered, and each suspend step finishes before the next starts.
Each scenario has its own reporting and trace session. Context follows structured child coroutines and dispatcher changes, and the session is cleared before its Cucumber worker is reused. The application and its systems are shared across the suite, so keep scenario data independent, as the example does with unique SKUs and scenario-scoped injection.
Stove accepts both parallel and sequential execution. Cucumber itself defaults to
sequential execution when the property is absent; Stove does not override engine
configuration. Set cucumber.execution.parallel.enabled=false when a suite needs
sequential scenarios.
Tags and failure reports¶
Select scenarios with the example's Gradle property:
To see diagnostics, run the deliberate failure:
This command is expected to fail: it asserts 999 remaining units when the actual
stock is 7. Open examples/cucumber-example/build/reports/cucumber.html, expand
the failed scenario's hooks, then Stove diagnostics. JSON is also written to
build/reports/cucumber.json inside the example. The Gradle property works because
the example forwards it to cucumber.filter.tags; configure that forwarding when
adapting the task.
Each executed scenario gets its own StoveTestSession, opened by Stove's
@Before(order = Int.MIN_VALUE) hook and closed after all scenario hooks. IDs use
the feature URI plus scenario/example line, keeping identical outline names distinct.
System activity uses the same reporting lifecycle as Stove's Kotest and Jupiter adapters.
The diagnostic hook attaches plain text without replacing the original exception.
It runs at Int.MIN_VALUE to follow ordinary @After hooks. If a hook fails after
the attachment, the plugin prints the remaining diagnostics to stderr before
cleanup. Undefined, ambiguous, and pending steps are also reported as failures.
Discovery errors and run-level hook failures remain Cucumber run failures.
Dashboard, tracing, and profiling¶
Start the Stove dashboard (stove), then run the example with:
Open http://localhost:4040 and select cucumber-example. Every run enables
dashboard ingestion, OpenTelemetry instrumentation, and Pyroscope profiling.
Each scenario has an HTTP timeline and six client/server spans in Trace.
Drill Down shows CPU, allocation, surviving-object, and contention samples;
select a server span's Drill Down · this span action for correlated samples.
Short operations may have no samples. Whole-run profiles include both the
application and test harness in the shared JVM.
The example's shared after-hook waits for asynchronous span export before scenario cleanup and verifies the server routes for successful scenarios. Expected HTTP 400/409 responses can mark client spans as errors while their scenarios pass. Use the Gradle task from the terminal or IDE so both agents are attached.
Troubleshooting and limits¶
| Symptom | Check |
|---|---|
Extra Continuation argument in a step error |
Use Stove's step annotation import for suspend functions |
| “No Stove Cucumber scenario” | Register StoveCucumberPlugin in cucumber.plugin |
| Stove is not initialized | Start it in a native @BeforeAll hook before scenarios |
| No diagnostic attachment | Include com.trendyol.stove.extensions.cucumber in glue; run-level failures have no scenario attachment |
| Features run twice | Keep cucumber out of top-level discovery when launching through the suite engine |
| Lambda stack trace points beyond the source file | Kotlin inlining can produce synthetic line numbers; use the reported feature URI and scenario line to identify the case |
| Lambda table values have unexpected types | Accept DataTable and convert explicitly; generic List/Map element types are erased |
| Annotated step is rejected during discovery | Use a public suspend class member; extract reuse into helpers |
| State leaks between scenarios | Use scenario-scoped state, unique data, and cleanup limited to that scenario's data |
| Validate glue without starting the application | cucumber.execution.dry-run=true skips step bodies and hooks, including BeforeAll / AfterAll; no Stove instance or reporting session is needed |
Lifecycle hooks use native synchronous Cucumber annotations. Scenarios share one
Stove application lifecycle; StoveContext binds validation to that active instance.
See Reporting for report configuration and Tracing for application trace instrumentation.