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

[Java] Define step definitions and hooks with minimal ceremony #2415

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all 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
4 changes: 2 additions & 2 deletions core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@ cucumber.filter.tags= # a cucumber tag expression.
# only scenarios with matching tags are executed.
# example: @Cucumber and not (@Gherkin or @Zucchini)

cucumber.glue= # comma separated package names.
# example: com.example.glue
cucumber.glue= # comma separated package or class names.
# example: com.example.glue,com.example.features.SomeFeature

cucumber.plugin= # comma separated plugin strings.
# example: pretty, json:path/to/report.json
Expand Down
5 changes: 3 additions & 2 deletions core/src/main/java/io/cucumber/core/options/Constants.java
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,9 @@ public final class Constants {
/**
* Property name to set the glue path: {@value}
* <p>
* A comma separated list of a classpath uri or package name e.g.:
* {@code com.example.app.steps}.
* A comma separated list of a classpath uri or a package or a class name
* e.g.:
* {@code com.example.app.steps,com.example.app.features.SomeFeatureSteps}.
*
* @see io.cucumber.core.feature.GluePath
*/
Expand Down
24 changes: 21 additions & 3 deletions core/src/main/java/io/cucumber/core/resource/ClasspathScanner.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import java.net.URI;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
Expand Down Expand Up @@ -38,6 +39,15 @@ public ClasspathScanner(Supplier<ClassLoader> classLoaderSupplier) {
this.classLoaderSupplier = classLoaderSupplier;
}

public <T> List<Class<? extends T>> scanForSubClasses(String packageOrClassName, Class<T> parentClass) {
Optional<Class<?>> classFromName = safelyLoadClass(packageOrClassName, false);

return classFromName.isPresent() && !parentClass.equals(classFromName.get())
&& parentClass.isAssignableFrom(classFromName.get())
? Arrays.asList((Class<? extends T>) classFromName.get())
: scanForSubClassesInPackage(packageOrClassName, parentClass);
}

public <T> List<Class<? extends T>> scanForSubClassesInPackage(String packageName, Class<T> parentClass) {
return scanForClassesInPackage(packageName, isSubClassOf(parentClass))
.stream()
Expand Down Expand Up @@ -96,17 +106,19 @@ private Function<Path, Consumer<Path>> processClassFiles(
) {
return baseDir -> classFile -> {
String fqn = determineFullyQualifiedClassName(baseDir, basePackageName, classFile);
safelyLoadClass(fqn)
safelyLoadClass(fqn, true)
.filter(classFilter)
.ifPresent(classConsumer);
};
}

private Optional<Class<?>> safelyLoadClass(String fqn) {
private Optional<Class<?>> safelyLoadClass(String fqn, boolean logWarning) {
try {
return Optional.ofNullable(getClassLoader().loadClass(fqn));
} catch (ClassNotFoundException | NoClassDefFoundError e) {
log.warn(e, () -> "Failed to load class '" + fqn + "'.\n" + classPathScanningExplanation());
if (logWarning) {
log.warn(e, () -> "Failed to load class '" + fqn + "'.\n" + classPathScanningExplanation());
}
}
return Optional.empty();
}
Expand All @@ -115,4 +127,10 @@ public List<Class<?>> scanForClassesInPackage(String packageName) {
return scanForClassesInPackage(packageName, NULL_FILTER);
}

public List<Class<?>> getClasses(String packageOrClassName) {
Optional<Class<?>> classFromName = safelyLoadClass(packageOrClassName, false);
return classFromName.isPresent() ? Arrays.asList(classFromName.get())
: scanForClassesInPackage(packageOrClassName, NULL_FILTER);
}

}
28 changes: 28 additions & 0 deletions core/src/test/java/io/cucumber/core/feature/GluePathTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,15 @@ void can_parse_absolute_path_form() {
() -> assertThat(uri.getSchemeSpecificPart(), is("/com/example/app")));
}

@Test
void can_parse_absolute_path_form_class() {
URI uri = GluePath.parse("/com/example/app/Steps");

assertAll(
() -> assertThat(uri.getScheme(), is("classpath")),
() -> assertThat(uri.getSchemeSpecificPart(), is("/com/example/app/Steps")));
}

@Test
void can_parse_package_form() {
URI uri = GluePath.parse("com.example.app");
Expand All @@ -79,6 +88,15 @@ void can_parse_package_form() {
() -> assertThat(uri.getSchemeSpecificPart(), is("/com/example/app")));
}

@Test
void can_parse_package_form_class() {
URI uri = GluePath.parse("com.example.app.Steps");

assertAll(
() -> assertThat(uri.getScheme(), is("classpath")),
() -> assertThat(uri.getSchemeSpecificPart(), is("/com/example/app/Steps")));
}

@Test
void glue_path_must_have_class_path_scheme() {
Executable testMethod = () -> GluePath.parse("file:com/example/app");
Expand All @@ -105,6 +123,16 @@ void can_parse_windows_path_form() {
() -> assertThat(uri.getSchemeSpecificPart(), is(equalTo("/com/example/app"))));
}

@Test
@EnabledOnOs(OS.WINDOWS)
void can_parse_windows_path_form_class() {
URI uri = GluePath.parse("com\\example\\app\\Steps");

assertAll(
() -> assertThat(uri.getScheme(), is("classpath")),
() -> assertThat(uri.getSchemeSpecificPart(), is(equalTo("/com/example/app/Steps"))));
}

@Test
@EnabledOnOs(OS.WINDOWS)
void absolute_windows_path_form_is_not_valid() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,27 +5,16 @@
import io.cucumber.core.resource.test.ExampleClass;
import io.cucumber.core.resource.test.ExampleInterface;
import io.cucumber.core.resource.test.OtherClass;
import org.hamcrest.CoreMatchers;
import org.hamcrest.Matchers;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;

import java.io.IOException;
import java.net.URI;
import java.net.URL;
import java.net.URLConnection;
import java.net.URLStreamHandler;
import java.util.Arrays;
import java.util.Collections;
import java.util.Enumeration;
import java.util.List;
import java.util.Vector;
import java.util.logging.Level;
import java.util.logging.LogRecord;

import static java.util.Arrays.asList;
import static java.util.Collections.enumeration;
import static java.util.Collections.singletonList;
import static org.hamcrest.MatcherAssert.assertThat;
Expand Down Expand Up @@ -70,6 +59,38 @@ void scanForSubClassesInNonExistingPackage() {
assertThat(classes, empty());
}

@Test
void scanForSubClassesWhenPackage() {
List<Class<? extends ExampleInterface>> classes = scanner.scanForSubClasses(
"io.cucumber.core.resource.test",
ExampleInterface.class);

assertThat(classes, contains(ExampleClass.class));
}

@Test
void scanForSubClassesWhenClass() {
List<Class<? extends ExampleInterface>> classes = scanner.scanForSubClasses(
"io.cucumber.core.resource.test.ExampleClass",
ExampleInterface.class);

assertThat(classes, contains(ExampleClass.class));
}

@Test
void scanForSubClassesWhenNonExistingPackage() {
List<Class<? extends ExampleInterface>> classes = scanner
.scanForSubClasses("io.cucumber.core.resource.does.not.exist", ExampleInterface.class);
assertThat(classes, empty());
}

@Test
void scanForSubClassesWhenNonExistingClass() {
List<Class<? extends ExampleInterface>> classes = scanner
.scanForSubClasses("io.cucumber.core.resource.test.NonExistentClass", ExampleInterface.class);
assertThat(classes, empty());
}

@Test
void scanForClassesInPackage() {
List<Class<?>> classes = scanner.scanForClassesInPackage("io.cucumber.core.resource.test");
Expand Down Expand Up @@ -104,4 +125,34 @@ protected URLConnection openConnection(URL u) {
containsString("Failed to find resources for 'bundle-resource:com/cucumber/bundle'"));
}

@Test
void getClassesWhenPackage() {
List<Class<?>> classes = scanner.getClasses("io.cucumber.core.resource.test");

assertThat(classes, containsInAnyOrder(
ExampleClass.class,
ExampleInterface.class,
OtherClass.class));

}

@Test
void getClassesWhenNonExistingPackage() {
List<Class<?>> classes = scanner.getClasses("io.cucumber.core.resource.does.not.exist");
assertThat(classes, empty());
}

@Test
void getClassesWhenClass() {
List<Class<?>> classes = scanner.getClasses("io.cucumber.core.resource.test.ExampleClass");

assertThat(classes, contains(ExampleClass.class));

}

@Test
void getClassesWhenNonExistingClass() {
List<Class<?>> classes = scanner.getClasses("io.cucumber.core.resource.test.NonExistentClass");
assertThat(classes, empty());
}
}
2 changes: 1 addition & 1 deletion guice/src/main/java/io/cucumber/guice/GuiceBackend.java
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ public void loadGlue(Glue glue, List<URI> gluePaths) {
gluePaths.stream()
.filter(gluePath -> CLASSPATH_SCHEME.equals(gluePath.getScheme()))
.map(ClasspathSupport::packageName)
.map(classFinder::scanForClassesInPackage)
.map(classFinder::getClasses)
.flatMap(Collection::stream)
.filter(InjectorSource.class::isAssignableFrom)
.forEach(container::addClass);
Expand Down
9 changes: 8 additions & 1 deletion guice/src/test/java/io/cucumber/guice/GuiceBackendTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,19 @@ class GuiceBackendTest {
private ObjectFactory factory;

@Test
void finds_injector_source_impls_by_classpath_url() {
void finds_injector_source_impls_by_package_classpath_url() {
GuiceBackend backend = new GuiceBackend(factory, classLoader);
backend.loadGlue(glue, singletonList(URI.create("classpath:io/cucumber/guice/integration")));
verify(factory).addClass(YourInjectorSource.class);
}

@Test
void finds_injector_source_impls_by_class_classpath_url() {
GuiceBackend backend = new GuiceBackend(factory, classLoader);
backend.loadGlue(glue, singletonList(URI.create("classpath:io/cucumber/guice/integration/YourInjectorSource")));
verify(factory).addClass(YourInjectorSource.class);
}

@Test
void world_and_snippet_methods_do_nothing() {
GuiceBackend backend = new GuiceBackend(factory, classLoader);
Expand Down
2 changes: 1 addition & 1 deletion java/src/main/java/io/cucumber/java/JavaBackend.java
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ public void loadGlue(Glue glue, List<URI> gluePaths) {
gluePaths.stream()
.filter(gluePath -> CLASSPATH_SCHEME.equals(gluePath.getScheme()))
.map(ClasspathSupport::packageName)
.map(classFinder::scanForClassesInPackage)
.map(classFinder::getClasses)
.flatMap(Collection::stream)
.forEach(aGlueClass -> scan(aGlueClass, (method, annotation) -> {
container.addClass(method.getDeclaringClass());
Expand Down
32 changes: 29 additions & 3 deletions java/src/main/java/io/cucumber/java/MethodScanner.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,16 @@

import java.lang.annotation.Annotation;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.function.BiConsumer;
import java.util.stream.Collectors;

import static io.cucumber.core.resource.ClasspathSupport.classPathScanningExplanation;
import static io.cucumber.java.InvalidMethodException.createInvalidMethodException;
import static java.lang.reflect.Modifier.isAbstract;
import static java.lang.reflect.Modifier.isPrivate;
import static java.lang.reflect.Modifier.isPublic;
import static java.lang.reflect.Modifier.isStatic;

Expand All @@ -29,12 +34,15 @@ static void scan(Class<?> aClass, BiConsumer<Method, Annotation> consumer) {
if (!isInstantiable(aClass)) {
return;
}
for (Method method : safelyGetMethods(aClass)) {
for (Method method : safelyGetPublicMethods(aClass)) {
scan(consumer, aClass, method);
}
for (Method method : safelyGetNonPublicMethods(aClass)) {
scan(consumer, aClass, method);
}
}

private static Method[] safelyGetMethods(Class<?> aClass) {
private static Method[] safelyGetPublicMethods(Class<?> aClass) {
try {
return aClass.getMethods();
} catch (NoClassDefFoundError e) {
Expand All @@ -44,8 +52,26 @@ private static Method[] safelyGetMethods(Class<?> aClass) {
return new Method[0];
}

private static List<Method> safelyGetNonPublicMethods(Class<?> aClass) {
try {
return Arrays.stream(aClass.getDeclaredMethods())
.filter(MethodScanner::hasAcceptableModifiers)
.collect(Collectors.toList());
} catch (NoClassDefFoundError e) {
log.warn(e,
() -> "Failed to load methods of class '" + aClass.getName() + "'.\n" + classPathScanningExplanation());
}
return Collections.emptyList();
}

private static boolean hasAcceptableModifiers(Method aMethod) {
return !isPrivate(aMethod.getModifiers())
&& !isPublic(aMethod.getModifiers())
&& !isAbstract(aMethod.getModifiers());
}

private static boolean isInstantiable(Class<?> clazz) {
return isPublic(clazz.getModifiers())
return !isPrivate(clazz.getModifiers())
&& !isAbstract(clazz.getModifiers())
&& (isStatic(clazz.getModifiers()) || clazz.getEnclosingClass() == null);
}
Expand Down
5 changes: 4 additions & 1 deletion java/src/test/java/io/cucumber/java/JavaBackendTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import io.cucumber.core.backend.Glue;
import io.cucumber.core.backend.ObjectFactory;
import io.cucumber.core.backend.StepDefinition;
import io.cucumber.java.individualclasssteps.StepsTwo;
import io.cucumber.java.steps.Steps;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
Expand Down Expand Up @@ -48,9 +49,11 @@ void createBackend() {

@Test
void finds_step_definitions_by_classpath_url() {
backend.loadGlue(glue, singletonList(URI.create("classpath:io/cucumber/java/steps")));
backend.loadGlue(glue, asList(URI.create("classpath:io/cucumber/java/steps"),
URI.create("classpath:io/cucumber/java/individualclasssteps/StepsTwo")));
backend.buildWorld();
verify(factory).addClass(Steps.class);
verify(factory).addClass(StepsTwo.class);
}

@Test
Expand Down
Loading