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)
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