Skip to content

Commit 8b8ca6f

Browse files
authored
[Core] Include DefaultObjectFactory as part of the API (#2400)
Cucumber can be used with different ObjectFactory implementations. When multiple implementations are available the `cucumber.object-factory` property allows Cucumber to disambiguate between the preferred choice. Because the `DefaultObjectFactory` was not actually loaded via SPI it was not possible to explicitly select the default implementation. By using `cucumber.object-factory=io.cucumber.core.backend.DefaultObjectFactory` users can now select the `DefaultObjectFactory` implementation. This may benefit users who use Cucumber in both unit and integration tests. Some object factory implementations such as Cucumber Spring are slow to startup using the `DefaultObjectFactory` would allow the suite of unit tests to be executed faster.
1 parent 01b10c5 commit 8b8ca6f

File tree

9 files changed

+312
-126
lines changed

9 files changed

+312
-126
lines changed

core/README.md

+4-4
Original file line numberDiff line numberDiff line change
@@ -74,10 +74,10 @@ Each property also has an `UPPER_CASE` and `snake_case` variant. For example
7474

7575
## Backend ##
7676

77-
Backends consist of two components: a `Backend`, and an `ObjectFactory`. They are
78-
respectively responsible for discovering glue classes, registering step definitions,
79-
and creating instances of said glue classes. Backend and object factory
80-
implementations are discovered via SPI.
77+
Backends consist of two components: a `Backend`, and an optional `ObjectFactory`.
78+
They are respectively responsible for discovering glue classes, registering
79+
step definitions, and creating instances of said glue classes. Backend and
80+
object factory implementations are discovered via SPI.
8181

8282
## Plugin ##
8383

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package io.cucumber.core.backend;
2+
3+
import io.cucumber.core.exception.CucumberException;
4+
import org.apiguardian.api.API;
5+
6+
import java.lang.reflect.Constructor;
7+
import java.util.HashMap;
8+
import java.util.Map;
9+
10+
/**
11+
* Default factory to instantiate glue classes. Loaded via SPI.
12+
* <p>
13+
* This object factory instantiates glue classes by using their public
14+
* no-argument constructor. As such it does not provide any dependency
15+
* injection.
16+
* <p>
17+
* Note: This class is intentionally an explicit part of the public api. It
18+
* allows the default object factory to be used even when another object factory
19+
* implementation is present through the
20+
* {@value io.cucumber.core.options.Constants#OBJECT_FACTORY_PROPERTY_NAME}
21+
* property or equivalent configuration options.
22+
*
23+
* @see ObjectFactory
24+
*/
25+
@API(status = API.Status.STABLE, since = "7.1.0")
26+
public final class DefaultObjectFactory implements ObjectFactory {
27+
28+
private final Map<Class<?>, Object> instances = new HashMap<>();
29+
30+
public void start() {
31+
// No-op
32+
}
33+
34+
public void stop() {
35+
instances.clear();
36+
}
37+
38+
public boolean addClass(Class<?> clazz) {
39+
return true;
40+
}
41+
42+
public <T> T getInstance(Class<T> type) {
43+
T instance = type.cast(instances.get(type));
44+
if (instance == null) {
45+
instance = cacheNewInstance(type);
46+
}
47+
return instance;
48+
}
49+
50+
private <T> T cacheNewInstance(Class<T> type) {
51+
try {
52+
Constructor<T> constructor = type.getConstructor();
53+
T instance = constructor.newInstance();
54+
instances.put(type, instance);
55+
return instance;
56+
} catch (NoSuchMethodException e) {
57+
throw new CucumberException(String.format("" +
58+
"%s does not have a public zero-argument constructor.\n" +
59+
"\n" +
60+
"To use dependency injection add an other ObjectFactory implementation such as:\n" +
61+
" * cucumber-picocontainer\n" +
62+
" * cucumber-spring\n" +
63+
" * cucumber-jakarta-cdi\n" +
64+
" * ...ect\n",
65+
type), e);
66+
} catch (Exception e) {
67+
throw new CucumberException(String.format("Failed to instantiate %s", type), e);
68+
}
69+
}
70+
71+
}

core/src/main/java/io/cucumber/core/backend/ObjectFactory.java

+2-4
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,8 @@
77
* <p>
88
* Cucumber scenarios are executed against a test context that consists of
99
* multiple glue classes. These must be instantiated and may optionally be
10-
* injected with dependencies.
11-
* <p>
12-
* When multiple {@code ObjectFactory} implementations are available Cucumber
13-
* will look for a preference in the provided properties or options.
10+
* injected with dependencies. The object factory facilitates the creation of
11+
* both the glue classes and dependencies.
1412
*
1513
* @see java.util.ServiceLoader
1614
* @see io.cucumber.core.runtime.ObjectFactoryServiceLoader
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,30 @@
11
package io.cucumber.core.runtime;
22

3+
import io.cucumber.core.backend.DefaultObjectFactory;
34
import io.cucumber.core.backend.ObjectFactory;
45
import io.cucumber.core.backend.Options;
56
import io.cucumber.core.exception.CucumberException;
67

7-
import java.lang.reflect.Constructor;
8-
import java.util.HashMap;
98
import java.util.Iterator;
10-
import java.util.Map;
119
import java.util.ServiceLoader;
1210
import java.util.function.Supplier;
1311
import java.util.stream.Collectors;
1412
import java.util.stream.Stream;
1513

1614
import static java.util.Objects.requireNonNull;
1715

16+
/**
17+
* Loads an instance of {@link ObjectFactory} using the {@link ServiceLoader}
18+
* mechanism.
19+
* <p>
20+
* Will load an instance of the class provided by
21+
* {@link Options#getObjectFactoryClass()}. If
22+
* {@link Options#getObjectFactoryClass()} does not provide a class and there is
23+
* exactly one {@code ObjectFactory} instance available that instance will be
24+
* used.
25+
* <p>
26+
* Otherwise {@link DefaultObjectFactory} with no dependency injection
27+
*/
1828
public final class ObjectFactoryServiceLoader {
1929

2030
private final Supplier<ClassLoader> classLoaderSupplier;
@@ -25,28 +35,12 @@ public ObjectFactoryServiceLoader(Supplier<ClassLoader> classLoaderSupplier, Opt
2535
this.options = requireNonNull(options);
2636
}
2737

28-
/**
29-
* Loads an instance of {@link ObjectFactory} using the
30-
* {@link ServiceLoader} mechanism.
31-
* <p>
32-
* Will load an instance of the class provided by
33-
* {@link Options#getObjectFactoryClass()}. If
34-
* {@link Options#getObjectFactoryClass()} does not provide a class and
35-
* there is exactly one {@code ObjectFactory} instance available that
36-
* instance will be used.
37-
* <p>
38-
* Otherwise {@link DefaultJavaObjectFactory} with no dependency injection
39-
* capabilities will be used.
40-
*
41-
* @return an instance of {@link ObjectFactory}
42-
*/
4338
ObjectFactory loadObjectFactory() {
4439
Class<? extends ObjectFactory> objectFactoryClass = options.getObjectFactoryClass();
4540
ClassLoader classLoader = classLoaderSupplier.get();
4641
ServiceLoader<ObjectFactory> loader = ServiceLoader.load(ObjectFactory.class, classLoader);
4742
if (objectFactoryClass == null) {
4843
return loadSingleObjectFactoryOrDefault(loader);
49-
5044
}
5145

5246
return loadSelectedObjectFactory(loader, objectFactoryClass);
@@ -55,17 +49,34 @@ ObjectFactory loadObjectFactory() {
5549
private static ObjectFactory loadSingleObjectFactoryOrDefault(ServiceLoader<ObjectFactory> loader) {
5650
Iterator<ObjectFactory> objectFactories = loader.iterator();
5751

58-
ObjectFactory objectFactory;
59-
if (objectFactories.hasNext()) {
52+
// Find the first non-default object factory,
53+
// or the default as a side effect.
54+
ObjectFactory objectFactory = null;
55+
while (objectFactories.hasNext()) {
6056
objectFactory = objectFactories.next();
61-
} else {
62-
objectFactory = new DefaultJavaObjectFactory();
57+
if (!(objectFactory instanceof DefaultObjectFactory)) {
58+
break;
59+
}
60+
}
61+
62+
if (objectFactory == null) {
63+
throw new CucumberException("" +
64+
"Could not find any object factory.\n" +
65+
"\n" +
66+
"Cucumber uses SPI to discover object factory implementations.\n" +
67+
"This typically happens when using shaded jars. Make sure\n" +
68+
"to merge all SPI definitions in META-INF/services correctly");
6369
}
6470

65-
if (objectFactories.hasNext()) {
71+
// Check if there are no other non-default object factories
72+
while (objectFactories.hasNext()) {
6673
ObjectFactory extraObjectFactory = objectFactories.next();
74+
if (extraObjectFactory instanceof DefaultObjectFactory) {
75+
continue;
76+
}
6777
throw new CucumberException(getMultipleObjectFactoryLogMessage(objectFactory, extraObjectFactory));
6878
}
79+
6980
return objectFactory;
7081
}
7182

@@ -80,8 +91,10 @@ private static ObjectFactory loadSelectedObjectFactory(
8091

8192
throw new CucumberException("" +
8293
"Could not find object factory " + objectFactoryClass.getName() + ".\n" +
94+
"\n" +
8395
"Cucumber uses SPI to discover object factory implementations.\n" +
84-
"Has the class been registered with SPI and is it available on the classpath?");
96+
"Has the class been registered with SPI and is it available on\n" +
97+
"the classpath?");
8598
}
8699

87100
private static String getMultipleObjectFactoryLogMessage(ObjectFactory... objectFactories) {
@@ -90,60 +103,15 @@ private static String getMultipleObjectFactoryLogMessage(ObjectFactory... object
90103
.map(Class::getName)
91104
.collect(Collectors.joining(", "));
92105

93-
return "More than one Cucumber ObjectFactory was found in the classpath\n" +
106+
return "More than one Cucumber ObjectFactory was found on the classpath\n" +
94107
"\n" +
95108
"Found: " + factoryNames + "\n" +
96109
"\n" +
97-
"You may have included, for instance, cucumber-spring AND cucumber-guice as part of\n" +
98-
"your dependencies. When this happens, Cucumber can't decide which to use.\n" +
99-
"In order to enjoy dependency injection features, either remove the unnecessary dependencies" +
100-
"from your classpath or use the `cucumber.object-factory` property or `@CucumberOptions(objectFactory=...)` to select one.\n";
101-
}
102-
103-
/**
104-
* Creates glue instances. Does not provide Dependency Injection.
105-
* <p>
106-
* All glue classes must have a public no-argument constructor.
107-
*/
108-
static class DefaultJavaObjectFactory implements ObjectFactory {
109-
110-
private final Map<Class<?>, Object> instances = new HashMap<>();
111-
112-
public void start() {
113-
// No-op
114-
}
115-
116-
public void stop() {
117-
instances.clear();
118-
}
119-
120-
public boolean addClass(Class<?> clazz) {
121-
return true;
122-
}
123-
124-
public <T> T getInstance(Class<T> type) {
125-
T instance = type.cast(instances.get(type));
126-
if (instance == null) {
127-
instance = cacheNewInstance(type);
128-
}
129-
return instance;
130-
}
131-
132-
private <T> T cacheNewInstance(Class<T> type) {
133-
try {
134-
Constructor<T> constructor = type.getConstructor();
135-
T instance = constructor.newInstance();
136-
instances.put(type, instance);
137-
return instance;
138-
} catch (NoSuchMethodException e) {
139-
throw new CucumberException(String.format(
140-
"%s doesn't have an empty constructor. If you need dependency injection, put cucumber-picocontainer on the classpath",
141-
type), e);
142-
} catch (Exception e) {
143-
throw new CucumberException(String.format("Failed to instantiate %s", type), e);
144-
}
145-
}
146-
110+
"You may have included, for instance, cucumber-spring AND cucumber-guice as part\n" +
111+
"of your dependencies. When this happens, Cucumber can't decide which to use.\n" +
112+
"In order to enjoy dependency injection features, either remove the unnecessary\n" +
113+
"dependencies from your classpath or use the `cucumber.object-factory` property\n" +
114+
"or `@CucumberOptions(objectFactory=...)` to select one.\n";
147115
}
148116

149117
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
io.cucumber.core.backend.DefaultObjectFactory
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package io.cucumber.core.backend;
2+
3+
import io.cucumber.core.exception.CucumberException;
4+
import org.junit.jupiter.api.Test;
5+
6+
import static org.hamcrest.MatcherAssert.assertThat;
7+
import static org.hamcrest.core.Is.is;
8+
import static org.hamcrest.core.IsEqual.equalTo;
9+
import static org.hamcrest.core.IsNot.not;
10+
import static org.hamcrest.core.IsNull.notNullValue;
11+
import static org.junit.jupiter.api.Assertions.assertAll;
12+
import static org.junit.jupiter.api.Assertions.assertThrows;
13+
14+
class DefaultObjectFactoryTest {
15+
final ObjectFactory factory = new DefaultObjectFactory();
16+
17+
@Test
18+
void shouldGiveUsNewInstancesForEachScenario() {
19+
factory.addClass(StepDefinition.class);
20+
21+
// Scenario 1
22+
factory.start();
23+
StepDefinition o1 = factory.getInstance(StepDefinition.class);
24+
factory.stop();
25+
26+
// Scenario 2
27+
factory.start();
28+
StepDefinition o2 = factory.getInstance(StepDefinition.class);
29+
factory.stop();
30+
31+
assertAll(
32+
() -> assertThat(o1, is(notNullValue())),
33+
() -> assertThat(o1, is(not(equalTo(o2)))),
34+
() -> assertThat(o2, is(not(equalTo(o1)))));
35+
}
36+
37+
@Test
38+
void shouldThrowForNonZeroArgPublicConstructors() {
39+
CucumberException exception = assertThrows(CucumberException.class,
40+
() -> factory.getInstance(NoAccessibleConstructor.class));
41+
42+
assertThat(exception.getMessage(), is("" +
43+
"class io.cucumber.core.backend.DefaultObjectFactoryTest$NoAccessibleConstructor does not have a public zero-argument constructor.\n"
44+
+
45+
"\n" +
46+
"To use dependency injection add an other ObjectFactory implementation such as:\n" +
47+
" * cucumber-picocontainer\n" +
48+
" * cucumber-spring\n" +
49+
" * cucumber-jakarta-cdi\n" +
50+
" * ...ect\n"));
51+
}
52+
53+
public static class StepDefinition {
54+
// we just test the instances
55+
}
56+
57+
public static class NoAccessibleConstructor {
58+
private NoAccessibleConstructor() {
59+
60+
}
61+
62+
}
63+
64+
}

core/src/test/java/io/cucumber/core/runtime/JavaObjectFactoryTest.java

-41
This file was deleted.

0 commit comments

Comments
 (0)