Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix the glue class autowiring, transaction and cucumber-glue scope issues of the spring module #711

Merged
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@
import org.junit.runner.RunWith;

@RunWith(Cucumber.class)
@CucumberOptions(glue = {"cucumber.examples.spring.txn", "cucumber.runtime.java.spring.hooks"})
@CucumberOptions(glue = {"cucumber.examples.spring.txn", "cucumber.api.spring"})
public class RunCukesTest {
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.web.WebAppConfiguration;
import org.springframework.transaction.support.TransactionSynchronizationManager;

import static org.junit.Assert.assertTrue;

import java.util.List;

Expand Down Expand Up @@ -31,5 +34,7 @@ public void a_User_has_posted_the_following_messages(List<Message> messages) thr
m.setAuthor(user);
messageRepository.save(m);
}
assertTrue("No transaction is active",
TransactionSynchronizationManager.isActualTransactionActive());
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
@txn
Feature: See Messages

Scenario: See another user's messages
Expand Down
212 changes: 112 additions & 100 deletions spring/src/main/java/cucumber/runtime/java/spring/SpringFactory.java
Original file line number Diff line number Diff line change
@@ -1,34 +1,37 @@
package cucumber.runtime.java.spring;

import java.io.PrintWriter;
import java.io.StringWriter;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;

import cucumber.runtime.CucumberException;
import cucumber.runtime.java.ObjectFactory;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.config.BeanDefinition;
import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
import org.springframework.beans.factory.support.BeanDefinitionBuilder;
import org.springframework.beans.factory.support.BeanDefinitionRegistry;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.context.support.GenericXmlApplicationContext;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.ContextHierarchy;
import org.springframework.test.context.TestContextManager;

import cucumber.runtime.CucumberException;
import cucumber.runtime.java.ObjectFactory;
import java.lang.annotation.Annotation;
import java.util.Collection;
import java.util.HashSet;

/**
* Spring based implementation of ObjectFactory.
* <p/>
* <p>
* <ul>
* <li>It uses TestContextManager to create and prepare test instances. Configuration via: @ContextConfiguration
* <li>It uses TestContextManager to create and prepare test instances.
* Configuration via: @ContextConfiguration of @ContextHierarcy
* At least on step definition class needs to have a @ContextConfiguration or
* @ContextHierarchy annotation. If more that one step definition class has such
* an annotation, the annotations must be equal on the different step definition
* classes.</li>
* <li>The step definitions class with @ContextConfiguration or @ContextHierarchy
* annotation, may also have a @WebAppConfiguration or @DirtiesContext annotation.
* </li>
* <li>It also uses a context which contains the step definitions and is reloaded for each
* scenario.</li>
* <li>The step definitions added to the TestContextManagers context and
* is reloaded for each scenario.</li>
* </ul>
* </p>
* <p/>
Expand All @@ -39,137 +42,146 @@
*/
public class SpringFactory implements ObjectFactory {

private static ConfigurableApplicationContext applicationContext;
private static ConfigurableListableBeanFactory beanFactory;
private ConfigurableListableBeanFactory beanFactory;
private CucumberTestContextManager testContextManager;

private final Collection<Class<?>> stepClasses = new HashSet<Class<?>>();
private final Map<Class<?>, TestContextManager> contextManagersByClass = new HashMap<Class<?>, TestContextManager>();
private Class<?> stepClassWithSpringContext = null;

public SpringFactory() {
}

static {
applicationContext = new GenericXmlApplicationContext("cucumber/runtime/java/spring/cucumber-glue.xml");
applicationContext.registerShutdownHook();
beanFactory = applicationContext.getBeanFactory();
}

@Override
public void addClass(final Class<?> stepClass) {
if (!stepClasses.contains(stepClass)) {
if (dependsOnSpringContext(stepClass)) {
if (stepClassWithSpringContext == null) {
stepClassWithSpringContext = stepClass;
} else {
checkAnnotationsEqual(stepClassWithSpringContext, stepClass);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we want to fail if glue classes with different Spring annotations are found?

In real life we often see Spring-y tests that define slightly different contexts and are more or less independent from each other. AFAIU when similar independent step definitions will be loaded by the same runtime (e.g. one JUnit runner class), we'll throw an error in this PR. I'm afraid real users might find that counter-intuitive and inconvenient.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only one Spring test is loaded during each run, but more than one Cucumber step definition is loaded for each scenario. I agree with @brasmusson we should allow only one Spring context. @mgurov while I see advantages in injecting a specific Spring context in JUnit tests, I'd like to see an example of where this is needed in Cucumber. Supporting multiple context is controversial enough to deserve a separate entry in the issue tracker and a deeper discussion.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also in Cucumber-JvM v1.1.3 only one spring context was allowed, then it was hardcoded that the file cucumber.xml on the classpath defined the spring context.
From an implementation point of view, probable the biggest problem with allowing multiple context is to find out in which the SpringTransactionHooks need to be in to find the bean implementing the PlatformTransactionManager it needs to do its job.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@paoloambrosio in general I agree that less contexts is better but IMHO it is not the mission of cucumber-jvm to force one or another spring practice.

I'm a bit concerned about the proliferation of the RunCukesTest.javas that we see even in this change, but if you guys do not see an issue with that then fine with me.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mgurov I just do not see how it should work in detail. In which of the several context should the SpringTransactionHooks class be put (so it finds the PlatformTransactionManager it needs)? Or should it be forbidden to have more than one context if you use transactions?
And what about glue classes without context annotations, in which context should they be put? Or should we require context annotations on all glue classes (that use spring)? I have got the impression that the requirement that every glue class (that use spring) must have an context annotation is one of the problems the v1.1.6.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@brasmusson yes, that can be tricky with the TestContextManager. One of the options could be to (partially) reimplement it for our needs and build the context (or hierarchy of contexts) ourself. But maybe the current solution would be good enough as long as heavy spring module users do not complain.

}
}
stepClasses.add(stepClass);

BeanDefinitionRegistry registry = (BeanDefinitionRegistry) applicationContext.getAutowireCapableBeanFactory();
BeanDefinition beanDefinition = BeanDefinitionBuilder
.genericBeanDefinition(stepClass)
.setScope(GlueCodeScope.NAME)
.getBeanDefinition();
registry.registerBeanDefinition(stepClass.getName(), beanDefinition);
}
}

@Override
public void start() {
GlueCodeContext.INSTANCE.start();
}

@Override
public void stop() {
notifyContextManagersAboutTestClassFinished();

GlueCodeContext.INSTANCE.stop();
beanFactory.destroySingletons();
private void checkAnnotationsEqual(Class<?> stepClassWithSpringContext, Class<?> stepClass) {
Annotation[] annotations1 = stepClassWithSpringContext.getAnnotations();
Annotation[] annotations2 = stepClass.getAnnotations();
if (annotations1.length != annotations2.length) {
throw new CucumberException("Annotations differs on glue classes found: " +
stepClassWithSpringContext.getName() + ", " +
stepClass.getName());
}
for (Annotation annotation : annotations1) {
if (!isAnnotationInArray(annotation, annotations2)) {
throw new CucumberException("Annotations differs on glue classes found: " +
stepClassWithSpringContext.getName() + ", " +
stepClass.getName());
}
}
}

private void notifyContextManagersAboutTestClassFinished() {
Map<Class<?>, Exception> exceptionsThrown = new HashMap<Class<?>, Exception>();

for (Map.Entry<Class<?>, TestContextManager> classTestContextManagerEntry : contextManagersByClass
.entrySet()) {
try {
classTestContextManagerEntry.getValue().afterTestClass();
} catch (Exception e) {
exceptionsThrown.put(classTestContextManagerEntry.getKey(), e);
private boolean isAnnotationInArray(Annotation annotation, Annotation[] annotations) {
for (Annotation annotationFromArray: annotations) {
if (annotation.equals(annotationFromArray)) {
return true;
}
}

contextManagersByClass.clear();

rethrowExceptionsIfAny(exceptionsThrown);
return false;
}

private void rethrowExceptionsIfAny(Map<Class<?>, Exception> exceptionsThrown) {
if (exceptionsThrown.isEmpty()) {
return;
@Override
public void start() {
if (stepClassWithSpringContext == null) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What are we protecting here against?

It is logical to expect that there will be some annotated glue classes when we use SpringFactory, but imagine there are some scenarios that do not depend on Spring contexts and we have SpringFactory in our class path - would not it be better for such scenarios to execute peacefully even if their glue classes are not annotated, and when they are called individually?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When not requiring every glue class that uses spring to have an @ContextConfiguration/@ContextHierarchy annotation, I thought it would be a bit easy to end up having none, and this exception is better than the NullPointerException that will occur on the next line otherwise.
As long as any glue class on the glue paths has a Spring annotation this will work, even if there are some scenarios that do not depend on Spring contexts (glue classes are added for the whole cucumber execution, but the only the glue classes needed are instantiated for each Scenario).
As in Cucumber-JVM v1.1.3 all glue classes are instantiated using an ApplicationContext/BeanFactory, regardless of they actually need it or not.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

created a branch to illustrate the problem: mgurov@effb7d4

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is fixed in the latest commit. Now, if no @ContextConfiguration/@ContextHierarchy is found, the SpringFactory will check if cucumber.xml exist on the classpath, and if so use that, and if cucumber.xml does not exist on the classpath, it will create a generic context with only the glue classes in, so it will handle a "plain old java glue".

throw new CucumberException("No glue class with @ContextConfiguration or " +
"@ContextHierarcy annotation found in: " + stepClasses.toString());
}
testContextManager = new CucumberTestContextManager(stepClassWithSpringContext);
notifyContextManagerAboutTestClassStarted();
if (isFirstScenario() || isNewContextCreated()) {
beanFactory = testContextManager.getBeanFactory();
for (Class<?> stepClass : stepClasses) {
registerStepClassBeanDefinition(stepClass);
}
}
GlueCodeContext.INSTANCE.start();
}

if (exceptionsThrown.size() == 1) {
//ony one exception, throw an exception with the correct cause
Exception e = exceptionsThrown.values().iterator().next();
private void notifyContextManagerAboutTestClassStarted() {
try {
testContextManager.beforeTestClass();
} catch (Exception e) {
throw new CucumberException(e.getMessage(), e);
}
}

//multiple exceptions but we can only have one cause, put relevant info in the exception message
//to not lose the interesting data
throw new CucumberException(getMultipleExceptionMessage(exceptionsThrown));
private boolean isFirstScenario() {
return beanFactory == null;
}

private String getMultipleExceptionMessage(Map<Class<?>, Exception> exceptionsThrow) {
StringBuilder exceptionsThrown = new StringBuilder(1000);
exceptionsThrown.append("Multiple exceptions occurred during processing of the TestExecutionListeners\n\n");
private boolean isNewContextCreated() {
return !beanFactory.equals(testContextManager.getBeanFactory());
}

for (Map.Entry<Class<?>, Exception> classExceptionEntry : exceptionsThrow.entrySet()) {
exceptionsThrown.append("Exception during processing of TestExecutionListeners of ");
exceptionsThrown.append(classExceptionEntry.getKey());
exceptionsThrown.append('\n');
exceptionsThrown.append(classExceptionEntry.getValue().toString());
exceptionsThrown.append('\n');
private void registerStepClassBeanDefinition(Class<?> stepClass) {
BeanDefinitionRegistry registry = (BeanDefinitionRegistry) beanFactory;
BeanDefinition beanDefinition = BeanDefinitionBuilder
.genericBeanDefinition(stepClass)
.setScope(GlueCodeScope.NAME)
.getBeanDefinition();
registry.registerBeanDefinition(stepClass.getName(), beanDefinition);
}

StringWriter stackTraceStringWriter = new StringWriter();
PrintWriter stackTracePrintWriter = new PrintWriter(stackTraceStringWriter);
classExceptionEntry.getValue().printStackTrace(stackTracePrintWriter);
exceptionsThrown.append(stackTraceStringWriter.toString());
exceptionsThrown.append('\n');
@Override
public void stop() {
notifyContextManagerAboutTestClassFinished();
GlueCodeContext.INSTANCE.stop();
}

private void notifyContextManagerAboutTestClassFinished() {
try {
testContextManager.afterTestClass();
} catch (Exception e) {
throw new CucumberException(e.getMessage(), e);
}

return exceptionsThrown.toString();
}

@Override
public <T> T getInstance(final Class<T> type) {
if (!beanFactory.containsSingleton(type.getName())) {
beanFactory.registerSingleton(type.getName(), getTestInstance(type));
try {
return beanFactory.getBean(type);
} catch (BeansException e) {
throw new CucumberException(e.getMessage(), e);
}

return applicationContext.getBean(type);
}

private <T> T getTestInstance(final Class<T> type) {
try {
T instance = createTest(type);
private boolean dependsOnSpringContext(Class<?> type) {
return type.isAnnotationPresent(ContextConfiguration.class)
|| type.isAnnotationPresent(ContextHierarchy.class);
}
}

if (dependsOnSpringContext(type)) {
TestContextManager contextManager = new TestContextManager(type);
contextManager.prepareTestInstance(instance);
contextManager.beforeTestClass();
class CucumberTestContextManager extends TestContextManager {

contextManagersByClass.put(type, contextManager);
}
public CucumberTestContextManager(Class<?> testClass) {
super(testClass);
registerGlueCodeScope(getContext());
}

return instance;
} catch (Exception e) {
throw new CucumberException(e.getMessage(), e);
}
public ConfigurableListableBeanFactory getBeanFactory() {
return getContext().getBeanFactory();
}

@SuppressWarnings("unchecked")
protected <T> T createTest(Class<T> type) throws Exception {
return (T) type.getConstructors()[0].newInstance();
private ConfigurableApplicationContext getContext() {
return (ConfigurableApplicationContext)getTestContext().getApplicationContext();
}

private boolean dependsOnSpringContext(Class<?> type) {
return type.isAnnotationPresent(ContextConfiguration.class)
|| type.isAnnotationPresent(ContextHierarchy.class);
private void registerGlueCodeScope(ConfigurableApplicationContext context) {
do {
context.getBeanFactory().registerScope(GlueCodeScope.NAME, new GlueCodeScope());
context = (ConfigurableApplicationContext)context.getParent();
} while (context != null);
}
}

This file was deleted.

This file was deleted.

This file was deleted.

Loading