Skip to content
Home / Skills / Bdd / Cucumber Patterns
BD

Cucumber Patterns

Bdd implementation v1.0.0

Cucumber Patterns

Overview

Cucumber is a BDD testing framework that executes Gherkin scenarios as automated tests. Cucumber-JVM specifically brings Cucumber to the Java ecosystem with seamless Spring Boot integration, enabling teams to write business-readable specifications that drive executable tests. Through step definitions, hooks, and data transformers, Cucumber bridges the gap between natural language scenarios and production code. Mastering Cucumber patterns enables teams to build maintainable, reusable test automation that serves as living documentation.

Effective Cucumber implementation requires understanding not just the syntax, but the patterns that make tests maintainable at scale. Well-designed step definitions, proper use of hooks, and intelligent data handling transform Cucumber from a simple testing tool into a powerful specification framework.


Key Concepts

Cucumber Architecture

┌─────────────────────────────────────────────────────────┐
│              Cucumber-JVM Architecture                  │
├─────────────────────────────────────────────────────────┤
│                                                         │
│  Feature Files (.feature)                               │
│          ↓                                              │
│  Gherkin Parser                                         │
│          ↓                                              │
│  Scenario Executor                                      │
│          ↓                                              │
│  Step Definition Matcher (regex/cucumber expressions)   │
│          ↓                                              │
│  Step Definition Methods (@Given/@When/@Then)           │
│          ↓                                              │
│  Application Code (Services, Repositories, APIs)        │
│                                                         │
│  Hooks (@Before/@After/@BeforeStep/@AfterStep)          │
│          ↓                                              │
│  Reports (HTML, JSON, JUnit XML)                        │
│                                                         │
└─────────────────────────────────────────────────────────┘

Step Definition Matching

Expression TypeSyntaxExampleUse Case
Cucumber Expression{type}I have {int} applesSimple parameters, readable
Regular ExpressionJava regex^I have (\\d+) apples$Complex patterns, legacy code
Custom Parameter@ParameterType{money}Domain types (Money, Date)
Data TableDataTableTable as parameterMultiple rows of data
Doc StringStringMulti-line textLarge text blocks

Hook Execution Order

@BeforeAll (once per test run)

@Before (before each scenario)

@BeforeStep (before each step)

Step Execution (Given/When/Then)

@AfterStep (after each step)

@After (after each scenario - ALWAYS runs, even if failure)

@AfterAll (once per test run)

Spring Integration Lifecycle

// Cucumber + Spring Boot integration
@SpringBootTest
@CucumberContextConfiguration
public class CucumberSpringConfig {
    // Spring context shared across scenarios
}

// Step definitions as Spring components
@Component
public class OrderSteps {
    @Autowired
    private OrderService orderService;
    
    // Dependency injection works
}

Data Transformation Patterns

Raw String → Custom Type
   "100.00" → Money.usd(100)
   "2026-02-01" → LocalDate.of(2026, 2, 1)
   "PENDING" → OrderStatus.PENDING

DataTable → List<Entity>
   | name | email |
   | John | j@e.c |  → List<User>

DataTable → Map<String, String>
   | key   | value |
   | city  | NYC   |  → Map<String, String>

Best Practices

1. Use Cucumber Expressions Over Regex

Cucumber expressions are more readable and maintainable. Use regex only for complex patterns.

2. Keep Step Definitions Thin

Step definitions should delegate to domain services, not contain business logic.

3. Reuse Step Definitions Across Features

Write generic, reusable step definitions that work for multiple scenarios.

4. Use Hooks for Technical Concerns Only

Hooks are for setup/teardown, not business behavior. Business steps belong in scenarios.

5. Create Custom Parameter Types for Domain Concepts

Transform strings to domain objects automatically for cleaner step definitions.


Code Examples

Example 1: Step Definitions with Cucumber Expressions

// Step definitions using Cucumber expressions

@SpringBootTest
@CucumberContextConfiguration
public class OrderSteps {
    
    @Autowired
    private OrderService orderService;
    
    @Autowired
    private OrderRepository orderRepository;
    
    private Order currentOrder;
    private Exception thrownException;
    
    // ✅ GOOD - Simple cucumber expression
    @Given("I have an order totaling ${double}")
    public void iHaveAnOrderTotaling(double amount) {
        currentOrder = new Order();
        currentOrder.addItem(new OrderItem("Product", Money.usd(amount)));
    }
    
    // ✅ GOOD - Multiple parameters
    @When("I apply discount code {string} with {int}% off")
    public void iApplyDiscountCode(String code, int percentage) {
        DiscountCode discount = new DiscountCode(code, percentage);
        orderService.applyDiscount(currentOrder, discount);
    }
    
    // ✅ GOOD - Optional parameters
    @Given("a customer named {string} with email {string}")
    public void aCustomerWithEmail(String name, String email) {
        Customer customer = new Customer(name, email);
        orderService.setCustomer(currentOrder, customer);
    }
    
    // ✅ GOOD - Word alternatives with regex
    @Then("^the order (should be|is) (confirmed|pending)$")
    public void theOrderStatus(String verb, String status) {
        OrderStatus expectedStatus = OrderStatus.valueOf(status.toUpperCase());
        assertThat(currentOrder.getStatus()).isEqualTo(expectedStatus);
    }
    
    // ✅ GOOD - Custom parameter type (see later example)
    @When("I set delivery date to {date}")
    public void iSetDeliveryDate(LocalDate date) {
        currentOrder.setDeliveryDate(date);
    }
}

// ❌ BAD - Overly specific step definition (not reusable)
@Given("I have an order for Widget A with quantity 2 at price $25.00 totaling $50.00")
public void specificOrder() {
    // Too specific, can't be reused
}

// ✅ GOOD - Generic, reusable
@Given("I have an order for {string} with quantity {int} at price ${double}")
public void genericOrder(String product, int quantity, double price) {
    // Reusable across many scenarios
}

// ❌ BAD - Business logic in step definition
@When("I apply discount code {string}")
public void applyDiscountWithLogic(String code) {
    // WRONG - business logic here
    if (currentOrder.getTotal().isGreaterThan(Money.usd(100))) {
        double discountPercent = code.equals("VIP") ? 20 : 10;
        Money discount = currentOrder.getTotal().multiply(discountPercent / 100.0);
        currentOrder.setDiscount(discount);
    }
}

// ✅ GOOD - Delegate to service
@When("I apply discount code {string}")
public void applyDiscount(String code) {
    orderService.applyDiscount(currentOrder, code);
}

Example 2: Data Tables and Transformers

// Working with data tables

public class ProductSteps {
    
    @Autowired
    private ProductRepository productRepository;
    
    // ✅ GOOD - DataTable to List of Maps
    @Given("the following products exist:")
    public void theFollowingProductsExist(DataTable dataTable) {
        List<Map<String, String>> rows = dataTable.asMaps();
        
        for (Map<String, String> row : rows) {
            Product product = new Product(
                row.get("sku"),
                row.get("name"),
                new BigDecimal(row.get("price")),
                Boolean.parseBoolean(row.get("inStock"))
            );
            productRepository.save(product);
        }
    }
    
    // ✅ GOOD - DataTable to custom objects with transformer
    @Given("the catalog contains:")
    public void theCatalogContains(List<Product> products) {
        // Automatic conversion using DataTableType
        productRepository.saveAll(products);
    }
    
    // ✅ GOOD - Vertical data table (key-value pairs)
    @When("I create product with details:")
    public void iCreateProductWithDetails(Map<String, String> productDetails) {
        Product product = Product.builder()
            .sku(productDetails.get("sku"))
            .name(productDetails.get("name"))
            .price(new BigDecimal(productDetails.get("price")))
            .category(productDetails.get("category"))
            .build();
        
        productRepository.save(product);
    }
}

// Custom DataTable transformer
@DataTableType
public Product productEntry(Map<String, String> entry) {
    return new Product(
        entry.get("sku"),
        entry.get("name"),
        new BigDecimal(entry.get("price")),
        Boolean.parseBoolean(entry.get("inStock"))
    );
}

// ❌ BAD - Manual parsing in every step
@Given("products exist:")
public void productsExist(DataTable dataTable) {
    List<List<String>> rows = dataTable.asLists();
    for (int i = 1; i < rows.size(); i++) {  // Skip header
        List<String> row = rows.get(i);
        String sku = row.get(0);
        String name = row.get(1);
        String price = row.get(2);
        // Manual parsing repeated everywhere
    }
}

// ✅ GOOD - Use transformer once, reuse everywhere

Example 3: Hooks and Lifecycle Management

// Hooks for setup and teardown

public class CucumberHooks {
    
    @Autowired
    private OrderRepository orderRepository;
    
    @Autowired
    private ProductRepository productRepository;
    
    @Autowired
    private TestDataBuilder testDataBuilder;
    
    // ✅ GOOD - Clean state before each scenario
    @Before
    public void beforeScenario() {
        // Ensure clean state for scenario independence
        orderRepository.deleteAll();
        productRepository.deleteAll();
    }
    
    // ✅ GOOD - Conditional setup with tags
    @Before("@requires-test-data")
    public void setupTestData() {
        // Only run for scenarios tagged with @requires-test-data
        testDataBuilder.createStandardTestData();
    }
    
    // ✅ GOOD - Capture screenshot on failure
    @After
    public void afterScenario(Scenario scenario) {
        if (scenario.isFailed()) {
            // Log additional context for failures
            byte[] screenshot = captureScreenshot();
            scenario.attach(screenshot, "image/png", "failure-screenshot");
            
            // Log database state
            String dbState = captureDbState();
            scenario.log("Database state: " + dbState);
        }
    }
    
    // ✅ GOOD - Order of hook execution
    @Before(order = 1)
    public void setupDatabase() {
        // Runs first
    }
    
    @Before(order = 2)
    public void setupTestData() {
        // Runs second (higher order = later)
    }
    
    // ✅ GOOD - Step-level hooks for debugging
    @BeforeStep
    public void beforeStep(Scenario scenario) {
        scenario.log("Executing step: " + scenario.getName());
    }
    
    @AfterStep
    public void afterStep(Scenario scenario) {
        if (scenario.isFailed()) {
            // Capture state immediately after failing step
            scenario.log("Step failed, capturing context...");
        }
    }
}

// ❌ BAD - Business logic in hooks
@Before
public void setup() {
    // WRONG - this is business behavior, belongs in scenario
    Order order = new Order();
    order.addItem(new OrderItem("Product", Money.usd(100)));
    orderService.save(order);
}

// ✅ GOOD - Technical setup only
@Before
public void setup() {
    // Technical concerns only
    database.truncateAll();
    cache.clear();
    mockServer.reset();
}

// Business setup belongs in Background or Given steps

Example 4: Custom Parameter Types

// Custom parameter types for domain objects

@ParameterType("\\d{4}-\\d{2}-\\d{2}")
public LocalDate date(String dateString) {
    return LocalDate.parse(dateString);
}

@ParameterType("\\$?\\d+\\.\\d{2}")
public Money money(String amount) {
    String cleaned = amount.replace("$", "");
    return Money.usd(new BigDecimal(cleaned));
}

@ParameterType("PENDING|CONFIRMED|SHIPPED|DELIVERED|CANCELLED")
public OrderStatus orderStatus(String status) {
    return OrderStatus.valueOf(status);
}

@ParameterType(".*@.*\\..*")
public Email email(String emailString) {
    return new Email(emailString);
}

// Now use in step definitions with type safety

@Given("the order was placed on {date}")
public void orderPlacedOn(LocalDate date) {
    // Automatic conversion from string to LocalDate
    currentOrder.setOrderDate(date);
}

@When("I set budget to {money}")
public void setBudget(Money amount) {
    // Automatic conversion from string to Money
    currentOrder.setBudget(amount);
}

@Then("order status should be {orderStatus}")
public void orderStatusShouldBe(OrderStatus status) {
    // Automatic conversion from string to OrderStatus enum
    assertThat(currentOrder.getStatus()).isEqualTo(status);
}

@Given("customer email is {email}")
public void customerEmail(Email email) {
    // Automatic validation and conversion
    currentOrder.setCustomerEmail(email);
}

// ✅ Benefits:
// - Type safety
// - Validation in one place
// - Reusable across all steps
// - Clear intent in step definitions

// ❌ BAD - Manual conversion everywhere
@Given("the order was placed on {string}")
public void orderPlacedOn(String dateString) {
    LocalDate date = LocalDate.parse(dateString);  // Repeated everywhere
    currentOrder.setOrderDate(date);
}

Example 5: Spring Integration Patterns

// Spring Boot + Cucumber integration

// 1. Configuration class
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@CucumberContextConfiguration
public class CucumberSpringConfig {
    
    @LocalServerPort
    private int port;
    
    @Bean
    @Scope("cucumber-glue")
    public ScenarioContext scenarioContext() {
        // Scenario-scoped bean, fresh per scenario
        return new ScenarioContext();
    }
    
    @Bean
    public RestTemplate restTemplate() {
        return new RestTemplate();
    }
}

// 2. Step definitions with dependency injection
@Component
public class OrderApiSteps {
    
    @Autowired
    private RestTemplate restTemplate;
    
    @LocalServerPort
    private int port;
    
    @Autowired
    private ScenarioContext scenarioContext;  // Scenario-scoped
    
    @Autowired
    private OrderRepository orderRepository;
    
    @When("I create order via API:")
    public void iCreateOrderViaApi(DataTable orderData) {
        String url = "http://localhost:" + port + "/api/orders";
        
        OrderRequest request = OrderRequest.from(orderData);
        
        ResponseEntity<OrderResponse> response = restTemplate.postForEntity(
            url,
            request,
            OrderResponse.class
        );
        
        // Store in scenario context for later steps
        scenarioContext.setResponse(response);
        scenarioContext.setOrderId(response.getBody().getId());
    }
    
    @Then("the API response status should be {int}")
    public void theApiResponseStatusShouldBe(int expectedStatus) {
        ResponseEntity<?> response = scenarioContext.getResponse();
        assertThat(response.getStatusCodeValue()).isEqualTo(expectedStatus);
    }
}

// 3. Scenario context for sharing state
@Scope("cucumber-glue")
@Component
public class ScenarioContext {
    private Map<String, Object> context = new HashMap<>();
    
    public void set(String key, Object value) {
        context.put(key, value);
    }
    
    public <T> T get(String key, Class<T> type) {
        return type.cast(context.get(key));
    }
    
    public void setOrderId(String orderId) {
        set("orderId", orderId);
    }
    
    public String getOrderId() {
        return get("orderId", String.class);
    }
    
    public void setResponse(ResponseEntity<?> response) {
        set("response", response);
    }
    
    public ResponseEntity<?> getResponse() {
        return get("response", ResponseEntity.class);
    }
}

// ✅ GOOD - Clean separation of concerns
// - Configuration in dedicated class
// - Step definitions as Spring components
// - Scenario context for sharing state
// - Full Spring Boot test environment

// ❌ BAD - Static state sharing
public class OrderSteps {
    private static Order currentOrder;  // WRONG - shared across scenarios!
    
    @When("I create order")
    public void createOrder() {
        currentOrder = new Order();  // Race conditions, test pollution
    }
}

// ✅ GOOD - Instance variables or scenario-scoped beans
@Component
public class OrderSteps {
    private Order currentOrder;  // Instance variable, safe
    
    // Or use scenario-scoped bean
    @Autowired
    private ScenarioContext context;
}

// 4. TestContainers integration
@SpringBootTest
@Testcontainers
@CucumberContextConfiguration
public class CucumberTestConfig {
    
    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15")
        .withDatabaseName("test")
        .withUsername("test")
        .withPassword("test");
    
    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);
    }
}

// ✅ Benefits:
// - Real database for integration tests
// - Consistent test environment
// - No mocking required for repositories

Anti-Patterns

❌ Duplicate Step Definitions

// ❌ BAD - Multiple step definitions for same intent
@Given("I am logged in as admin")
public void loggedInAsAdmin() { }

@Given("I am logged in as administrator")
public void loggedInAsAdministrator() { }

@Given("admin user is logged in")
public void adminUserLoggedIn() { }

// Problem: Ambiguous, hard to maintain, confusing

// ✅ GOOD - Single step definition, standardize scenarios
@Given("I am logged in as {string}")
public void loggedInAs(String role) {
    // One implementation, many uses
}

// Update scenarios to use consistent language

❌ Step Definitions with Business Logic

// ❌ BAD - Business logic in step definition
@When("I apply discount")
public void applyDiscount() {
    BigDecimal total = currentOrder.getTotal();
    BigDecimal discount;
    
    if (total.compareTo(new BigDecimal("100")) > 0) {
        discount = total.multiply(new BigDecimal("0.10"));
    } else {
        discount = total.multiply(new BigDecimal("0.05"));
    }
    
    currentOrder.setDiscount(discount);
    // Complex logic doesn't belong here!
}

// ✅ GOOD - Delegate to service
@When("I apply discount")
public void applyDiscount() {
    discountService.calculateAndApply(currentOrder);
}

// Business logic in service layer where it belongs

❌ Sharing State via Static Variables

// ❌ BAD - Static state causes test pollution
public class OrderSteps {
    private static Order currentOrder;  // Shared across scenarios!
    
    @When("I create order")
    public void createOrder() {
        currentOrder = new Order();
    }
}

// Problems:
// - Scenarios not independent
// - Race conditions in parallel execution
// - Hard to debug

// ✅ GOOD - Instance variables or scenario context
@Component
public class OrderSteps {
    private Order currentOrder;  // Instance variable
    
    // Or
    @Autowired
    private ScenarioContext context;  // Scenario-scoped bean
}

Testing Strategies

Layered Step Definitions

// Organize step definitions by layer

// 1. Domain/Model layer steps
public class DomainSteps {
    @Given("an order totaling {money}")
    public void anOrderTotaling(Money amount) {
        Order order = new Order();
        order.addItem(new OrderItem("Product", amount));
        context.setOrder(order);
    }
}

// 2. Service layer steps
public class ServiceSteps {
    @Autowired
    private OrderService orderService;
    
    @When("I submit the order")
    public void iSubmitTheOrder() {
        Order order = context.getOrder();
        Order saved = orderService.submit(order);
        context.setOrder(saved);
    }
}

// 3. API layer steps
public class ApiSteps {
    @Autowired
    private MockMvc mockMvc;
    
    @When("I submit order via API")
    public void iSubmitOrderViaApi() throws Exception {
        OrderRequest request = OrderRequest.from(context.getOrder());
        
        mockMvc.perform(post("/api/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content(toJson(request)))
            .andExpect(status().isCreated());
    }
}

// Choose appropriate layer based on scenario:
// - Unit-level scenarios → Domain/Service steps
// - Integration scenarios → Service steps with real dependencies
// - E2E scenarios → API steps

Parallel Execution

// Enable parallel execution in Maven

// pom.xml
<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-surefire-plugin</artifactId>
    <configuration>
        <parallel>methods</parallel>
        <threadCount>4</threadCount>
        <perCoreThreadCount>true</perCoreThreadCount>
    </configuration>
</plugin>

// Cucumber options
@CucumberOptions(
    features = "src/test/resources/features",
    plugin = {"json:target/cucumber-parallel/report.json"},
    tags = "not @serial"
)

// Requirements for parallel execution:
// ✅ Scenarios must be independent
// ✅ No shared static state
// ✅ Use @Scope("cucumber-glue") for shared beans
// ✅ Clean database before each scenario
// ✅ Use random ports for API tests

// Tag scenarios that can't run in parallel
@serial
Scenario: Test with global lock requirement

Test Organization

// Organize test runners by suite

// Smoke tests - critical path
@RunWith(Cucumber.class)
@CucumberOptions(
    features = "src/test/resources/features",
    glue = "com.example.steps",
    tags = "@smoke"
)
public class SmokeTestSuite {
}

// Regression tests - full suite
@RunWith(Cucumber.class)
@CucumberOptions(
    features = "src/test/resources/features",
    glue = "com.example.steps",
    tags = "@regression and not @wip"
)
public class RegressionTestSuite {
}

// API tests only
@RunWith(Cucumber.class)
@CucumberOptions(
    features = "src/test/resources/features",
    glue = "com.example.steps",
    tags = "@api"
)
public class ApiTestSuite {
}

Reporting and Documentation

// Generate comprehensive reports

@CucumberOptions(
    plugin = {
        "pretty",                                       // Console
        "html:target/cucumber-reports/cucumber.html",  // HTML
        "json:target/cucumber-reports/cucumber.json",  // JSON
        "junit:target/cucumber-reports/cucumber.xml",  // JUnit XML
        "timeline:target/cucumber-reports/timeline"    // Timeline
    }
)

// Use Cucumber Reports plugin for enhanced reporting
// pom.xml
<plugin>
    <groupId>net.masterthought</groupId>
    <artifactId>maven-cucumber-reporting</artifactId>
    <version>5.7.5</version>
    <executions>
        <execution>
            <phase>verify</phase>
            <goals>
                <goal>generate</goal>
            </goals>
            <configuration>
                <projectName>My Project</projectName>
                <outputDirectory>target/cucumber-html-reports</outputDirectory>
                <inputDirectory>target/cucumber-reports</inputDirectory>
                <jsonFiles>
                    <param>**/*.json</param>
                </jsonFiles>
            </configuration>
        </execution>
    </executions>
</plugin>

// CI/CD integration
// - Publish HTML reports as artifacts
// - Track test trends over time
// - Alert on failing scenarios
// - Use as living documentation

References