Skip to content

Commit be9bf9c

Browse files
committed
Load test classes only once to speed up optimal test split calculation
1 parent c22c6d2 commit be9bf9c

File tree

5 files changed

+273
-253
lines changed

5 files changed

+273
-253
lines changed
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
package de.donnerbart.split;
2+
3+
import com.fasterxml.jackson.dataformat.xml.XmlMapper;
4+
import com.github.javaparser.JavaParser;
5+
import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration;
6+
import com.github.javaparser.ast.expr.AnnotationExpr;
7+
import com.github.javaparser.ast.nodeTypes.NodeWithName;
8+
import de.donnerbart.split.model.TestCase;
9+
import de.donnerbart.split.model.TestSuite;
10+
import org.jetbrains.annotations.NotNull;
11+
import org.jetbrains.annotations.Nullable;
12+
import org.slf4j.Logger;
13+
import org.slf4j.LoggerFactory;
14+
15+
import java.io.IOException;
16+
import java.nio.file.FileSystems;
17+
import java.nio.file.FileVisitResult;
18+
import java.nio.file.Files;
19+
import java.nio.file.Path;
20+
import java.nio.file.SimpleFileVisitor;
21+
import java.nio.file.attribute.BasicFileAttributes;
22+
import java.util.HashSet;
23+
import java.util.Set;
24+
import java.util.function.Consumer;
25+
26+
import static de.donnerbart.split.util.FormatUtil.formatTime;
27+
28+
public class TestLoader {
29+
30+
private static final @NotNull Set<String> SKIP_TEST_IMPORTS =
31+
Set.of("org.junit.jupiter.api.Disabled", "org.junit.Ignore");
32+
private static final @NotNull Set<String> SKIP_TEST_ANNOTATIONS = Set.of("Disabled", "Ignore");
33+
34+
private static final @NotNull Logger LOG = LoggerFactory.getLogger(TestLoader.class);
35+
36+
private final @NotNull String glob;
37+
private final @Nullable String excludeGlob;
38+
private final @Nullable String junitGlob;
39+
private final @NotNull NewTestTimeOption newTestTimeOption;
40+
private final @NotNull Path workingDirectory;
41+
private final @NotNull Consumer<Integer> exitCodeConsumer;
42+
43+
public TestLoader(
44+
final @NotNull String glob,
45+
final @Nullable String excludeGlob,
46+
final @Nullable String junitGlob,
47+
final @NotNull NewTestTimeOption newTestTimeOption,
48+
final @NotNull Path workingDirectory,
49+
final @NotNull Consumer<Integer> exitCodeConsumer) {
50+
this.glob = glob;
51+
this.excludeGlob = excludeGlob;
52+
this.junitGlob = junitGlob;
53+
this.newTestTimeOption = newTestTimeOption;
54+
this.workingDirectory = workingDirectory;
55+
this.exitCodeConsumer = exitCodeConsumer;
56+
}
57+
58+
public @NotNull Set<TestCase> load() throws Exception {
59+
final var testPaths = getPaths(workingDirectory, glob, excludeGlob);
60+
final var classNames = fileToClassName(testPaths, exitCodeConsumer);
61+
if (classNames.isEmpty()) {
62+
LOG.error("Found no test classes");
63+
exitCodeConsumer.accept(1);
64+
} else {
65+
LOG.info("Found {} test classes", classNames.size());
66+
}
67+
68+
final var testCases = new HashSet<TestCase>();
69+
if (junitGlob != null) {
70+
// analyze JUnit reports
71+
final var junitPaths = getPaths(workingDirectory, junitGlob, null);
72+
LOG.info("Found {} JUnit report files", junitPaths.size());
73+
if (!junitPaths.isEmpty()) {
74+
var fastestTest = new TestCase("", Double.MAX_VALUE);
75+
var slowestTest = new TestCase("", Double.MIN_VALUE);
76+
final var xmlMapper = new XmlMapper();
77+
for (final var junitPath : junitPaths) {
78+
final var testSuite = xmlMapper.readValue(junitPath.toFile(), TestSuite.class);
79+
final var testCase = new TestCase(testSuite.getName(), testSuite.getTime());
80+
if (classNames.contains(testCase.name())) {
81+
if (testCases.add(testCase)) {
82+
LOG.debug("Adding test {} [{}]", testCase.name(), formatTime(testCase.time()));
83+
if (testCase.time() < fastestTest.time()) {
84+
fastestTest = testCase;
85+
}
86+
if (testCase.time() > slowestTest.time()) {
87+
slowestTest = testCase;
88+
}
89+
}
90+
} else {
91+
LOG.info("Skipping test {} from JUnit report", testCase.name());
92+
}
93+
}
94+
LOG.debug("Found {} recorded test classes with time information", testCases.size());
95+
LOG.debug("Fastest test class: {} ({})", fastestTest.name(), formatTime(fastestTest.time()));
96+
LOG.debug("Slowest test class: {} ({})", slowestTest.name(), formatTime(slowestTest.time()));
97+
}
98+
}
99+
// add tests without timing records
100+
final var newTestTime = getNewTestTime(newTestTimeOption, testCases);
101+
classNames.forEach(className -> {
102+
final var testCase = new TestCase(className, newTestTime);
103+
if (testCases.add(testCase)) {
104+
LOG.debug("Adding test {} [estimated {}]", testCase.name(), formatTime(testCase.time()));
105+
}
106+
});
107+
return testCases;
108+
}
109+
110+
private @NotNull Set<Path> getPaths(
111+
final @NotNull Path rootPath,
112+
final @NotNull String glob,
113+
final @Nullable String excludeGlob) throws Exception {
114+
final var files = new HashSet<Path>();
115+
final var includeMatcher = FileSystems.getDefault().getPathMatcher("glob:" + glob);
116+
final var excludeMatcher = FileSystems.getDefault().getPathMatcher("glob:" + excludeGlob);
117+
Files.walkFileTree(rootPath, new SimpleFileVisitor<>() {
118+
@Override
119+
public @NotNull FileVisitResult visitFile(
120+
final @Nullable Path path,
121+
final @NotNull BasicFileAttributes attributes) {
122+
if (path != null) {
123+
final var candidate = path.normalize();
124+
if (includeMatcher.matches(candidate)) {
125+
if (excludeMatcher.matches(candidate)) {
126+
LOG.debug("Excluding test file {}", candidate);
127+
} else {
128+
files.add(candidate);
129+
}
130+
}
131+
}
132+
return FileVisitResult.CONTINUE;
133+
}
134+
135+
@Override
136+
public @NotNull FileVisitResult visitFileFailed(final @Nullable Path file, final @NotNull IOException e) {
137+
return FileVisitResult.CONTINUE;
138+
}
139+
});
140+
return files;
141+
}
142+
143+
private @NotNull Set<String> fileToClassName(
144+
final @NotNull Set<Path> testPaths,
145+
final @NotNull Consumer<Integer> exitCodeConsumer) {
146+
final var javaParser = new JavaParser();
147+
final var classNames = new HashSet<String>();
148+
for (final var testPath : testPaths) {
149+
try {
150+
final var compilationUnit = javaParser.parse(testPath).getResult().orElseThrow();
151+
final var declaration = compilationUnit.findFirst(ClassOrInterfaceDeclaration.class).orElseThrow();
152+
final var className = declaration.getFullyQualifiedName().orElseThrow();
153+
if (declaration.isInterface()) {
154+
LOG.info("Skipping interface {}", className);
155+
continue;
156+
} else if (declaration.isAbstract()) {
157+
LOG.info("Skipping abstract class {}", className);
158+
continue;
159+
}
160+
final var hasSkipTestImport = compilationUnit.getImports()
161+
.stream()
162+
.map(NodeWithName::getNameAsString)
163+
.anyMatch(SKIP_TEST_IMPORTS::contains);
164+
final var hasSkipTestAnnotation = declaration.getAnnotations()
165+
.stream()
166+
.map(AnnotationExpr::getNameAsString)
167+
.anyMatch(SKIP_TEST_ANNOTATIONS::contains);
168+
if (hasSkipTestImport && hasSkipTestAnnotation) {
169+
LOG.info("Skipping disabled test class {}", className);
170+
continue;
171+
}
172+
classNames.add(className);
173+
} catch (final Exception e) {
174+
LOG.error("Failed to parse test class {}", testPath, e);
175+
exitCodeConsumer.accept(1);
176+
}
177+
}
178+
return classNames;
179+
}
180+
181+
private double getNewTestTime(
182+
final @NotNull NewTestTimeOption useAverageTimeForNewTests,
183+
final @NotNull Set<TestCase> testCases) {
184+
if (testCases.isEmpty()) {
185+
return 0d;
186+
}
187+
return switch (useAverageTimeForNewTests) {
188+
case ZERO -> 0d;
189+
case AVERAGE -> {
190+
final var averageTime =
191+
testCases.stream().mapToDouble(TestCase::time).sum() / (double) testCases.size();
192+
LOG.info("Average test time is {}", formatTime(averageTime));
193+
yield averageTime;
194+
}
195+
case MIN -> {
196+
final var minTime = testCases.stream().mapToDouble(TestCase::time).min().orElseThrow();
197+
LOG.info("Minimum test time is {}", formatTime(minTime));
198+
yield minTime;
199+
}
200+
case MAX -> {
201+
final var maxTime = testCases.stream().mapToDouble(TestCase::time).max().orElseThrow();
202+
LOG.info("Maximum test time is {}", formatTime(maxTime));
203+
yield maxTime;
204+
}
205+
};
206+
}
207+
}

0 commit comments

Comments
 (0)