Skip to main content

A Cucumber Alternative for Kotlin and Java

· 6 min read
Creator of Kensa

Cucumber earns its place on one kind of test: an acceptance test whose output someone outside the development team reads. A tester, an analyst, a product owner. That reader is the only reason to put Given-When-Then on a test at all. Unit tests are code, and developers can read code.

I wrote Kensa for that reader, and it works the same in Kotlin and Java. This post takes a Cucumber scenario, moves it to Kensa, and maps the rest of Cucumber's concepts across, so you can see what a move involves before you start one. If you want the argument for dropping feature files first, that's in BDD Without Gherkin.

One scenario, before and after​

The scenario comes from Clearwave, an example order service that provisions voice and broadband through two suppliers. One supplier rejects the order. In Cucumber:

Feature: Order Service

Scenario: Order is rejected by FibreVision
Given OpenNetwork will complete the order
And FibreVision will reject the order
When a voice and broadband order is placed
Then the order confirmation should be pending
And eventually FibreVision should report the order rejected

With step definitions along these lines:

class OrderSteps(private val world: OrderWorld) {

@Given("{word} will reject the order")
fun supplierWillRejectTheOrder(supplier: String) {
stubFor(supplier).primeOrder(world.trackingId, OrderScenario.Rejected)
}

@When("a voice and broadband order is placed")
fun aVoiceAndBroadbandOrderIsPlaced() {
world.confirmation = orderService.place(world.anOrder())
}

// and three more
}

The same test in Kensa, as it stands in the example project:

@Test
fun `order is rejected by fibre vision`() {
given(openNetworkWillCompleteTheOrder())
and(fibreVisionWillRejectTheOrder())

whenever(aVoiceAndBroadbandOrderIsPlaced())

then(theOrderConfirmation(), shouldBePending())
thenEventuallyFibreVisionNotifications(shouldBeRejected(
supplier = fixtures[broadbandSupplier],
))
}

Kensa parses that method at runtime and renders it as the scenario. Each call becomes a sentence, and the broadband supplier fixture renders as the value it held on this run, so the report says FibreVision, not the name of a variable.

Each step definition becomes a function:

private fun fibreVisionWillRejectTheOrder() = Action<GivensContext> { (fixtures) ->
fibreVisionStub.primeOrder(fixtures[trackingId], OrderScenario.Rejected)
}

The body is the step definition's body. What's gone is the string that bound it. The method name is the sentence, so there's no regular expression to match and nothing for a rename to break.

The function can live in the test class, as it does here, or anywhere the tests can call it. A step used across several test classes goes in a base class or a shared helper, the same way you'd share any other code, and it reads the same in every report that uses it.

Everything else Cucumber gives you​

CucumberKensa
Feature file and step definitionsThe test method, rendered as sentences
Step definitionA function returning an Action or a StateCollector, in the test class or shared
Feature description@Notes on the test class, with markdown and links
BackgroundA SetupStep, written once and passed to given
Scenario Outline and Examples@ParameterizedTest, with @ParameterizedTestDescription naming each row
Data table@ExpandableRenderedValue(renderAs = Tabular)
World or scenario contextFixtures for inputs, captured outputs for results
TagsYour framework's tags, plus @Issue and @Epic
HooksYour framework's lifecycle, unchanged

A few of those are worth a sentence more.

Fixtures replace the World. A Cucumber World is a bag of mutable state that the steps write to and read from. Kensa fixtures are declared once, typed, created lazily and fresh for each test, and they can depend on each other. A tracking ID, a customer, an address built from both. Because they're named, the report shows each one by name wherever the test uses it.

Fresh fixtures make parallel runs safe. Nothing is shared between tests, so they can run at the same time against the same deployed services. Clearwave gives every test its own trackingId fixture and sends it as a header on each request. The supplier stubs use it to route primed responses and captured messages back to the test that caused them, so each report shows only its own traffic while the whole suite runs in parallel. The report's overview shows what that saved: wall clock against total elapsed, and the speed-up.

Scenario Outlines become parameterised tests. Each row of the examples table is an invocation, and @ParameterizedTestDescription gives each one a readable label in the report instead of a list of arguments.

Tags link to the ticket. @Issue("PROJ-42") puts a badge on the test that opens the ticket, and the report can be filtered by issue or epic. If the acceptance criteria were written in Jira, the person who wrote them can get from there to what ran.

What the report adds​

This is where a move stops being a translation.

A Cucumber report shows the scenario, green or red. An acceptance test against a running system does more than that. It sends requests, the system talks to other services, and responses come back. Kensa records those interactions while the test runs. The order test above captures the order request, the confirmation, and each notification the two supplier stubs send back. The report shows them as a sequence diagram drawn from that traffic, and each message opens to its full payload.

For the reader this post started with, that's the difference. They can see what the system did, in order, with the data, without opening the code. The Clearwave example report has this test in it, as OrderServiceTest and OrderServiceJavaTest, along with the rest of the suite.

Moving a suite across​

You don't have to do it in one go. Cucumber JVM and Kensa both run on the JUnit Platform, so one build can run both suites while you move scenarios over one at a time. Most of the work is moving step definition bodies into functions and deleting the strings.

There's a cost. Kensa is opinionated about how a test reads, since the test body is what the reader sees. It has more machinery than a test library, and it doesn't have Cucumber's twenty years of Stack Overflow answers.

When Kensa isn't the answer​

If nobody outside the team reads the output, you don't need Cucumber or Kensa. Write plain tests. BDD in Kotlin covers the wider field.


Kensa is open source. The Kotlin and Java quickstarts take about five minutes.