BDD Cucumber Spring Boot microservices executable specifications
Md Sanwar Hossain
Md Sanwar Hossain
Senior Software Engineer · Spring Boot Testing Series
Testing April 4, 2026 20 min read Spring Boot Testing Series

BDD with Cucumber + Spring Boot: Writing Executable Specifications for Microservices

Behaviour-Driven Development with Cucumber bridges the gap between business requirements and automated tests. When your product team writes acceptance criteria in Gherkin — Given, When, Then — those scenarios become living documentation that runs against your Spring Boot service on every CI build. This guide walks through a complete Cucumber 7 + JUnit 5 + Spring Boot 3 setup, covering feature files, step definitions, Spring context sharing, Testcontainers integration, data tables, and scenario outlines for microservice acceptance testing.

Table of Contents

  1. BDD Basics: Gherkin, Scenarios, and Executable Specifications
  2. Project Setup: Cucumber 7 + JUnit 5 + Spring Boot 3
  3. Writing Feature Files: Scenarios, Data Tables, and Outlines
  4. Step Definitions: Glue Code and Spring Injection
  5. Sharing Spring Context Across Step Definition Classes
  6. Integrating Testcontainers for Real Database Acceptance Tests
  7. Generating Living Documentation and HTML Reports
  8. Running Cucumber in GitHub Actions CI
  9. Key Takeaways

1. BDD Basics: Gherkin, Scenarios, and Executable Specifications

BDD Cucumber Gherkin executable specification microservices | mdsanwarhossain.me
BDD Cucumber Architecture — mdsanwarhossain.me

BDD is a collaboration technique, not just a testing tool. The core idea is that examples of system behavior, written in plain language by developers, testers, and business stakeholders together, become the acceptance criteria and the automated test suite simultaneously. Gherkin is the language: structured prose with Feature, Scenario, and step keywords (Given, When, Then, And, But).

The key discipline is writing scenarios at the business level, not the implementation level. "Given I call POST /api/orders with payload X" is an implementation scenario. "Given a customer has added 2 laptops to their cart" is a business scenario. The former breaks on API refactors; the latter describes an invariant that should always be true.

Golden Rule: Write Gherkin scenarios in the language of the business domain, not in terms of HTTP verbs, JSON fields, or class names. Scenarios should be readable and meaningful to a non-technical stakeholder.

2. Project Setup: Cucumber 7 + JUnit 5 + Spring Boot 3

Cucumber 7 integrates with JUnit 5 via the cucumber-junit-platform-engine artifact. The engine discovers feature files, matches step definitions, and runs scenarios as JUnit 5 test cases — making them visible in IDE test runners and CI reports. Spring Boot context is shared across all scenarios in the same run via @CucumberContextConfiguration.

<!-- pom.xml -->
<dependencies>
    <dependency>
        <groupId>io.cucumber</groupId>
        <artifactId>cucumber-java</artifactId>
        <version>7.18.0</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>io.cucumber</groupId>
        <artifactId>cucumber-spring</artifactId>
        <version>7.18.0</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>io.cucumber</groupId>
        <artifactId>cucumber-junit-platform-engine</artifactId>
        <version>7.18.0</version>
        <scope>test</scope>
    </dependency>
</dependencies>
# src/test/resources/junit-platform.properties
cucumber.publish.quiet=true
cucumber.plugin=pretty, html:target/cucumber-reports/index.html, json:target/cucumber.json
cucumber.features=src/test/resources/features
cucumber.glue=com.example.steps

3. Writing Feature Files: Scenarios, Data Tables, and Outlines

Feature files live in src/test/resources/features/. Each .feature file covers a single feature area. Scenarios use data tables for inline test data and Scenario Outlines for parameterized variations. Background steps run before every scenario in the feature, eliminating duplication of common setup steps.

# src/test/resources/features/order-management.feature
Feature: Order Management
  As a customer
  I want to place and manage orders
  So that I can purchase products from the platform

  Background:
    Given the product catalog contains the following items:
      | sku    | name    | price  | stock |
      | SKU-01 | Laptop  | 999.99 | 50    |
      | SKU-02 | Mouse   |  29.99 | 200   |

  Scenario: Customer places an order for an in-stock product
    Given I am logged in as customer "alice@example.com"
    When I place an order for 2 units of "SKU-01"
    Then the order should be created with status "PENDING"
    And the inventory for "SKU-01" should be reduced by 2

  Scenario: Customer cannot order more than available stock
    Given I am logged in as customer "bob@example.com"
    When I attempt to order 100 units of "SKU-02"
    Then the order should be rejected with error "Insufficient inventory"
    And no order should be created for "bob@example.com"

  Scenario Outline: Discount is applied based on order total
    Given I am logged in as customer "charlie@example.com"
    When I place an order for <qty> units of "SKU-02"
    Then the discount applied should be "<discount>"

    Examples:
      | qty | discount |
      |   1 | 0%       |
      |  10 | 5%       |
      |  50 | 10%      |

4. Step Definitions: Glue Code and Spring Injection

Cucumber step definitions Spring Boot microservices | mdsanwarhossain.me
Cucumber Step Definitions Architecture — mdsanwarhossain.me

Step definition classes are plain Spring beans annotated with @Component. Cucumber's Spring integration injects all dependencies via standard @Autowired. Each step method is annotated with a Cucumber step annotation (@Given, @When, @Then) whose string pattern is matched against the scenario text.

@Component
public class OrderStepDefinitions {

    @Autowired
    private OrderService orderService;

    @Autowired
    private ProductRepository productRepository;

    @Autowired
    private OrderRepository orderRepository;

    // Shared state within a scenario (Cucumber creates new instance per scenario)
    private String currentUserEmail;
    private Order lastCreatedOrder;
    private Exception lastException;

    @Given("the product catalog contains the following items:")
    public void theProductCatalogContainsItems(DataTable dataTable) {
        List<Map<String, String>> rows = dataTable.asMaps(String.class, String.class);
        rows.forEach(row -> productRepository.save(
            new Product(
                row.get("sku"),
                row.get("name"),
                new BigDecimal(row.get("price")),
                Integer.parseInt(row.get("stock"))
            )
        ));
    }

    @Given("I am logged in as customer {string}")
    public void iAmLoggedInAsCustomer(String email) {
        this.currentUserEmail = email;
    }

    @When("I place an order for {int} units of {string}")
    public void iPlaceAnOrderFor(int quantity, String sku) {
        try {
            this.lastCreatedOrder = orderService.placeOrder(
                new OrderRequest(currentUserEmail, sku, quantity)
            );
        } catch (Exception e) {
            this.lastException = e;
        }
    }

    @When("I attempt to order {int} units of {string}")
    public void iAttemptToOrderUnits(int quantity, String sku) {
        iPlaceAnOrderFor(quantity, sku); // delegate — captures exception
    }

    @Then("the order should be created with status {string}")
    public void theOrderShouldBeCreatedWithStatus(String expectedStatus) {
        assertThat(lastException).isNull();
        assertThat(lastCreatedOrder).isNotNull();
        assertThat(lastCreatedOrder.getStatus()).isEqualTo(expectedStatus);
    }

    @Then("the order should be rejected with error {string}")
    public void theOrderShouldBeRejectedWithError(String expectedMessage) {
        assertThat(lastException)
            .isNotNull()
            .hasMessageContaining(expectedMessage);
    }

    @Then("the inventory for {string} should be reduced by {int}")
    public void theInventoryShouldBeReducedBy(String sku, int reduction) {
        Product product = productRepository.findBySku(sku).orElseThrow();
        // Stock was 50 initially for SKU-01, order was for 2 units
        assertThat(product.getStock()).isEqualTo(50 - reduction);
    }
}

5. Sharing Spring Context Across Step Definition Classes

The entry point for Cucumber's Spring integration is the @CucumberContextConfiguration class. This bootstraps the Spring Boot application context once for the entire test suite. All step definition classes share the same context, enabling autowiring of repositories, services, and test containers across multiple step files.

// Bootstrap class — one per test suite
@CucumberContextConfiguration
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
public class CucumberSpringConfiguration {
    // No content needed — acts as the Spring context anchor
}

// Separate step definitions for a different domain area
@Component
public class InventoryStepDefinitions {

    @Autowired
    private InventoryService inventoryService; // Same Spring context

    @Then("no order should be created for {string}")
    public void noOrderShouldBeCreatedFor(String email) {
        List<Order> orders = orderRepository.findByCustomerEmail(email);
        assertThat(orders).isEmpty();
    }

    @Then("the discount applied should be {string}")
    public void theDiscountAppliedShouldBe(String expectedDiscount) {
        // Assert via the shared lastCreatedOrder state using ScenarioContext
        assertThat(scenarioContext.getLastOrder().getAppliedDiscount())
            .isEqualTo(expectedDiscount);
    }
}

// Shared scenario context bean — use to pass state between step classes
@Component
@ScenarioScope  // Cucumber-Spring scope: new instance per scenario
public class ScenarioContext {
    private Order lastOrder;
    private Exception lastException;

    public Order getLastOrder() { return lastOrder; }
    public void setLastOrder(Order order) { this.lastOrder = order; }
    // getters/setters for lastException...
}
@ScenarioScope: This Cucumber-Spring scope creates a new bean instance for each scenario and destroys it after — analogous to request scope in web applications. Use it for any state object shared between step classes within a single scenario.

6. Integrating Testcontainers for Real Database Acceptance Tests

Acceptance tests with real business assertions are only meaningful if they run against real infrastructure. Combine Testcontainers with Cucumber to spin up a PostgreSQL container once for the entire Cucumber suite, share it via the Spring context configuration class, and run all scenarios against real data.

@CucumberContextConfiguration
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
@Testcontainers
public class CucumberSpringConfiguration {

    @Container
    @ServiceConnection
    static PostgreSQLContainer<?> postgres =
            new PostgreSQLContainer<>("postgres:16-alpine");

    // Container starts once and is shared across all scenarios
}

// application-test.yml — Spring Boot test profile
# src/test/resources/application-test.yml
spring:
  jpa:
    hibernate:
      ddl-auto: create-drop
    show-sql: false
  flyway:
    enabled: true
    locations: classpath:db/migration, classpath:db/testdata

Use @Transactional on step definition classes to roll back database changes after each scenario, keeping scenarios isolated from each other. However, be cautious: if your test asserts on Kafka messages or external side-effects that happen inside the transaction, rollback may prevent you from observing them. In those cases, use @Sql scripts to reset state instead of transaction rollback.

7. Generating Living Documentation and HTML Reports

Cucumber generates multiple report formats. The HTML report provides a visual breakdown of all features, scenarios, and steps — pass/fail status, duration, and embedded screenshots for failed steps. Publish these as CI artifacts so stakeholders can review acceptance test results without reading code.

# junit-platform.properties
cucumber.plugin=\
  pretty,\
  html:target/cucumber-reports/cucumber.html,\
  json:target/cucumber-reports/cucumber.json,\
  junit:target/cucumber-reports/cucumber.xml,\
  io.cucumber.core.plugin.SerenityReporter

# Optional: publish to Cucumber Reports cloud
# cucumber.publish.enabled=true
# cucumber.publish.token=${CUCUMBER_PUBLISH_TOKEN}
# GitHub Actions — publish HTML report as artifact
- name: Upload Cucumber HTML Report
  uses: actions/upload-artifact@v4
  if: always()
  with:
    name: cucumber-report
    path: target/cucumber-reports/
    retention-days: 14

8. Running Cucumber in GitHub Actions CI

Cucumber scenarios are JUnit 5 tests and run via mvn verify or a dedicated Failsafe profile. Use tags to separate fast unit scenarios from slower acceptance scenarios, running them at different pipeline stages.

# .github/workflows/bdd-tests.yml
name: BDD Acceptance Tests

on:
  push:
    branches: [main, develop]
  pull_request:

jobs:
  acceptance-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up JDK 21
        uses: actions/setup-java@v4
        with:
          java-version: '21'
          distribution: 'temurin'
          cache: maven

      - name: Run BDD acceptance tests
        run: mvn verify -Dcucumber.filter.tags="not @wip" -Pfailsafe

      - name: Upload Cucumber report
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: cucumber-report
          path: target/cucumber-reports/

# Tagging scenarios to skip work-in-progress
# In your .feature file:
# @wip
# Scenario: Incomplete scenario under development
#   ...

# Run only smoke tests on every commit:
# mvn test -Dcucumber.filter.tags="@smoke"

# Run full suite on release:
# mvn verify -Dcucumber.filter.tags="not @wip"

9. Key Takeaways

Leave a Comment

Related Posts

Md Sanwar Hossain - Software Engineer
Md Sanwar Hossain

Software Engineer · Java · Spring Boot · Microservices

Last updated: April 4, 2026