Cucumber, a BDD Framework for Java and Spring


10 Aug 2020  Sergio Martin Rubio  13 mins read.

The best way to start using BDD with Java is by using one of the BDD test frameworks like Cucumber which allows you to write your test cases using the Gherkin syntax

Cucumber is an open source BDD framework that supports many languages like Java, JavaScript, Ruby, C++, Golang or Kotlin.

When using BDD with a framework like Cucumber you will have to write executable specification which translates to the requirements. This specification is written in a language called Gherkin (introduced in the previous section).

Dependencies

Cucumber will be used in combination with JUnit 5 and Spring.

<!-- Cucumber dependencies -->
<dependency>
    <groupId>io.cucumber</groupId>
    <artifactId>cucumber-java</artifactId>
    <version>${cucumber.version}</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>io.cucumber</groupId>
    <artifactId>cucumber-junit-platform-engine</artifactId>
    <version>${cucumber.version}</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>io.cucumber</groupId>
    <artifactId>cucumber-spring</artifactId>
    <version>${cucumber.version}</version>
    <scope>test</scope>
</dependency>

To run Cucumber with JUnit 4 you will have to replace cucumber-junit-platform-engine with cucumber-junit and also some additional configuration is required.

<dependency>
    <groupId>io.cucumber</groupId>
    <artifactId>cucumber-junit</artifactId>
    <version>${cucumber.version}</version>
    <scope>test</scope>
</dependency>

You can run the Cucumber tests with mvn clean test.

Files Structure

BDD specification is defined in files with the extension .feature. A feature file should define a single feature and this file will contain all the examples that describe how the feature works, including edge cases and alternative paths. It is recommended to create subdirectories for each capability and tags to identify related parts of the application, so you can organize the test execution.

By default Cucumber will look for.feature files on the classpath. However, you will need to locate your .feature files in the same location as the step definition package. e.g. if the step definitions are in src/test/java/com/sergiomartinrubio/transactionsservice/bdd/stepdefs/ the feature file should be located in src/test/resources/com/sergiomartinrubio/transactionsservice/bdd/stepdefs/.

If you are using ``cucumber-junit-platform-engine you can add some [configuration](https://github.com/cucumber/cucumber-jvm/tree/master/junit-platform-engine) in test/resources/junit-platform.properties`.

cucumber.plugin=pretty, html:target/cucumber-reports.html

pretty provides a more verbose login output.

html:target/cucumber-reports.html generates an HTML report at the location given.

json:target/cucumber.json generates a JSON report at the given location that can be used by third party tools like Jenkins.

In case you are using cucumber-junit you can have:

@CucumberOptions(
        plugin = {"pretty", "html:target/cucumber-reports.html"},
        features = "classpath:features"
)
public class RunCucumberTest {
}

Runner Configuration

If you are using cucumber-junit-platform-engine (JUnit 5) you will need to create a configuration class that will be used by your Cucumber step definition classes.

@Cucumber
public class RunCucumberTest {
}

For cucumber-junit (JUnit 4):

@RunWith(Cucumber.class)
public class RunCucumberTest {
}

Spring Configuration

We are going to use Cucumber to test our controller layer of a MVC application, so we have to configure an application context that cucumber will use.

@CucumberContextConfiguration
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class CucumberSpringContextConfiguration {

    @Autowired
    protected TestRestTemplate restTemplate;

}

Executable Specification

Executable specification is plain text definitions of test scenarios that are stored in a .feature file. The specification should contain multiple scenarios. The only requirement is that scenarios follow the Gherkin syntax.

In Cucumber, you use the Feature keyword to mark the feature’s title, and any text between the title and the first scenario is considered as the feature description.

Scenarios are defined with the Scenario keyword followed by a title. The title should summarize what the scenario does in a short and declarative sentence.

The executions steps are made up of three parts (the same as the ones defined in the Gherkin language):

  • The Given step (Given): includes preconditions, context or background (even if no action is executed).
  • The When step(When): includes the action or event you want to test. This action will usually return or generate an outcome.
  • The Then step(Then): is used to compare the outcome from the When step with the expectations.

Other Cucumber keywords are And and But that can be placed after any of the steps, and they will be considered the same as the previous step. The goal is to improve readability and reusability of scenarios, so you do not put two conditions in the same step.

Feature: Return transaction status # feature title
  # feature descrition
  As an user
  I want to be able to retrieve the status of a transaction
  So that I can see the transaction status, amount and fee
  
  Scenario: Get transaction stored in our system given a channel # scenario title
    Given a transaction that is stored in our system with date before today # given step
    When I check the status from INTERNAL channel # when step
    Then the system returns the status PENDING # then step
    And the amount # additional condition to then
    And the fee # additional condition then

Comments are also allowed in .feature files by using the character #.

The Background keyword is used to specify steps that will run before each scenario in the feature.

Tables are also another way of expressing scenarios, so you avoid duplicating the same specification multiple times for different values.

Step Definitions

Cucumber step definitions are classes with methods annotated with @Given @When @Then @And or @But.

@Given("a transaction that is stored in our system with date before today")
public void a_transaction_that_is_stored_in_our_system_with_date_before_today() {
  	Transaction transactionBeforeToday = createTransaction(DATE_BEFORE_NOW);
  	transactionService.save(transactionBeforeToday); // you can inject dependencies to setup the scenario
}

@When("I check the status from {channel} channel")
public void i_check_the_status_from_a_given_channel(Channel channel) {
    params.setChannel(Channel.valueOf(channel));
    params.setReference(TRANSACTION_REFERENCE);
    HttpHeaders httpHeaders = new HttpHeaders();
    httpHeaders.add(CONTENT_TYPE, APPLICATION_JSON_VALUE);
    HttpEntity<TransactionStatusParams> statusRequest = new HttpEntity<>(params, httpHeaders);
    response = restTemplate.exchange("/status", HttpMethod.POST, statusRequest, TransactionStatus.class);
}

@Then("the system returns the status {status}")
public void the_system_returns_the_status(String status) {
		assertThat(response.getBody().getStatus()).isEqualTo(status);
}

@And("the amount")
public void and_the_amount() {
  	assertThat(response.getBody().getAmount()).isEqualTo(AMOUNT);
}

The previous example shows how we can test the controller layer.

Parameter Types

Cucumber allows you to pass variables into your step implementations and use parameter types to convert the text between curly braces into the required type. For instance the string INTERNAL will be converted to the type Channel. Cucumber also provides some out-of-the-box conversations for types like BigDecimal, Integer, Double, Float… however for Enum or custom classes you will have to define your own custom parameter type.

@ParameterType("CLIENT|ATM|INTERNAL")
public Channel channel(String channel) {
		return Channel.valueOf(channel);
} 

@ParameterType accepts regular expression like ".*" which allows any string

Global Variables

A scenario sometimes requires that some information is shared between steps. You can do this by using global variables (e.g. response). This global variables are not shared between scenarios, so there is not risk of the values being overridden.

Pending Scenarios

Scenarios can also be pending when the feature is not implemented yet or you are still working on it. You can mark pending scenarios by throwing PendingException().

@Given("a transaction that is not stored in our system")
public void a_transaction_that_is_not_stored_in_our_system() {
	throw new PendingException();
}

Data Initialization and Clean up

Sometimes tests use a database that need to be initialise before running your tests and clean up after running each scenario. @Before and @After can be use to do clean up tasks like removing entries from the database. Do no confuse these annotations with the ones from JUnit.

@After
public void teardown() {
		transactionRepository.deleteAll();
}

Data Tables

Cucumber also provides embedded data tables in your scenarios. Tables can be used to combine similar scenarios in a single scenario so you avoid duplication and improve maintainability.

You can define the tables in a .feature file as follows:

Scenario: Get transaction with date before today given a channel
  Given a transaction that is stored in our system with date before today
  When I check the status from a channel:
    | channel  |
    | CLIENT   |
    | ATM      |
    | INTERNAL |
  Then the status, amount and fees are:
    | status  | amount | fee  |
    | SETTLED | 190.20 |      |
    | SETTLED | 190.20 |      |
    | SETTLED | 193.38 | 3.18 |

Then in your step definitions class you will have to create methods annotated with @DataTableType in order to allow Cucumber to map the table defined in the .feature file with a Map.

@DataTableType
public Channel channelEntry(Map<String, String> entry) {
    return Channel.valueOf(entry.get("channel"));
}
@DataTableType
public TransactionStatus transactionStatusEntry(Map<String, String> entry) {
    return TransactionStatus.builder()
            .status(Status.valueOf(entry.get("status")))
            .amount(new BigDecimal(entry.get("amount")))
            .fee(Optional.ofNullable(entry.get("fee"))
                    .map((BigDecimal::new))
                    .orElse(null))
            .build();
}

Finally you can define the steps definitions that will contain the data from the tables.

@When("I check the status from a channel:")
public void i_check_the_status_from_a_channel(List<Channel> channels) {
    for (Channel channel : channels) {
        params.setChannel(channel);
        params.setReference(TRANSACTION_REFERENCE);
        HttpHeaders httpHeaders = new HttpHeaders();
        httpHeaders.add(CONTENT_TYPE, APPLICATION_JSON_VALUE);
        HttpEntity<TransactionStatusParams> statusRequest = new HttpEntity<>(params, httpHeaders);
        ResponseEntity<TransactionStatus> response = restTemplate
                .exchange("/status", HttpMethod.POST, statusRequest, TransactionStatus.class);
        responses.add(response);
    }
}
@Then("the status, amount and fees are:")
public void the_status_amount_and_fee_are(List<TransactionStatus> transactionStatusList) {
    for (int i = 0; i < transactionStatusList.size(); i++) {
        assertThat(responses.get(i).getBody().getStatus()).isEqualTo(transactionStatusList.get(i).getStatus());
        assertThat(responses.get(i).getBody().getAmount()).isEqualTo(transactionStatusList.get(i).getAmount());
        assertThat(responses.get(i).getBody().getFee()).isEqualTo(transactionStatusList.get(i).getFee());
    }
}

Doc String Types

A doc string type can be a json file that is passes as parameter. You can use @DocStringType to convert the string type to a particular type with the help of ObjectMapper.

  Scenario: Get transaction with date after today given a channel
  	...
    Then the status, amount and fees
      """json
      {
         "status": "FUTURE",
         "amount": "190.20",
         "fee": "3.18"
      }
      """
private final ObjectMapper objectMapper = new ObjectMapper();

@DocStringType
public TransactionStatus transactionStatus(String docString) throws IOException {
  	return objectMapper.readValue(docString, TransactionStatus.class);
}
@Then("the status, amount and fees")
public void the_status_amount_and_fees(TransactionStatus transactionStatus){
 		// assert results
}

Examples