Skip to content

Commit d7b2190

Browse files
committed
initial commit
0 parents  commit d7b2190

File tree

3 files changed

+138
-0
lines changed

3 files changed

+138
-0
lines changed

README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Shard 🔪
2+
3+
A quick hack to shard large Go test suites.
4+
5+
Example usage with GitHub Actions:
6+
7+
```
8+
jobs:
9+
tests:
10+
runs-on: ubuntu-latest
11+
strategy:
12+
matrix:
13+
shard: [0, 1, 2, 3]
14+
steps:
15+
- name: Shard tests
16+
run: go run github.com/blampe/shard --total ${{ strategy.job-total }} --index ${{ strategy.job-index }} > tests
17+
- name: Run tests
18+
run: go test $(cat tests)
19+
```
20+
21+
22+

go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
module github.com/blampe/shard
2+
3+
go 1.22.6

main.go

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
package main
2+
3+
import (
4+
"flag"
5+
"fmt"
6+
"go/ast"
7+
"go/parser"
8+
"go/token"
9+
"log"
10+
"math/rand"
11+
"os"
12+
"path/filepath"
13+
"regexp"
14+
"slices"
15+
"strings"
16+
)
17+
18+
var re = regexp.MustCompile(`^Test[A-Z_]`)
19+
20+
type testf struct {
21+
path string
22+
name string
23+
}
24+
25+
func main() {
26+
log.SetFlags(0)
27+
log.SetPrefix(filepath.Base(os.Args[0]) + ": ")
28+
29+
root := flag.String("root", ".", "directory to search for tests")
30+
index := flag.Int("index", -1, "shard index to collect tests for")
31+
total := flag.Int("total", -1, "total number of shards")
32+
seed := flag.Int64("seed", 0, "randomly shuffle tests using this seed")
33+
34+
flag.Parse()
35+
if *index < 0 {
36+
log.Fatal("index is required")
37+
}
38+
if *total < 0 {
39+
log.Fatal("total is required")
40+
}
41+
if *index >= *total {
42+
log.Fatal("index must be less than total")
43+
}
44+
45+
tests := []testf{}
46+
47+
err := filepath.Walk(*root, func(path string, info os.FileInfo, err error) error {
48+
if err != nil {
49+
return err
50+
}
51+
52+
if info.IsDir() || !strings.HasSuffix(path, "_test.go") {
53+
return nil
54+
}
55+
56+
// Parse the file to find test functions
57+
fileSet := token.NewFileSet()
58+
node, err := parser.ParseFile(fileSet, path, nil, 0)
59+
if err != nil {
60+
return err
61+
}
62+
for _, decl := range node.Decls {
63+
f, ok := decl.(*ast.FuncDecl)
64+
if !ok {
65+
continue
66+
}
67+
name := f.Name.Name
68+
if !re.MatchString(name) {
69+
continue
70+
}
71+
tests = append(tests, testf{path: filepath.Dir(path), name: name})
72+
}
73+
74+
return nil
75+
})
76+
if err != nil {
77+
log.Fatal(err)
78+
}
79+
80+
// Shuffle the tests.
81+
if *seed != 0 {
82+
random := rand.New(rand.NewSource(*seed))
83+
for i := range tests {
84+
j := random.Intn(i + 1) //nolint:gosec // Not cryptographic.
85+
tests[i], tests[j] = tests[j], tests[i]
86+
}
87+
}
88+
89+
// Assign tests to our shard.
90+
paths := []string{}
91+
names := []string{}
92+
for idx, test := range tests {
93+
if idx%*total != *index {
94+
continue
95+
}
96+
paths = append(paths, "./"+test.path)
97+
names = append(names, test.name)
98+
}
99+
100+
// De-dupe.
101+
slices.Sort(paths)
102+
slices.Sort(names)
103+
paths = slices.Compact(paths)
104+
names = slices.Compact(names)
105+
106+
// No-op if we didn't find any tests or get any assigned.
107+
if len(paths) == 0 {
108+
paths = []string{*root}
109+
names = []string{"NoTestsFound"}
110+
}
111+
112+
fmt.Printf("-run ^(%s)$ %s\n", strings.Join(names, "|"), strings.Join(paths, " "))
113+
}

0 commit comments

Comments
 (0)