cucumber-kmp

Runs Gherkin .feature files as native tests, generating static step registries, avoiding runtime reflection and filesystem access, with full expressions, dialects, tag filtering and hooks.

Android
JVM
iOS
macOS
Wasm
JS
GitHub stars4
Authorsmenjoo
Dependents0
LicenseMIT License
Creation dateabout 1 month ago

Last activity1 day ago
Latest release0.2.0 (1 day ago)

cucumber-kmp

CI Maven Central Kotlin License: MIT

JVM Android iOS macOS JS Wasm

A Kotlin Multiplatform port of Cucumber. Gherkin .feature files run as real tests on the JVM, Android, iOS, macOS, JS and Wasm — with no runtime reflection, no classpath scanning and no runtime filesystem access.

The point is that non-developers keep writing plain-text .feature files in the repository, while the tests those files describe run natively on every target the app ships to.

Status: early but usable. Published to Maven Central and verified end to end on six targets. The API may still change between 0.x releases. On-device Android and iOS runs, and the Cucumber Compatibility Kit, are not done yet — see ARCHITECTURE.md for the roadmap.

Supported versions

What the artifacts require, as distinct from what this repository is built with. The build versions are in gradle/libs.versions.toml; these are the floors a consumer has to clear.

Supported Built and tested with
Kotlin 2.4.x 2.4.20
KSP 2.3.x 2.3.12
Gradle 9.x 9.6.1
AGP (Android consumers) 9.x 9.3.1
JDK (to run the build) 21 21

Two things worth knowing, because neither is guessable:

  • Kotlin is a floor, not a pin. klib metadata is forward-incompatible, so a consumer's compiler cannot be older than the language version the artifacts were built with. 2.4.x is therefore a real minimum, while the patch version is free — 2.4.0 consumes 2.4.20 artifacts.
  • KSP is versioned independently of Kotlin. Its POM pins no Kotlin dependency, so the historical Kotlin↔KSP lockstep no longer applies. A 2.3.12-built processor runs on a 2.3.9 host; there is no need to match the patch version, or to bump KSP across a repository to adopt this one.

The lowest combination actually exercised against these artifacts is Kotlin 2.4.0, KSP 2.3.9, Gradle 9.5.1 and AGP 9.2.1, in a consuming project — every claim above is measured rather than assumed.

Older Kotlin or Gradle lines are not tested and not supported. If you need one, open an issue saying which — it is likelier to be a CI matrix entry than a code change.

Installation

settings.gradle.kts:

pluginManagement {
    repositories { mavenCentral(); gradlePluginPortal() }
}

build.gradle.kts:

plugins {
    kotlin("multiplatform")
    id("com.google.devtools.ksp") version "2.3.12"
    id("io.github.menjoo.cucumberkmp") version "0.2.0"
}

kotlin {
    sourceSets {
        commonTest.dependencies {
            implementation("io.github.menjoo.cucumberkmp:cucumber-kmp-core:0.2.0")
            implementation("io.github.menjoo.cucumberkmp:cucumber-kmp-annotations:0.2.0")
        }
    }
}

That is the whole configuration. The plugin adds cucumber-kmp-ksp to each test compilation itself — KSP generates the step registry per target compilation because it cannot generate into commonTest, and the plugin already knows which compilations those are.

The generated tests and the generated step registry share one package, which the plugin hands to KSP itself, so cucumberKmp { } needs nothing unless you want to choose that package:

cucumberKmp {
    generatedPackage.set("com.example.cucumber")
}

Feature files go in src/commonTest/resources/features. See examples/calculator for a complete, working project.

Only cucumber-kmp-core is needed if you use the steps { } DSL rather than annotations — with no KSP in play, point cucumberKmp { stepRegistry } at whichever property holds your factory.

How it looks

Write the feature — this is the file a PO or QA owns, and no Kotlin appears in it:

Feature: Calculator
  Scenario Outline: a <percentage>% discount on <total>
    Given I have entered <total>
    And I press add
    When I apply a discount of <percentage> percent
    Then the result should be <expected>

    Examples:
      | total | percentage | expected |
      | 100   | 20         | 80       |
      | 250   | 10         | 225      |

Write the step definitions once, in commonTest:

@Steps
class CalculatorSteps {
    private val calculator = Calculator()      // fresh instance for every scenario

    @Given("I have entered {double}")
    fun iHaveEntered(value: Double) = calculator.enter(value)

    @When("I apply a discount of {int} percent")
    fun iApplyADiscount(percentage: Int) = calculator.applyDiscount(percentage)

    @Then("the result should be {double}")
    fun theResultShouldBe(expected: Double) = assertEquals(expected, calculator.result)
}

The build does the rest: KSP turns the annotations into a static step registry, and the Gradle plugin turns each scenario into a @Test. Adding a row to that Examples table adds a test — on every target — and nothing else changes.

Annotations are optional. The steps { } DSL underneath them needs no code generation at all:

val calculatorSteps = steps {
    var calculator = Calculator()              // fresh for every scenario
    given("I have entered {int}") { value: Int -> calculator.enter(value) }
    whenever("I press add") { calculator.add() }
}

Because steps { } returns a factory the runner calls once per scenario, a var declared inside the block is fresh each time — per-scenario isolation falls out of ordinary Kotlin closures, with no dependency-injection container and no reflection.

Also implemented: the full 80-language Gherkin dialect table, Cucumber Expressions with all of Cucumber's built-in parameter types, tag expressions, backgrounds, scenario outlines, data tables (with upstream's asList/asLists/asMap/asMaps conversions), doc strings, rules, hooks, tag filtering, and undefined/ambiguous step reporting with paste-able snippets.

Conformance

This is a port, so behavioural fidelity is a requirement rather than an aspiration. Upstream's own language-agnostic test data is embedded as generated Kotlin and run on every target:

Suite Cases
cucumber/gherkin parser — good and bad 50 + 12
cucumber/gherkin pickle compilation 50
cucumber/cucumber-expressions 120
cucumber/tag-expressions 64

Error messages, positions and expected: #Token, … lists are byte-identical to upstream, because those strings are what users compare against Cucumber's output. Every intentional difference is recorded in DEVIATIONS.md.

The fixtures are embedded as code rather than read from test resources because Kotlin/Native and Wasm cannot read files at runtime — the same constraint that shapes the whole framework.

Building

Requires JDK 21 and, for the Apple targets, Xcode.

./gradlew build                                  # all Tier A targets
./gradlew -Pcucumberkmp.browserTests build       # adds Chrome/Karma runs
./gradlew linkDebugTestIosArm64                  # device target: compile and link check

Regenerate the vendored upstream data (needs Python 3 and PyYAML):

python3 tools/update-gherkin-dialects.py --download
python3 tools/update-gherkin-corpus.py
python3 tools/update-expression-corpus.py
python3 tools/update-tag-expression-corpus.py

A scheduled workflow runs those weekly and reports if upstream has moved.

Licence

MIT, the same licence as Cucumber. See LICENSE, and NOTICE for what is derived from or embedded verbatim out of the upstream projects.

cucumber-kmp is an independent port. It is not published, endorsed or supported by Cucumber Ltd.

Android
JVM
iOS
macOS
Wasm
JS
GitHub stars4
Authorsmenjoo
Dependents0
LicenseMIT License
Creation dateabout 1 month ago

Last activity1 day ago
Latest release0.2.0 (1 day ago)

cucumber-kmp

CI Maven Central Kotlin License: MIT

JVM Android iOS macOS JS Wasm

A Kotlin Multiplatform port of Cucumber. Gherkin .feature files run as real tests on the JVM, Android, iOS, macOS, JS and Wasm — with no runtime reflection, no classpath scanning and no runtime filesystem access.

The point is that non-developers keep writing plain-text .feature files in the repository, while the tests those files describe run natively on every target the app ships to.

Status: early but usable. Published to Maven Central and verified end to end on six targets. The API may still change between 0.x releases. On-device Android and iOS runs, and the Cucumber Compatibility Kit, are not done yet — see ARCHITECTURE.md for the roadmap.

Supported versions

What the artifacts require, as distinct from what this repository is built with. The build versions are in gradle/libs.versions.toml; these are the floors a consumer has to clear.

Supported Built and tested with
Kotlin 2.4.x 2.4.20
KSP 2.3.x 2.3.12
Gradle 9.x 9.6.1
AGP (Android consumers) 9.x 9.3.1
JDK (to run the build) 21 21

Two things worth knowing, because neither is guessable:

  • Kotlin is a floor, not a pin. klib metadata is forward-incompatible, so a consumer's compiler cannot be older than the language version the artifacts were built with. 2.4.x is therefore a real minimum, while the patch version is free — 2.4.0 consumes 2.4.20 artifacts.
  • KSP is versioned independently of Kotlin. Its POM pins no Kotlin dependency, so the historical Kotlin↔KSP lockstep no longer applies. A 2.3.12-built processor runs on a 2.3.9 host; there is no need to match the patch version, or to bump KSP across a repository to adopt this one.

The lowest combination actually exercised against these artifacts is Kotlin 2.4.0, KSP 2.3.9, Gradle 9.5.1 and AGP 9.2.1, in a consuming project — every claim above is measured rather than assumed.

Older Kotlin or Gradle lines are not tested and not supported. If you need one, open an issue saying which — it is likelier to be a CI matrix entry than a code change.

Installation

settings.gradle.kts:

pluginManagement {
    repositories { mavenCentral(); gradlePluginPortal() }
}

build.gradle.kts:

plugins {
    kotlin("multiplatform")
    id("com.google.devtools.ksp") version "2.3.12"
    id("io.github.menjoo.cucumberkmp") version "0.2.0"
}

kotlin {
    sourceSets {
        commonTest.dependencies {
            implementation("io.github.menjoo.cucumberkmp:cucumber-kmp-core:0.2.0")
            implementation("io.github.menjoo.cucumberkmp:cucumber-kmp-annotations:0.2.0")
        }
    }
}

That is the whole configuration. The plugin adds cucumber-kmp-ksp to each test compilation itself — KSP generates the step registry per target compilation because it cannot generate into commonTest, and the plugin already knows which compilations those are.

The generated tests and the generated step registry share one package, which the plugin hands to KSP itself, so cucumberKmp { } needs nothing unless you want to choose that package:

cucumberKmp {
    generatedPackage.set("com.example.cucumber")
}

Feature files go in src/commonTest/resources/features. See examples/calculator for a complete, working project.

Only cucumber-kmp-core is needed if you use the steps { } DSL rather than annotations — with no KSP in play, point cucumberKmp { stepRegistry } at whichever property holds your factory.

How it looks

Write the feature — this is the file a PO or QA owns, and no Kotlin appears in it:

Feature: Calculator
  Scenario Outline: a <percentage>% discount on <total>
    Given I have entered <total>
    And I press add
    When I apply a discount of <percentage> percent
    Then the result should be <expected>

    Examples:
      | total | percentage | expected |
      | 100   | 20         | 80       |
      | 250   | 10         | 225      |

Write the step definitions once, in commonTest:

@Steps
class CalculatorSteps {
    private val calculator = Calculator()      // fresh instance for every scenario

    @Given("I have entered {double}")
    fun iHaveEntered(value: Double) = calculator.enter(value)

    @When("I apply a discount of {int} percent")
    fun iApplyADiscount(percentage: Int) = calculator.applyDiscount(percentage)

    @Then("the result should be {double}")
    fun theResultShouldBe(expected: Double) = assertEquals(expected, calculator.result)
}

The build does the rest: KSP turns the annotations into a static step registry, and the Gradle plugin turns each scenario into a @Test. Adding a row to that Examples table adds a test — on every target — and nothing else changes.

Annotations are optional. The steps { } DSL underneath them needs no code generation at all:

val calculatorSteps = steps {
    var calculator = Calculator()              // fresh for every scenario
    given("I have entered {int}") { value: Int -> calculator.enter(value) }
    whenever("I press add") { calculator.add() }
}

Because steps { } returns a factory the runner calls once per scenario, a var declared inside the block is fresh each time — per-scenario isolation falls out of ordinary Kotlin closures, with no dependency-injection container and no reflection.

Also implemented: the full 80-language Gherkin dialect table, Cucumber Expressions with all of Cucumber's built-in parameter types, tag expressions, backgrounds, scenario outlines, data tables (with upstream's asList/asLists/asMap/asMaps conversions), doc strings, rules, hooks, tag filtering, and undefined/ambiguous step reporting with paste-able snippets.

Conformance

This is a port, so behavioural fidelity is a requirement rather than an aspiration. Upstream's own language-agnostic test data is embedded as generated Kotlin and run on every target:

Suite Cases
cucumber/gherkin parser — good and bad 50 + 12
cucumber/gherkin pickle compilation 50
cucumber/cucumber-expressions 120
cucumber/tag-expressions 64

Error messages, positions and expected: #Token, … lists are byte-identical to upstream, because those strings are what users compare against Cucumber's output. Every intentional difference is recorded in DEVIATIONS.md.

The fixtures are embedded as code rather than read from test resources because Kotlin/Native and Wasm cannot read files at runtime — the same constraint that shapes the whole framework.

Building

Requires JDK 21 and, for the Apple targets, Xcode.

./gradlew build                                  # all Tier A targets
./gradlew -Pcucumberkmp.browserTests build       # adds Chrome/Karma runs
./gradlew linkDebugTestIosArm64                  # device target: compile and link check

Regenerate the vendored upstream data (needs Python 3 and PyYAML):

python3 tools/update-gherkin-dialects.py --download
python3 tools/update-gherkin-corpus.py
python3 tools/update-expression-corpus.py
python3 tools/update-tag-expression-corpus.py

A scheduled workflow runs those weekly and reports if upstream has moved.

Licence

MIT, the same licence as Cucumber. See LICENSE, and NOTICE for what is derived from or embedded verbatim out of the upstream projects.

cucumber-kmp is an independent port. It is not published, endorsed or supported by Cucumber Ltd.