Skip to content

Commit 6b6bc53

Browse files
committed
Add --calculate-optimal-total-split option
1 parent 9adb351 commit 6b6bc53

File tree

6 files changed

+147
-34
lines changed

6 files changed

+147
-34
lines changed

README.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ java -jar split-tests-java.jar --split-index 0 --split-total 10 --glob '**/*Test
2727

2828
### Using a JUnit report
2929

30-
For example, check out the project into `project` and the JUnit reports into `reports`.
30+
For example, check out the project into `project` and the JUnit reports into `reports`.
3131

3232
```
3333
java -jar split-tests-java.jar --split-index 0 --split-total 10 --glob 'project/**/*Test.java' --junit 'reports/**/*.xml'
@@ -38,6 +38,10 @@ java -jar split-tests-java.jar --split-index 0 --split-total 10 --glob 'project/
3838
```plain
3939
Usage: <main class> [options]
4040
Options:
41+
--calculate-optimal-total-split, -o
42+
Calculates the optimal test split. Logs a warning if --split-total does
43+
not match.
44+
Default: false
4145
--debug, -d
4246
Enables debug logging.
4347
Default: false
@@ -56,6 +60,9 @@ Usage: <main class> [options]
5660
--junit-glob, -j
5761
Glob pattern to find JUnit reports. Make sure to single-quote the
5862
pattern to avoid shell expansion.
63+
--max-optimal-total-split-calculations, -m
64+
The maximum number of --calculate-optimal-total-split calculations.
65+
Default: 50
5966
--new-test-time, -n
6067
Configures the calculation of the test time for tests without JUnit
6168
reports.
@@ -83,6 +90,7 @@ This tool is written in Java and uses Gradle as build tool.
8390

8491
split-tests-java is inspired by [`split-test`](https://github.com/mtsmfm/split-test) for Ruby.
8592

86-
In comparison to split-test, split-tests-java works with default JUnit reports.
93+
In comparison to `split-test`, `split-tests-java` works with standard JUnit reports without a `file` or `filepath`
94+
attribute.
8795
The output are also fully qualified class names instead of file names.
8896
This makes it compatible with default Java tooling.

src/main/java/de/donnerbart/split/Arguments.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,14 @@ class Arguments {
5151
description = "The working directory. Defaults to the current directory.")
5252
@Nullable Path workingDirectory;
5353

54+
@Parameter(names = {"--calculate-optimal-total-split", "-o"},
55+
description = "Calculates the optimal test split. Logs a warning if --split-total does not match.")
56+
boolean calculateOptimalTotalSplit = false;
57+
58+
@Parameter(names = {"--max-optimal-total-split-calculations", "-m"},
59+
description = "The maximum number of --calculate-optimal-total-split calculations.")
60+
int maxOptimalTotalSplitCalculations = 50;
61+
5462
@Parameter(names = {"--debug", "-d"}, description = "Enables debug logging.")
5563
boolean debug = false;
5664

src/main/java/de/donnerbart/split/TestSplit.java

Lines changed: 31 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import org.jetbrains.annotations.Nullable;
1313
import org.slf4j.Logger;
1414
import org.slf4j.LoggerFactory;
15+
import org.slf4j.helpers.NOPLogger;
1516

1617
import java.io.IOException;
1718
import java.nio.file.FileSystems;
@@ -33,15 +34,14 @@ public class TestSplit {
3334
Set.of("org.junit.jupiter.api.Disabled", "org.junit.Ignore");
3435
private static final @NotNull Set<String> SKIP_TEST_ANNOTATIONS = Set.of("Disabled", "Ignore");
3536

36-
private static final @NotNull Logger LOG = LoggerFactory.getLogger(TestSplit.class);
37-
3837
private final int splitTotal;
3938
private final @NotNull String glob;
4039
private final @Nullable String excludeGlob;
4140
private final @Nullable String junitGlob;
4241
private final @NotNull FormatOption formatOption;
4342
private final @NotNull NewTestTimeOption newTestTimeOption;
4443
private final @NotNull Path workingDirectory;
44+
private final @NotNull Logger log;
4545
private final boolean debug;
4646
private final @NotNull Consumer<Integer> exitCodeConsumer;
4747

@@ -53,6 +53,7 @@ public TestSplit(
5353
final @NotNull FormatOption formatOption,
5454
final @NotNull NewTestTimeOption newTestTimeOption,
5555
final @NotNull Path workingDirectory,
56+
final boolean log,
5657
final boolean debug,
5758
final @NotNull Consumer<Integer> exitCodeConsumer) {
5859
this.splitTotal = splitTotal;
@@ -62,6 +63,7 @@ public TestSplit(
6263
this.formatOption = formatOption;
6364
this.newTestTimeOption = newTestTimeOption;
6465
this.workingDirectory = workingDirectory;
66+
this.log = log ? LoggerFactory.getLogger(TestSplit.class) : NOPLogger.NOP_LOGGER;
6567
this.debug = debug;
6668
this.exitCodeConsumer = exitCodeConsumer;
6769
}
@@ -70,17 +72,17 @@ public TestSplit(
7072
final var testPaths = getPaths(workingDirectory, glob, excludeGlob);
7173
final var classNames = fileToClassName(testPaths, exitCodeConsumer);
7274
if (classNames.isEmpty()) {
73-
LOG.error("Found no test classes");
75+
log.error("Found no test classes");
7476
exitCodeConsumer.accept(1);
7577
} else {
76-
LOG.info("Found {} test classes", classNames.size());
78+
log.info("Found {} test classes", classNames.size());
7779
}
7880

7981
final var testCases = new HashSet<TestCase>();
8082
if (junitGlob != null) {
8183
// analyze JUnit reports
8284
final var junitPaths = getPaths(workingDirectory, junitGlob, null);
83-
LOG.info("Found {} JUnit report files", junitPaths.size());
85+
log.info("Found {} JUnit report files", junitPaths.size());
8486
if (!junitPaths.isEmpty()) {
8587
var fastestTest = new TestCase("", Double.MAX_VALUE);
8688
var slowestTest = new TestCase("", Double.MIN_VALUE);
@@ -90,7 +92,7 @@ public TestSplit(
9092
final var testCase = new TestCase(testSuite.getName(), testSuite.getTime());
9193
if (classNames.contains(testCase.name())) {
9294
if (testCases.add(testCase)) {
93-
LOG.debug("Adding test {} [{}]", testCase.name(), formatTime(testCase.time()));
95+
log.debug("Adding test {} [{}]", testCase.name(), formatTime(testCase.time()));
9496
if (testCase.time() < fastestTest.time()) {
9597
fastestTest = testCase;
9698
}
@@ -99,53 +101,53 @@ public TestSplit(
99101
}
100102
}
101103
} else {
102-
LOG.info("Skipping test {} from JUnit report", testCase.name());
104+
log.info("Skipping test {} from JUnit report", testCase.name());
103105
}
104106
}
105-
LOG.debug("Found {} recorded test classes with time information", testCases.size());
106-
LOG.debug("Fastest test class: {} ({})", fastestTest.name(), formatTime(fastestTest.time()));
107-
LOG.debug("Slowest test class: {} ({})", slowestTest.name(), formatTime(slowestTest.time()));
107+
log.debug("Found {} recorded test classes with time information", testCases.size());
108+
log.debug("Fastest test class: {} ({})", fastestTest.name(), formatTime(fastestTest.time()));
109+
log.debug("Slowest test class: {} ({})", slowestTest.name(), formatTime(slowestTest.time()));
108110
}
109111
}
110112
// add tests without timing records
111113
final var newTestTime = getNewTestTime(newTestTimeOption, testCases);
112114
classNames.forEach(className -> {
113115
final var testCase = new TestCase(className, newTestTime);
114116
if (testCases.add(testCase)) {
115-
LOG.debug("Adding test {} [estimated {}]", testCase.name(), formatTime(testCase.time()));
117+
log.debug("Adding test {} [estimated {}]", testCase.name(), formatTime(testCase.time()));
116118
}
117119
});
118120

119121
// split tests
120-
LOG.debug("Splitting {} tests", testCases.size());
122+
log.debug("Splitting {} tests", testCases.size());
121123
final var splits = new Splits(splitTotal, formatOption);
122124
testCases.stream().sorted(Comparator.reverseOrder()).forEach(testCase -> {
123125
final var split = splits.add(testCase);
124-
LOG.debug("Adding test {} to split #{}", testCase.name(), split.index());
126+
log.debug("Adding test {} to split #{}", testCase.name(), split.index());
125127
});
126128

127129
if (debug) {
128130
if (splitTotal > 1) {
129131
final var fastestSplit = splits.getFastest();
130-
LOG.debug("Fastest test plan is #{} with {} tests ({})",
132+
log.debug("Fastest test plan is #{} with {} tests ({})",
131133
fastestSplit.formatIndex(),
132134
fastestSplit.tests().size(),
133135
formatTime(fastestSplit.totalRecordedTime()));
134136
final var slowestSplit = splits.getSlowest();
135-
LOG.debug("Slowest test plan is #{} with {} tests ({})",
137+
log.debug("Slowest test plan is #{} with {} tests ({})",
136138
slowestSplit.formatIndex(),
137139
slowestSplit.tests().size(),
138140
formatTime(slowestSplit.totalRecordedTime()));
139-
LOG.debug("Difference between the fastest and slowest test plan: {}",
141+
log.debug("Difference between the fastest and slowest test plan: {}",
140142
formatTime(slowestSplit.totalRecordedTime() - fastestSplit.totalRecordedTime()));
141143
}
142-
LOG.debug("Test splits:");
143-
splits.forEach(split -> LOG.debug(split.toString()));
144+
log.debug("Test splits:");
145+
splits.forEach(split -> log.debug(split.toString()));
144146
}
145147
return splits;
146148
}
147149

148-
private static @NotNull Set<Path> getPaths(
150+
private @NotNull Set<Path> getPaths(
149151
final @NotNull Path rootPath,
150152
final @NotNull String glob,
151153
final @Nullable String excludeGlob) throws Exception {
@@ -161,7 +163,7 @@ public TestSplit(
161163
final var candidate = path.normalize();
162164
if (includeMatcher.matches(candidate)) {
163165
if (excludeMatcher.matches(candidate)) {
164-
LOG.debug("Excluding test file {}", candidate);
166+
log.debug("Excluding test file {}", candidate);
165167
} else {
166168
files.add(candidate);
167169
}
@@ -178,7 +180,7 @@ public TestSplit(
178180
return files;
179181
}
180182

181-
private static @NotNull Set<String> fileToClassName(
183+
private @NotNull Set<String> fileToClassName(
182184
final @NotNull Set<Path> testPaths,
183185
final @NotNull Consumer<Integer> exitCodeConsumer) {
184186
final var javaParser = new JavaParser();
@@ -189,10 +191,10 @@ public TestSplit(
189191
final var declaration = compilationUnit.findFirst(ClassOrInterfaceDeclaration.class).orElseThrow();
190192
final var className = declaration.getFullyQualifiedName().orElseThrow();
191193
if (declaration.isInterface()) {
192-
LOG.info("Skipping interface {}", className);
194+
log.info("Skipping interface {}", className);
193195
continue;
194196
} else if (declaration.isAbstract()) {
195-
LOG.info("Skipping abstract class {}", className);
197+
log.info("Skipping abstract class {}", className);
196198
continue;
197199
}
198200
final var hasSkipTestImport = compilationUnit.getImports()
@@ -204,19 +206,19 @@ public TestSplit(
204206
.map(AnnotationExpr::getNameAsString)
205207
.anyMatch(SKIP_TEST_ANNOTATIONS::contains);
206208
if (hasSkipTestImport && hasSkipTestAnnotation) {
207-
LOG.info("Skipping disabled test class {}", className);
209+
log.info("Skipping disabled test class {}", className);
208210
continue;
209211
}
210212
classNames.add(className);
211213
} catch (final Exception e) {
212-
LOG.error("Failed to parse test class {}", testPath, e);
214+
log.error("Failed to parse test class {}", testPath, e);
213215
exitCodeConsumer.accept(1);
214216
}
215217
}
216218
return classNames;
217219
}
218220

219-
private static double getNewTestTime(
221+
private double getNewTestTime(
220222
final @NotNull NewTestTimeOption useAverageTimeForNewTests,
221223
final @NotNull Set<TestCase> testCases) {
222224
if (testCases.isEmpty()) {
@@ -227,17 +229,17 @@ private static double getNewTestTime(
227229
case AVERAGE -> {
228230
final var averageTime =
229231
testCases.stream().mapToDouble(TestCase::time).sum() / (double) testCases.size();
230-
LOG.info("Average test time is {}", formatTime(averageTime));
232+
log.info("Average test time is {}", formatTime(averageTime));
231233
yield averageTime;
232234
}
233235
case MIN -> {
234236
final var minTime = testCases.stream().mapToDouble(TestCase::time).min().orElseThrow();
235-
LOG.info("Minimum test time is {}", formatTime(minTime));
237+
log.info("Minimum test time is {}", formatTime(minTime));
236238
yield minTime;
237239
}
238240
case MAX -> {
239241
final var maxTime = testCases.stream().mapToDouble(TestCase::time).max().orElseThrow();
240-
LOG.info("Maximum test time is {}", formatTime(maxTime));
242+
log.info("Maximum test time is {}", formatTime(maxTime));
241243
yield maxTime;
242244
}
243245
};

src/main/java/de/donnerbart/split/TestSplitMain.java

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,13 +56,17 @@ public static void main(final @Nullable String @NotNull [] args) throws Exceptio
5656
LOG.info("JUnit glob: {}", arguments.junitGlob);
5757
}
5858
LOG.info("Output format: {}", arguments.formatOption);
59+
if (arguments.calculateOptimalTotalSplit) {
60+
calculateOptimalTotalSplit(arguments, workingDirectory);
61+
}
5962
final var testSplit = new TestSplit(arguments.splitTotal,
6063
arguments.glob,
6164
arguments.excludeGlob,
6265
arguments.junitGlob,
6366
arguments.formatOption,
6467
arguments.newTestTimeOption,
6568
workingDirectory,
69+
true,
6670
arguments.debug,
6771
System::exit);
6872
final var splits = testSplit.run();
@@ -88,4 +92,48 @@ static boolean validateArguments(final @NotNull Arguments arguments, final @NotN
8892
}
8993
return true;
9094
}
95+
96+
@VisibleForTesting
97+
static int calculateOptimalTotalSplit(final @NotNull Arguments arguments, final @NotNull Path workingDirectory)
98+
throws Exception {
99+
if (arguments.junitGlob == null) {
100+
LOG.warn("The option --calculate-optimal-total-split requires --junit-glob");
101+
return 0;
102+
}
103+
LOG.info("Calculating optimal test split");
104+
var optimalSplit = 1;
105+
var lastSlowestSplit = Double.MAX_VALUE;
106+
while (true) {
107+
final var testSplit = new TestSplit(optimalSplit,
108+
arguments.glob,
109+
arguments.excludeGlob,
110+
arguments.junitGlob,
111+
arguments.formatOption,
112+
arguments.newTestTimeOption,
113+
workingDirectory,
114+
false,
115+
false,
116+
System::exit);
117+
final var splits = testSplit.run();
118+
final var slowestSplit = splits.getSlowest().totalRecordedTime();
119+
if (Double.compare(slowestSplit, lastSlowestSplit) == 0) {
120+
optimalSplit--;
121+
LOG.info("The optimal --total-split value for this test suite is {}", optimalSplit);
122+
if (optimalSplit != arguments.splitTotal) {
123+
LOG.warn("The --split-total value of {} does not match the optimal split of {}",
124+
arguments.splitTotal,
125+
optimalSplit);
126+
}
127+
return optimalSplit;
128+
}
129+
LOG.debug("The slowest split with {} splits takes {}", optimalSplit, formatTime(slowestSplit));
130+
if (optimalSplit++ >= arguments.maxOptimalTotalSplitCalculations) {
131+
LOG.warn(
132+
"The option --max-optimal-total-split-calculations of {} is too low to calculate the optimal test split",
133+
arguments.maxOptimalTotalSplitCalculations);
134+
return 0;
135+
}
136+
lastSlowestSplit = slowestSplit;
137+
}
138+
}
91139
}

0 commit comments

Comments
 (0)