Skip to content

Commit 7ea970e

Browse files
authored
Merge pull request #46389 from Ladicek/continuous-testing-selection
Continuous Testing: add support for build system like test selection
2 parents 0208ffd + 528ae6b commit 7ea970e

File tree

30 files changed

+1074
-13
lines changed

30 files changed

+1074
-13
lines changed

core/deployment/src/main/java/io/quarkus/deployment/dev/testing/JunitTestRunner.java

+252-12
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@
7474
import io.quarkus.deployment.util.IoUtil;
7575
import io.quarkus.dev.console.QuarkusConsole;
7676
import io.quarkus.dev.testing.TracingHandler;
77+
import io.quarkus.util.GlobUtil;
7778

7879
/**
7980
* This class is responsible for running a single run of JUnit tests.
@@ -105,6 +106,7 @@ public class JunitTestRunner {
105106
private final Set<String> excludeTags;
106107
private final Pattern include;
107108
private final Pattern exclude;
109+
private final String specificSelection;
108110
private final List<String> includeEngines;
109111
private final List<String> excludeEngines;
110112
private final boolean failingTestsOnly;
@@ -126,6 +128,7 @@ public JunitTestRunner(Builder builder) {
126128
this.excludeTags = new HashSet<>(builder.excludeTags);
127129
this.include = builder.include;
128130
this.exclude = builder.exclude;
131+
this.specificSelection = builder.specificSelection;
129132
this.includeEngines = builder.includeEngines;
130133
this.excludeEngines = builder.excludeEngines;
131134
this.failingTestsOnly = builder.failingTestsOnly;
@@ -167,7 +170,15 @@ public FilterResult apply(TestDescriptor testDescriptor) {
167170
} else if (!excludeTags.isEmpty()) {
168171
launchBuilder.filters(TagFilter.excludeTags(new ArrayList<>(excludeTags)));
169172
}
170-
if (include != null) {
173+
if (specificSelection != null) {
174+
if (specificSelection.startsWith("maven:")) {
175+
launchBuilder.filters(new MavenSpecificSelectionFilter(specificSelection.substring("maven:".length())));
176+
} else if (specificSelection.startsWith("gradle:")) {
177+
launchBuilder.filters(new GradleSpecificSelectionFilter(specificSelection.substring("gradle:".length())));
178+
} else {
179+
log.error("Unknown specific selection, ignoring: " + specificSelection);
180+
}
181+
} else if (include != null) {
171182
launchBuilder.filters(new RegexFilter(false, include));
172183
} else if (exclude != null) {
173184
launchBuilder.filters(new RegexFilter(true, exclude));
@@ -436,10 +447,10 @@ private static List<String> toTagList(TestIdentifier testIdentifier) {
436447
private Class<?> getTestClassFromSource(Optional<TestSource> optionalTestSource) {
437448
if (optionalTestSource.isPresent()) {
438449
var testSource = optionalTestSource.get();
439-
if (testSource instanceof ClassSource) {
440-
return ((ClassSource) testSource).getJavaClass();
441-
} else if (testSource instanceof MethodSource) {
442-
return ((MethodSource) testSource).getJavaClass();
450+
if (testSource instanceof ClassSource classSource) {
451+
return classSource.getJavaClass();
452+
} else if (testSource instanceof MethodSource methodSource) {
453+
return methodSource.getJavaClass();
443454
} else if (testSource.getClass().getName().equals(ARCHUNIT_FIELDSOURCE_FQCN)) {
444455
try {
445456
return (Class<?>) testSource.getClass().getMethod("getJavaClass").invoke(testSource);
@@ -775,6 +786,7 @@ static class Builder {
775786
private List<String> excludeTags = Collections.emptyList();
776787
private Pattern include;
777788
private Pattern exclude;
789+
private String specificSelection;
778790
private List<String> includeEngines = Collections.emptyList();
779791
private List<String> excludeEngines = Collections.emptyList();
780792
private boolean failingTestsOnly;
@@ -844,6 +856,11 @@ public Builder setExclude(Pattern exclude) {
844856
return this;
845857
}
846858

859+
public Builder setSpecificSelection(String specificSelection) {
860+
this.specificSelection = specificSelection;
861+
return this;
862+
}
863+
847864
public Builder setIncludeEngines(List<String> includeEngines) {
848865
this.includeEngines = includeEngines;
849866
return this;
@@ -880,9 +897,8 @@ private RegexFilter(boolean exclude, Pattern pattern) {
880897
@Override
881898
public FilterResult apply(TestDescriptor testDescriptor) {
882899
if (testDescriptor.getSource().isPresent()) {
883-
if (testDescriptor.getSource().get() instanceof MethodSource) {
884-
MethodSource methodSource = (MethodSource) testDescriptor.getSource().get();
885-
String name = methodSource.getJavaClass().getName();
900+
if (testDescriptor.getSource().get() instanceof MethodSource methodSource) {
901+
String name = methodSource.getClassName();
886902
if (pattern.matcher(name).matches()) {
887903
return FilterResult.includedIf(!exclude);
888904
}
@@ -893,6 +909,232 @@ public FilterResult apply(TestDescriptor testDescriptor) {
893909
}
894910
}
895911

912+
// https://maven.apache.org/surefire/maven-surefire-plugin/test-mojo.html#test
913+
// org.apache.maven.surefire.api.testset.TestListResolver
914+
// org.apache.maven.surefire.api.testset.ResolvedTest
915+
private static class MavenSpecificSelectionFilter implements PostDiscoveryFilter {
916+
private final Matcher[] excludes;
917+
private final Matcher[] includes;
918+
919+
MavenSpecificSelectionFilter(String selection) {
920+
List<Matcher> excludes = new ArrayList<>();
921+
List<Matcher> includes = new ArrayList<>();
922+
923+
if (selection != null) {
924+
for (String item : selection.split(",")) {
925+
item = item.trim();
926+
if (item.isEmpty() || "!".equals(item) || "#".equals(item)) {
927+
continue;
928+
}
929+
List<Matcher> list;
930+
if (item.startsWith("!")) {
931+
list = excludes;
932+
item = item.substring(1);
933+
} else {
934+
list = includes;
935+
}
936+
937+
int hashIndex = item.indexOf('#');
938+
if (hashIndex == 0) {
939+
List<Pattern> methods = extractMethodPatterns(item.substring(hashIndex + 1));
940+
list.add(new MethodMatcher(methods.toArray(new Pattern[0])));
941+
} else if (hashIndex > 0) {
942+
String classPattern = adjustClassGlob(item.substring(0, hashIndex));
943+
if (hashIndex == item.length() - 1) {
944+
list.add(new ClassMatcher(globToPattern(classPattern)));
945+
} else {
946+
List<Pattern> methods = extractMethodPatterns(item.substring(hashIndex + 1));
947+
list.add(new ClassAndMethodMatcher(globToPattern(classPattern), methods.toArray(new Pattern[0])));
948+
}
949+
} else {
950+
String classPattern = adjustClassGlob(item);
951+
list.add(new ClassMatcher(globToPattern(classPattern)));
952+
}
953+
}
954+
}
955+
956+
this.excludes = excludes.toArray(new Matcher[0]);
957+
this.includes = includes.toArray(new Matcher[0]);
958+
}
959+
960+
private static List<Pattern> extractMethodPatterns(String methodGlobs) {
961+
List<Pattern> result = new ArrayList<>();
962+
for (String methodGlob : methodGlobs.split("\\+")) {
963+
methodGlob = methodGlob.trim();
964+
if (!methodGlob.isEmpty()) {
965+
result.add(globToPattern(methodGlob));
966+
}
967+
}
968+
return result;
969+
}
970+
971+
private static String adjustClassGlob(String classGlob) {
972+
if (classGlob.startsWith("**/")) {
973+
classGlob = classGlob.substring("**/".length());
974+
}
975+
if (classGlob.endsWith(".java")) {
976+
classGlob = classGlob.substring(0, classGlob.length() - ".java".length());
977+
} else if (classGlob.endsWith(".class")) {
978+
classGlob = classGlob.substring(0, classGlob.length() - ".class".length());
979+
} else if (classGlob.endsWith(".*")) {
980+
classGlob = classGlob.substring(0, classGlob.length() - ".*".length());
981+
}
982+
return "**/" + classGlob.replace('.', '/');
983+
}
984+
985+
private static Pattern globToPattern(String glob) {
986+
return Pattern.compile(GlobUtil.toRegexPattern(glob));
987+
}
988+
989+
@Override
990+
public FilterResult apply(TestDescriptor testDescriptor) {
991+
if (testDescriptor.getSource().isPresent()
992+
&& testDescriptor.getSource().get() instanceof MethodSource methodSource) {
993+
String className = methodSource.getClassName().replace('.', '/');
994+
String methodName = methodSource.getMethodName();
995+
for (Matcher exclude : excludes) {
996+
if (exclude.matches(className, methodName)) {
997+
return FilterResult.excluded(null);
998+
}
999+
}
1000+
for (Matcher include : includes) {
1001+
if (include.matches(className, methodName)) {
1002+
return FilterResult.included(null);
1003+
}
1004+
}
1005+
return FilterResult.excluded(null);
1006+
}
1007+
return FilterResult.included("not a method");
1008+
}
1009+
1010+
private interface Matcher {
1011+
boolean matches(String className, String methodName);
1012+
}
1013+
1014+
private record ClassMatcher(Pattern classPattern) implements Matcher {
1015+
@Override
1016+
public boolean matches(String className, String methodName) {
1017+
return classPattern.matcher(className).matches();
1018+
}
1019+
}
1020+
1021+
private record MethodMatcher(Pattern[] methodPatterns) implements Matcher {
1022+
@Override
1023+
public boolean matches(String className, String methodName) {
1024+
for (Pattern methodPattern : methodPatterns) {
1025+
if (methodPattern.matcher(methodName).matches()) {
1026+
return true;
1027+
}
1028+
}
1029+
return false;
1030+
}
1031+
}
1032+
1033+
private record ClassAndMethodMatcher(Pattern classPattern, Pattern[] methodPatterns) implements Matcher {
1034+
@Override
1035+
public boolean matches(String className, String methodName) {
1036+
if (classPattern.matcher(className).matches()) {
1037+
for (Pattern methodPattern : methodPatterns) {
1038+
if (methodPattern.matcher(methodName).matches()) {
1039+
return true;
1040+
}
1041+
}
1042+
}
1043+
return false;
1044+
}
1045+
}
1046+
}
1047+
1048+
// https://docs.gradle.org/current/userguide/java_testing.html#test_filtering
1049+
// org.gradle.api.internal.tasks.testing.filter.TestSelectionMatcher
1050+
// org.gradle.api.internal.tasks.testing.filter.TestSelectionMatcher.TestPattern
1051+
private static class GradleSpecificSelectionFilter implements PostDiscoveryFilter {
1052+
// these 2 arrays always have the same length
1053+
private final Pattern[] includes;
1054+
private final boolean[] simpleNames;
1055+
1056+
GradleSpecificSelectionFilter(String selection) {
1057+
List<Pattern> includes = new ArrayList<>();
1058+
List<Boolean> simpleNames = new ArrayList<>();
1059+
1060+
if (selection != null) {
1061+
for (String item : selection.split(",")) {
1062+
item = item.trim();
1063+
if (item.isEmpty()) {
1064+
continue;
1065+
}
1066+
1067+
includes.add(parsePattern(item));
1068+
simpleNames.add(Character.isUpperCase(item.charAt(0)));
1069+
}
1070+
}
1071+
1072+
this.includes = includes.toArray(new Pattern[0]);
1073+
this.simpleNames = new boolean[simpleNames.size()];
1074+
for (int i = 0; i < simpleNames.size(); i++) {
1075+
this.simpleNames[i] = simpleNames.get(i);
1076+
}
1077+
}
1078+
1079+
private static Pattern parsePattern(String item) {
1080+
StringBuilder result = new StringBuilder();
1081+
int start = 0;
1082+
int current = 0;
1083+
while (current < item.length()) {
1084+
if (item.charAt(current) == '*') {
1085+
if (current > start) {
1086+
String part = item.substring(start, current);
1087+
result.append(Pattern.quote(part));
1088+
}
1089+
result.append(".*");
1090+
start = current + 1;
1091+
}
1092+
current++;
1093+
}
1094+
if (current > start) {
1095+
String part = item.substring(start, current);
1096+
result.append(Pattern.quote(part));
1097+
}
1098+
return Pattern.compile(result.toString());
1099+
}
1100+
1101+
@Override
1102+
public FilterResult apply(TestDescriptor testDescriptor) {
1103+
if (testDescriptor.getSource().isPresent()
1104+
&& testDescriptor.getSource().get() instanceof MethodSource methodSource) {
1105+
String className = methodSource.getClassName();
1106+
String methodName = methodSource.getMethodName();
1107+
String classAndMethodName = className + "." + methodName;
1108+
1109+
String simpleClassName = className;
1110+
String simpleClassAndMethodName = classAndMethodName;
1111+
1112+
// using simple names is common, so let's just precompute that unconditionally
1113+
int lastDot = className.lastIndexOf('.');
1114+
if (lastDot >= 0 && lastDot < className.length() - 1) {
1115+
simpleClassName = className.substring(lastDot + 1);
1116+
simpleClassAndMethodName = simpleClassName + "." + methodName;
1117+
}
1118+
1119+
for (int i = 0; i < includes.length; i++) {
1120+
String testedClassName = className;
1121+
String testedClassAndMethodName = classAndMethodName;
1122+
if (simpleNames[i]) {
1123+
testedClassName = simpleClassName;
1124+
testedClassAndMethodName = simpleClassAndMethodName;
1125+
}
1126+
1127+
Pattern include = includes[i];
1128+
if (include.matcher(testedClassAndMethodName).matches() || include.matcher(testedClassName).matches()) {
1129+
return FilterResult.included(null);
1130+
}
1131+
}
1132+
return FilterResult.excluded(null);
1133+
}
1134+
return FilterResult.included("not a method");
1135+
}
1136+
}
1137+
8961138
/**
8971139
* filter for tests that are currently failing.
8981140
* <p>
@@ -904,10 +1146,8 @@ private class CurrentlyFailingFilter implements PostDiscoveryFilter {
9041146
@Override
9051147
public FilterResult apply(TestDescriptor testDescriptor) {
9061148
if (testDescriptor.getSource().isPresent()) {
907-
if (testDescriptor.getSource().get() instanceof MethodSource) {
908-
MethodSource methodSource = (MethodSource) testDescriptor.getSource().get();
909-
910-
String name = methodSource.getJavaClass().getName();
1149+
if (testDescriptor.getSource().get() instanceof MethodSource methodSource) {
1150+
String name = methodSource.getClassName();
9111151
Map<UniqueId, TestResult> results = testState.getCurrentResults().get(name);
9121152
if (results == null) {
9131153
return FilterResult.included("new test");

core/deployment/src/main/java/io/quarkus/deployment/dev/testing/ModuleTestRunner.java

+1
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ Runnable prepare(ClassScanResult classScanResult, boolean reRunFailures, long ru
5656
.setExcludeTags(testSupport.excludeTags)
5757
.setInclude(testSupport.include)
5858
.setExclude(testSupport.exclude)
59+
.setSpecificSelection(testSupport.specificSelection)
5960
.setIncludeEngines(testSupport.includeEngines)
6061
.setExcludeEngines(testSupport.excludeEngines)
6162
.setTestType(testSupport.testType)

core/deployment/src/main/java/io/quarkus/deployment/dev/testing/TestSupport.java

+5
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ public class TestSupport implements TestController {
6262
volatile List<String> excludeTags = Collections.emptyList();
6363
volatile Pattern include = null;
6464
volatile Pattern exclude = null;
65+
volatile String specificSelection = null;
6566
volatile List<String> includeEngines = Collections.emptyList();
6667
volatile List<String> excludeEngines = Collections.emptyList();
6768
volatile boolean displayTestOutput;
@@ -605,6 +606,10 @@ public void setPatterns(String include, String exclude) {
605606
this.exclude = exclude == null ? null : Pattern.compile(exclude);
606607
}
607608

609+
public void setSpecificSelection(String specificSelection) {
610+
this.specificSelection = specificSelection;
611+
}
612+
608613
public void setEngines(List<String> includeEngines, List<String> excludeEngines) {
609614
this.includeEngines = includeEngines;
610615
this.excludeEngines = excludeEngines;

core/deployment/src/main/java/io/quarkus/deployment/dev/testing/TestTracingProcessor.java

+4
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,10 @@ void startTesting(TestConfig config, LiveReloadBuildItem liveReloadBuildItem,
9595
config.excludeTags().orElse(Collections.emptyList()));
9696
testSupport.setPatterns(config.includePattern().orElse(null),
9797
config.excludePattern().orElse(null));
98+
String specificSelection = System.getProperty("quarkus-internal.test.specific-selection");
99+
if (specificSelection != null) {
100+
testSupport.setSpecificSelection(specificSelection);
101+
}
98102
testSupport.setEngines(config.includeEngines().orElse(Collections.emptyList()),
99103
config.excludeEngines().orElse(Collections.emptyList()));
100104
testSupport.setConfiguredDisplayTestOutput(config.displayTestOutput());

0 commit comments

Comments
 (0)