Inheritance in Cucumber Step Definitions - Is It Possible?

Inheritance in Cucumber Step Definitions - Is It Possible?

Are you trying to implement inheritance in Cucumber step definitions? Have you encountered the exception cucumber.runtime.CucumberException: You're not allowed to extend classes that define Step Definitions or hooks? If so, you’re not alone. This is a common challenge faced by testers working with Cucumber, especially in complex projects.

When developers try to use inheritance in classes implementing step definitions, Cucumber throws an error stating that the features have duplicate step definitions. This article explains why this happens and provides a robust solution using composition and dependency injection.

Why Can’t I Inherit from a Step Definition Class?

A step definition class contains the implementation code that tests system behavior in response to actions or state changes. In complex systems with multiple variables affecting the output, we often create multiple feature files with many duplicate steps.

The intuitive approach would be to create a base class with common steps and then extend it in specialized classes. However, Cucumber explicitly prevents this pattern.

The Technical Reason

Cucumber creates a new instance of all classes defining step definitions before each scenario. It then invokes step definition methods on one of those instances whenever it needs to run a step.

If you use inheritance, Cucumber would create instances of both the parent and child classes. When it encounters a step that’s defined in the parent class, Cucumber wouldn’t know which instance to use to execute the method - the parent instance or the child instance? This ambiguity is why Cucumber disallows inheritance for step definition classes.

Real-World Example: Rating Engine

Let’s examine a practical example of a Rating Engine that calculates shipping rates for different types of articles:

Feature File 1: Calculate the rate for fragile goods

Feature: Calculate the rate for fragile goods

Scenario Outline: Calculate the rate of international shipment for fragile goods
  Given Shipment is to the international destination <destination>
  And the type of merchandise is <typeOfMerchandise>
  When calculating the rate of the shipment for fragile goods
  Then the calculated rate is <expectedRate>

Examples:
  | destination | typeOfMerchandise | expectedRate |
  | DXB         | Glass             | 30           |
  | DXB         | Ceramic           | 50           |

Scenario Outline: Calculate the rate of local shipment for fragile goods
  Given Shipment is to the local destination <destination>
  And the type of merchandise is <typeOfMerchandise>
  When calculating the rate of the shipment for fragile goods
  Then the calculated rate is <expectedRate>

Examples:
  | destination | typeOfMerchandise | expectedRate |
  | NY          | Glass             | 10           |
  | LA          | Ceramic           | 15           |

Feature File 2: Calculate the rate for perishable goods

Feature: Calculate the rate for perishable goods

Scenario Outline: Calculate the rate for the international shipment of perishable goods
  Given Shipment is to the international destination <destination>
  And the type of merchandise is <typeOfMerchandise>
  When calculating the rate of the shipment for perishable goods
  Then the calculated rate is <expectedRate>

Examples:
  | destination | typeOfMerchandise | expectedRate |
  | DXB         | Seafood           | 70           |
  | DXB         | Chemicals         | 80           |

Scenario Outline: Calculate the rate for the local shipment of perishable goods
  Given Shipment is to the local destination <destination>
  And the type of merchandise is <typeOfMerchandise>
  When calculating the rate of the shipment for perishable goods
  Then the calculated rate is <expectedRate>

Examples:
  | destination | typeOfMerchandise | expectedRate |
  | NY          | Seafood           | 15           |
  | LA          | Chemicals         | 25           |

In these feature files, steps 1, 2, and 4 are common, while step 3 is unique to each feature file. Naturally, you might want to create:

  1. A base class RateCalculatorBaseStepDef with common steps
  2. Specialized classes FragileGoodsRateCalculatorSteps and PerishableGoodsRateCalculatorSteps that extend the base class

But as we’ve established, this approach won’t work in Cucumber.

The Solution: Composition with Dependency Injection

The idiomatic solution in Java and Cucumber-JVM is to use dependency injection when you want to share state between steps implemented in different classes. Cucumber supports many dependency injection frameworks, including Spring, PicoContainer, Guice, and others.

For simplicity, let’s implement our solution using PicoContainer, a lightweight dependency injection framework that requires minimal configuration.

Step 1: Create a class for common steps

public class RateCalculatorCommonSteps {
    private ShipmentDetails shipmentDetails;
    
    public RateCalculatorCommonSteps() {
        shipmentDetails = new ShipmentDetails();
    }
    
    @Given("Shipment is to the international destination {string}")
    public void internationalShipment(String destination) {
        shipmentDetails.setDestination(destination);
        shipmentDetails.setShipmentType("INTERNATIONAL");
    }
    
    @Given("Shipment is to the local destination {string}")
    public void localShipment(String destination) {
        shipmentDetails.setDestination(destination);
        shipmentDetails.setShipmentType("LOCAL");
    }
    
    @And("the type of merchandise is {string}")
    public void merchandiseType(String type) {
        shipmentDetails.setMerchandiseType(type);
    }
    
    @Then("the calculated rate is {int}")
    public void assertCalculatedRate(int expectedRate) {
        assertEquals(expectedRate, shipmentDetails.getCalculatedRate());
    }
    
    // Getter for shipmentDetails to be used by other step classes
    public ShipmentDetails getShipmentDetails() {
        return shipmentDetails;
    }
}

Step 2: Create specialized step classes that use the common steps

public class FragileGoodsRateCalculatorSteps {
    private final RateCalculatorCommonSteps commonSteps;
    private RateCalculator rateCalculator;
    
    // Constructor injection - PicoContainer will handle this automatically
    public FragileGoodsRateCalculatorSteps(RateCalculatorCommonSteps commonSteps) {
        this.commonSteps = commonSteps;
        this.rateCalculator = new RateCalculator();
    }
    
    @When("calculating the rate of the shipment for fragile goods")
    public void calculateFragileGoods() {
        ShipmentDetails details = commonSteps.getShipmentDetails();
        details.setArticleType("FRAGILE");
        
        // Calculate rate and update the shipment details
        int rate = rateCalculator.calculateRate(details);
        details.setCalculatedRate(rate);
    }
}
public class PerishableGoodsRateCalculatorSteps {
    private final RateCalculatorCommonSteps commonSteps;
    private RateCalculator rateCalculator;
    
    // Constructor injection - PicoContainer will handle this automatically
    public PerishableGoodsRateCalculatorSteps(RateCalculatorCommonSteps commonSteps) {
        this.commonSteps = commonSteps;
        this.rateCalculator = new RateCalculator();
    }
    
    @When("calculating the rate of the shipment for perishable goods")
    public void calculatePerishableGoods() {
        ShipmentDetails details = commonSteps.getShipmentDetails();
        details.setArticleType("PERISHABLE");
        
        // Calculate rate and update the shipment details
        int rate = rateCalculator.calculateRate(details);
        details.setCalculatedRate(rate);
    }
}

Step 3: Add PicoContainer dependency to your project

For Maven:

<dependency>
    <groupId>io.cucumber</groupId>
    <artifactId>cucumber-picocontainer</artifactId>
    <version>7.14.0</version>
    <scope>test</scope>
</dependency>

For Gradle:

testImplementation 'io.cucumber:cucumber-picocontainer:7.14.0'

Benefits of the Composition Approach

This composition-based solution offers several advantages:

  1. Clean Separation of Concerns: Each step definition class focuses on a specific aspect of functionality
  2. Code Reusability: Common steps are defined once and reused across multiple feature files
  3. Maintainability: Changes to common steps only need to be made in one place
  4. Testability: Each component can be tested in isolation
  5. Scalability: Easy to add new specialized step classes as your feature set grows

Alternative Approaches

1. Using a Context Object

Another approach is to use a shared context object that gets passed between step definition classes:

public class TestContext {
    private ShipmentDetails shipmentDetails;
    
    public TestContext() {
        shipmentDetails = new ShipmentDetails();
    }
    
    public ShipmentDetails getShipmentDetails() {
        return shipmentDetails;
    }
}

Then inject this context into your step classes:

public class CommonSteps {
    private final TestContext context;
    
    public CommonSteps(TestContext context) {
        this.context = context;
    }
    
    // Step definitions using context.getShipmentDetails()
}

2. Using Spring Framework

If your project already uses Spring for dependency injection, Cucumber can integrate with it easily. This approach is more suitable for larger projects that already have Spring in their ecosystem.

Add the cucumber-spring dependency:

<dependency>
    <groupId>io.cucumber</groupId>
    <artifactId>cucumber-spring</artifactId>
    <version>7.14.0</version>
    <scope>test</scope>
</dependency>

Then configure your step definition classes with Spring annotations:

@CucumberContextConfiguration
@ContextConfiguration(classes = TestConfig.class)
public class CucumberSpringConfiguration {
}

Common Pitfalls to Avoid

  1. Not Using Dependency Injection: Trying to manually share state between step classes can lead to synchronization issues
  2. Static Variables: Avoid using static variables to share state as this can cause unexpected behaviour in parallel test execution
  3. Directly Calling Methods: Avoid calling step definition methods directly from other step definition methods

Conclusion

While inheritance might seem like the natural way to reuse step definitions in Cucumber, it’s not supported by the framework’s architecture. Instead, using composition with dependency injection provides a more robust and maintainable solution.

By following the pattern outlined in this article, you can effectively share common steps across multiple feature files while maintaining clean separation of concerns. This approach scales well as your test suite grows and helps keep your automation code maintainable over time.

For complex projects, consider evaluating different dependency injection frameworks based on your specific needs and existing technology stack. Whether you choose lightweight options like PicoContainer or more comprehensive solutions like Spring, the composition-based approach will serve you well.