
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:
- A base class
RateCalculatorBaseStepDef
with common steps - Specialized classes
FragileGoodsRateCalculatorSteps
andPerishableGoodsRateCalculatorSteps
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:
- Clean Separation of Concerns: Each step definition class focuses on a specific aspect of functionality
- Code Reusability: Common steps are defined once and reused across multiple feature files
- Maintainability: Changes to common steps only need to be made in one place
- Testability: Each component can be tested in isolation
- 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
- Not Using Dependency Injection: Trying to manually share state between step classes can lead to synchronization issues
- Static Variables: Avoid using static variables to share state as this can cause unexpected behaviour in parallel test execution
- 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.