cucumberbddcucumber-javacucumber-junitcucumber-spring

Inject service into step definition when programmatically running cucumber tests


Given the code found here I am able to programmatically run cucumber tests.

Inside the step definition class I have commented the constructor injection of DummyService.

If I uncomment, then I receive (which is expected)

o.cucumber.core.exception.CucumberException: class org.example.cucumberseviceinjection.StepDefinitions does not have a public zero-argument constructor.

To use dependency injection add an other ObjectFactory implementation such as:
 * cucumber-picocontainer
 * cucumber-spring
 * cucumber-jakarta-cdi

To fix this I uncomment in the pom.xml next dependency:

        <dependency>
            <groupId>io.cucumber</groupId>
            <artifactId>cucumber-spring</artifactId>
        </dependency>

If I run the application now I receive following exception:

io.cucumber.core.backend.CucumberBackendException: Please annotate a glue class with some context configuration.

For example:

   @CucumberContextConfiguration
   @SpringBootTest(classes = TestConfig.class)
   public class CucumberSpringConfiguration { }
Or: 

   @CucumberContextConfiguration
   @ContextConfiguration( ... )
   public class CucumberSpringConfiguration { }

Why I'm receiving this when I have defined inside the runner class the glue property .configurationParameter("cucumber.glue", "org.example.cucumberseviceinjection") ?

Further, I've added the missing annotation on the step definition class. After this step, I receive:

Caused by: org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'org.example.cucumberseviceinjection.StepDefinitions': Unsatisfied dependency expressed through constructor parameter 0: No qualifying bean of type 'org.example.cucumberseviceinjection.DummyService' available: expected at least 1 bean which qualifies as autowire candidate. Dependency annotations: {}

Based on the documentation I understand that when running as test, because of annotating the config class with @SpringBootTest then the Spring context is created properly and we can inject other services in the step definition class.

How can I obtain the same behaviour (inject services, sharing state) when running test programmatically? (as per my example)


Solution

  • So to get this to work use the following package structure:

    src/main/java/
    └── org
        └── example
            ├── cucumberseviceinjection
            │   ├── CucumberServiceInjectionApplication.java
            │   └── CucumberTestRunner.java
            └── glue
                ├── CucumberTestContext.java
                ├── DummyService.java
                └── StepDefinitions.java
    

    And you have to configure the test context using a separate class. Not on a step definition class. One way to do this would be like this, but you can configure this in on other ways too. Best to read the Spring documentation for that.

    @CucumberContextConfiguration
    @ContextConfiguration
    public class CucumberTestContext {
    
        @Configuration
        @ComponentScan(basePackages = "org.example.glue")
        public static class TestConfiguration {
        }
    }
    
    

    So why does this work?

    In this situation there are a few frameworks stacked on top of each other.

    Spring -> JUnit Platform -> Cucumber JUnit Engine -> Cucumber Core -> Cucumber Spring -> Springs Test Context Manager -> Spring
    

    And Spring is in there twice. Once as the application that runs Cucumber, and once as the dependency injection container for the tests. And that is important to keep in mind.

    When you configure cucumber.glue on the JUnit Platform. This is then passed down to the Cucumber which uses it to look for classes step definitions (i.e. method annotated with @Given, @When, @Then, ect).

    And for each scenario cucumber will instantiate all classes with step definitions. This ensures that each tests runs in isolation - much like say JUnit. Though when using Cucumber Spring, the application context and all non scenario scoped beans are shared between tests - also much like JUnit. So you have be mindful of that.

    To instantiate the step definitions and their dependencies Cucumber Spring is used. Cucumber Spring wraps Springs TestContextManager framework. And the test context manager needs to know how to construct the application context.

    Normally with JUnit you would use this like so:

    @SpringBootTest
    public class ExampleTest {
       ...
    }
    

    And because @SpringBootTest is meta-annotated with

    @BootstrapWith(SpringBootTestContextBootstrapper.class)
    @ExtendWith({SpringExtension.class})
    

    JUnit know that it should use the SpringExtension, which then passes the ExampleTest to the test context manager which sees the BootstrapWith annotation with SpringBootTestContextBootstrapper and then knows that it should look for a @SpringBootApplication annotated class to build the test context.

    But that is JUnit, not Cucumber. Cucumber doesn't have test classes. It has feature files and they can't be annotated. ;)

    So instead we have to mark a class with @CucumberContextConfiguration to let Cucumber Spring know which class to pass to the test context manager so that it can construct the test application context. And Cucumber Spring looks for that class in the packages specified in cucumber.glue.

    So why not use this?

    @CucumberContextConfiguration
    @SpringBootTest
    public class CucumberTestContext {
    

    Because @SpringBootTest will instruct the test context manager to look for a Spring Application and it will find the CucumberServiceInjectionApplication. And probably also because you can't run two Spring Boot applications at the same time in the same JVM without a lot of customization.

    And why the separate packages

    While using this:

    @CucumberContextConfiguration
    @ContextConfiguration
    public class CucumberTestContext {
    
        @Configuration
        @ComponentScan(basePackages = "org.example.glue")
        public static class TestConfiguration {
        }
    }
    

    Cucumber knows to pass the CucumberTestContext to the test context manager. The manager sees that this is a @ContextConfiguration class. And because of that will look for inner classes annotated with @Configuration and pick up on any explicitly declared configurations (i.e. @ContextConfiguration(classes = ExampleConfiguration.class).

    Because we declared an inner class, the @ComponentScan kicks in an we don't want it to scan the packages of the hosting application.

    So how do I use different application contexts completely programmatically?

    Unfortunately this is not possible. The only option is to declare multiple application context configurations, put each in a separate package and run multiple tests with different sets of configuration.

    For example:

    Test A:
    cucumber.glue=org.example.glue.common,org.example.glue.config.a
    Test B:
    cucumber.glue=org.example.glue.common,org.example.glue.config.b