Skip to content

Commit d51bee1

Browse files
stabilize test timings
1 parent 8aecc21 commit d51bee1

4 files changed

Lines changed: 182 additions & 15 deletions

File tree

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,18 @@ Or, if it's easier to pipe the report file:
4040
rspec $(curl http://my.junit.url | split_tests -junit -split-index=$CI_NODE_INDEX -split-total=$CI_NODE_TOTAL)
4141
```
4242

43+
#### Stabilizing test timings
44+
45+
Sometimes test timings fluctuate, so always relying on the latest timings might not make the best split. For such cases, `split_tests` has an update mode that will apply a sliding window average between prior timings and current ones:
46+
47+
```
48+
split_tests -junit-update=old_glob -junit-new=new_glob -junit-out=out.xml
49+
```
50+
51+
Then you take out.xml and use it for the next test run.
52+
53+
Note that updating also cleans up the files and only preserves one "test case" per file, because that is enough for this tool's purpose.
54+
4355
### Naive split by line count
4456

4557
If you don't have test times, it might be reasonable for your project to assume runtime proportional to test length.
@@ -95,6 +107,12 @@ $./split_tests -help
95107
Use a JUnit XML report for test times
96108
-junit-path string
97109
Path to a JUnit XML report (leave empty to read from stdin; use glob pattern to load multiple files)
110+
-junit-new string
111+
Glob pattern for new JUnit XML files (for updating timings with sliding window)
112+
-junit-out string
113+
Output path for updated JUnit XML file (for updating timings with sliding window)
114+
-junit-update string
115+
Glob pattern for old JUnit XML files (for updating timings with sliding window)
98116
-line-count
99117
Use line count to estimate test times
100118
-split-index int

junit.go

Lines changed: 34 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -36,20 +36,42 @@ func addFileTimesFromIOReader(fileTimes map[string]float64, reader io.Reader) {
3636
}
3737
}
3838

39-
func getFileTimesFromJUnitXML(fileTimes map[string]float64) {
40-
if junitXMLPath != "" {
41-
filenames, err := doublestar.Glob(junitXMLPath)
39+
// loadJUnitTimingsFromGlob loads test timings from JUnit XML files matching a glob pattern
40+
func loadJUnitTimingsFromGlob(globPattern string) map[string]float64 {
41+
fileTimes := make(map[string]float64)
42+
43+
if globPattern == "" {
44+
return fileTimes
45+
}
46+
47+
filenames, err := doublestar.Glob(globPattern)
48+
if err != nil {
49+
fatalMsg("failed to match jUnit filename pattern: %v", err)
50+
}
51+
52+
if len(filenames) == 0 {
53+
printMsg("warning: no files matched pattern %s\n", globPattern)
54+
return fileTimes
55+
}
56+
57+
for _, junitFilename := range filenames {
58+
file, err := os.Open(junitFilename)
4259
if err != nil {
43-
fatalMsg("failed to match jUnit filename pattern: %v", err)
60+
fatalMsg("failed to open junit xml: %v\n", err)
4461
}
45-
for _, junitFilename := range filenames {
46-
file, err := os.Open(junitFilename)
47-
if err != nil {
48-
fatalMsg("failed to open junit xml: %v\n", err)
49-
}
50-
printMsg("using test times from JUnit report %s\n", junitFilename)
51-
addFileTimesFromIOReader(fileTimes, file)
52-
file.Close()
62+
printMsg("loaded test times from %s\n", junitFilename)
63+
addFileTimesFromIOReader(fileTimes, file)
64+
file.Close()
65+
}
66+
67+
return fileTimes
68+
}
69+
70+
func getFileTimesFromJUnitXML(fileTimes map[string]float64) {
71+
if junitXMLPath != "" {
72+
loadedTimes := loadJUnitTimingsFromGlob(junitXMLPath)
73+
for file, time := range loadedTimes {
74+
fileTimes[file] += time
5375
}
5476
} else {
5577
printMsg("using test times from JUnit report at stdin\n")

junit_update.go

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
package main
2+
3+
import (
4+
"encoding/xml"
5+
"os"
6+
"strconv"
7+
)
8+
9+
var junitUpdateOldGlob string
10+
var junitUpdateNewGlob string
11+
var junitUpdateOutPath string
12+
13+
const slidingWindowOldWeight = 0.9
14+
15+
// applySlidingWindow applies an exponential moving average to smooth out timing fluctuations
16+
// Uses exponential moving average: oldWeight * old + (1 - oldWeight) * new
17+
// This gives more weight to historical data while still incorporating recent changes
18+
func applySlidingWindow(oldTime, newTime float64) float64 {
19+
return slidingWindowOldWeight*oldTime + (1-slidingWindowOldWeight)*newTime
20+
}
21+
22+
23+
// updateJUnitTimings merges old and new JUnit timings using a sliding window algorithm
24+
func updateJUnitTimings() {
25+
if junitUpdateOldGlob == "" || junitUpdateNewGlob == "" || junitUpdateOutPath == "" {
26+
fatalMsg("junit-update requires -junit-update, -junit-new, and -junit-out flags\n")
27+
}
28+
29+
// Load old timings
30+
oldTimings := loadJUnitTimingsFromGlob(junitUpdateOldGlob)
31+
printMsg("loaded %d test files from old timings\n", len(oldTimings))
32+
33+
// Load new timings
34+
newTimings := loadJUnitTimingsFromGlob(junitUpdateNewGlob)
35+
printMsg("loaded %d test files from new timings\n", len(newTimings))
36+
37+
// Merge timings using sliding window algorithm
38+
mergedTimings := make(map[string]float64)
39+
40+
// Process all tests from new timings
41+
for file, newTime := range newTimings {
42+
oldTime, exists := oldTimings[file]
43+
if exists {
44+
// Test exists in both: apply sliding window
45+
mergedTimings[file] = applySlidingWindow(oldTime, newTime)
46+
} else {
47+
// Test not in old: use new timing
48+
mergedTimings[file] = newTime
49+
}
50+
}
51+
52+
// Tests not in new are automatically excluded (not added to mergedTimings)
53+
54+
printMsg("merged %d test files (removed tests not in new, used sliding window for existing tests)\n", len(mergedTimings))
55+
56+
// Write output JUnit XML
57+
writeJUnitXML(mergedTimings, junitUpdateOutPath)
58+
printMsg("wrote updated timings to %s\n", junitUpdateOutPath)
59+
}
60+
61+
// writeJUnitXML writes test timings to a JUnit XML file
62+
func writeJUnitXML(timings map[string]float64, outputPath string) {
63+
// JUnit XML structure for writing (with testsuite root element)
64+
type testCase struct {
65+
File string `xml:"file,attr"`
66+
Time string `xml:"time,attr"`
67+
}
68+
69+
type testSuite struct {
70+
XMLName xml.Name `xml:"testsuite"`
71+
Name string `xml:"name,attr"`
72+
Tests int `xml:"tests,attr"`
73+
TestCases []testCase `xml:"testcase"`
74+
}
75+
76+
// Convert map to slice for consistent output
77+
testCases := make([]testCase, 0, len(timings))
78+
for file, time := range timings {
79+
// Format as decimal without scientific notation
80+
timeStr := strconv.FormatFloat(time, 'f', -1, 64)
81+
testCases = append(testCases, testCase{
82+
File: file,
83+
Time: timeStr,
84+
})
85+
}
86+
87+
suite := testSuite{
88+
Name: "rspec",
89+
Tests: len(testCases),
90+
TestCases: testCases,
91+
}
92+
93+
// Create output file
94+
file, err := os.Create(outputPath)
95+
if err != nil {
96+
fatalMsg("failed to create output file: %v\n", err)
97+
}
98+
defer file.Close()
99+
100+
// Write XML header
101+
file.WriteString(xml.Header)
102+
103+
// Create encoder
104+
encoder := xml.NewEncoder(file)
105+
encoder.Indent("", " ")
106+
107+
// Encode XML
108+
err = encoder.Encode(suite)
109+
if err != nil {
110+
fatalMsg("failed to encode JUnit XML: %v\n", err)
111+
}
112+
113+
file.WriteString("\n")
114+
}

main.go

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,10 @@ func parseFlags() {
8282

8383
flag.BoolVar(&useLineCount, "line-count", false, "Use line count to estimate test times")
8484

85+
flag.StringVar(&junitUpdateOldGlob, "junit-update", "", "Glob pattern for old JUnit XML files (for updating timings with sliding window)")
86+
flag.StringVar(&junitUpdateNewGlob, "junit-new", "", "Glob pattern for new JUnit XML files (for updating timings with sliding window)")
87+
flag.StringVar(&junitUpdateOutPath, "junit-out", "", "Output path for updated JUnit XML file (for updating timings with sliding window)")
88+
8589
var showHelp bool
8690
flag.BoolVar(&showHelp, "help", false, "Show this help text")
8791

@@ -116,17 +120,26 @@ func parseFlags() {
116120
flag.PrintDefaults()
117121
os.Exit(1)
118122
}
123+
119124
if useCircleCI && (circleCIProjectPrefix == "" || circleCIBranchName == "") {
120125
fatalMsg("Incomplete CircleCI configuration (set -circleci-key, -circleci-project, and -circleci-branch\n")
121126
}
122-
if splitTotal == 0 || splitIndex < 0 || splitIndex > splitTotal {
123-
fatalMsg("-split-index and -split-total (and environment variables) are missing or invalid\n")
124-
}
125127
}
126128

127129
func main() {
128130
parseFlags()
129131

132+
// If JUnit update mode is enabled, handle it separately and exit
133+
if junitUpdateOldGlob != "" || junitUpdateNewGlob != "" || junitUpdateOutPath != "" {
134+
updateJUnitTimings()
135+
return
136+
}
137+
138+
// Validate split parameters (not needed in update mode)
139+
if splitTotal == 0 || splitIndex < 0 || splitIndex > splitTotal {
140+
fatalMsg("-split-index and -split-total (and environment variables) are missing or invalid\n")
141+
}
142+
130143
// We are not using filepath.Glob,
131144
// because it doesn't support '**' (to match all files in all nested directories)
132145
currentFiles, err := doublestar.Glob(testFilePattern)

0 commit comments

Comments
 (0)