Skip to content

Commit fc79d0e

Browse files
authored
Add flakeguard - a tool for finding flaky tests (#1255)
1 parent 551e0ad commit fc79d0e

File tree

11 files changed

+885
-0
lines changed

11 files changed

+885
-0
lines changed

tools/flakeguard/cmd/find.go

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
package cmd
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"log"
7+
8+
"github.com/smartcontractkit/chainlink-testing-framework/tools/flakeguard/git"
9+
"github.com/smartcontractkit/chainlink-testing-framework/tools/flakeguard/golang"
10+
"github.com/smartcontractkit/chainlink-testing-framework/tools/flakeguard/utils"
11+
"github.com/spf13/cobra"
12+
)
13+
14+
var FindTestsCmd = &cobra.Command{
15+
Use: "find",
16+
Long: "Analyzes Golang project repository for changed files against a specified base reference and determines the test packages that are potentially impacted",
17+
Short: "Find test packages that may be affected by changes",
18+
Run: func(cmd *cobra.Command, args []string) {
19+
projectPath, _ := cmd.Flags().GetString("project-path")
20+
verbose, _ := cmd.Flags().GetBool("verbose")
21+
jsonOutput, _ := cmd.Flags().GetBool("json")
22+
filterEmptyTests, _ := cmd.Flags().GetBool("filter-empty-tests")
23+
baseRef, _ := cmd.Flags().GetString("base-ref")
24+
excludes, _ := cmd.Flags().GetStringSlice("excludes")
25+
levels, _ := cmd.Flags().GetInt("levels")
26+
findByTestFilesDiff, _ := cmd.Flags().GetBool("find-by-test-files-diff")
27+
findByAffected, _ := cmd.Flags().GetBool("find-by-affected-packages")
28+
onlyShowChangedTestFiles, _ := cmd.Flags().GetBool("only-show-changed-test-files")
29+
30+
// Find all changes in test files and get their package names
31+
var changedTestPkgs []string
32+
if findByTestFilesDiff {
33+
changedTestFiles, err := git.FindChangedFiles(baseRef, "grep '_test\\.go$'", excludes)
34+
if err != nil {
35+
log.Fatalf("Error finding changed test files: %v", err)
36+
}
37+
if onlyShowChangedTestFiles {
38+
outputResults(changedTestFiles, jsonOutput)
39+
return
40+
}
41+
if verbose {
42+
fmt.Println("Changed test files:", changedTestFiles)
43+
}
44+
changedTestPkgs, err = golang.GetFilePackages(changedTestFiles)
45+
if err != nil {
46+
log.Fatalf("Error getting package names for test files: %v", err)
47+
}
48+
}
49+
50+
// Find all affected test packages
51+
var affectedTestPkgs []string
52+
if findByAffected {
53+
if verbose {
54+
fmt.Println("Finding affected packages...")
55+
}
56+
affectedTestPkgs = findAffectedPackages(baseRef, projectPath, excludes, levels)
57+
}
58+
59+
// Combine and deduplicate test package names
60+
testPkgs := append(changedTestPkgs, affectedTestPkgs...)
61+
testPkgs = utils.Deduplicate(testPkgs)
62+
63+
// Filter out packages that do not have tests
64+
if filterEmptyTests {
65+
if verbose {
66+
fmt.Println("Filtering packages without tests...")
67+
}
68+
testPkgs = golang.FilterPackagesWithTests(testPkgs)
69+
}
70+
71+
outputResults(testPkgs, jsonOutput)
72+
},
73+
}
74+
75+
func init() {
76+
FindTestsCmd.Flags().StringP("project-path", "r", ".", "The path to the Go project. Default is the current directory. Useful for subprojects.")
77+
FindTestsCmd.Flags().String("base-ref", "", "Git base reference (branch, tag, commit) for comparing changes. Required.")
78+
FindTestsCmd.Flags().BoolP("verbose", "v", false, "Enable verbose mode")
79+
FindTestsCmd.Flags().Bool("json", false, "Output the results in JSON format")
80+
FindTestsCmd.Flags().Bool("filter-empty-tests", false, "Filter out test packages with no actual test functions. Can be very slow for large projects.")
81+
FindTestsCmd.Flags().StringSlice("excludes", []string{}, "List of paths to exclude. Useful for repositories with multiple Go projects within.")
82+
FindTestsCmd.Flags().IntP("levels", "l", 2, "The number of levels of recursion to search for affected packages. Default is 2. 0 is unlimited.")
83+
FindTestsCmd.Flags().Bool("find-by-test-files-diff", true, "Enable the mode to find test packages by changes in test files.")
84+
FindTestsCmd.Flags().Bool("find-by-affected-packages", true, "Enable the mode to find test packages that may be affected by changes in any of the project packages.")
85+
FindTestsCmd.Flags().Bool("only-show-changed-test-files", false, "Only show the changed test files and exit")
86+
87+
if err := FindTestsCmd.MarkFlagRequired("base-ref"); err != nil {
88+
fmt.Println("Error marking base-ref as required:", err)
89+
}
90+
}
91+
92+
func findAffectedPackages(baseRef, projectPath string, excludes []string, levels int) []string {
93+
goList, err := golang.GoList()
94+
if err != nil {
95+
log.Fatalf("Error getting go list: %v\nStdErr: %s", err, goList.Stderr.String())
96+
}
97+
gitDiff, err := git.Diff(baseRef)
98+
if err != nil {
99+
log.Fatalf("Error getting the git diff: %v\nStdErr: %s", err, gitDiff.Stderr.String())
100+
}
101+
gitModDiff, err := git.ModDiff(baseRef, projectPath)
102+
if err != nil {
103+
log.Fatalf("Error getting the git mod diff: %v\nStdErr: %s", err, gitModDiff.Stderr.String())
104+
}
105+
106+
packages, err := golang.ParsePackages(goList.Stdout)
107+
if err != nil {
108+
log.Fatalf("Error parsing packages: %v", err)
109+
}
110+
111+
fileMap := golang.GetGoFileMap(packages, true)
112+
113+
var changedPackages []string
114+
changedPackages, err = git.GetChangedGoPackagesFromDiff(gitDiff.Stdout, projectPath, excludes, fileMap)
115+
if err != nil {
116+
log.Fatalf("Error getting changed packages: %v", err)
117+
}
118+
119+
changedModPackages, err := git.GetGoModChangesFromDiff(gitModDiff.Stdout)
120+
if err != nil {
121+
log.Fatalf("Error getting go.mod changes: %v", err)
122+
}
123+
124+
depMap := golang.GetGoDepMap(packages)
125+
126+
// Find affected packages
127+
// use map to make handling duplicates simpler
128+
affectedPkgs := map[string]bool{}
129+
130+
// loop through packages changed via file changes
131+
for _, pkg := range changedPackages {
132+
p := golang.FindAffectedPackages(pkg, depMap, false, levels)
133+
for _, p := range p {
134+
affectedPkgs[p] = true
135+
}
136+
}
137+
138+
// loop through packages changed via go.mod changes
139+
for _, pkg := range changedModPackages {
140+
p := golang.FindAffectedPackages(pkg, depMap, true, levels)
141+
for _, p := range p {
142+
affectedPkgs[p] = true
143+
}
144+
}
145+
146+
// convert map to array
147+
pkgs := []string{}
148+
for k := range affectedPkgs {
149+
pkgs = append(pkgs, k)
150+
}
151+
152+
return pkgs
153+
}
154+
155+
func outputResults(packages []string, jsonOutput bool) {
156+
if jsonOutput {
157+
if packages == nil {
158+
packages = make([]string, 0) // Ensure the slice is initialized to an empty array
159+
}
160+
data, err := json.Marshal(packages)
161+
if err != nil {
162+
log.Fatalf("Error marshaling test files to JSON: %v", err)
163+
}
164+
fmt.Println(string(data))
165+
} else {
166+
for _, pkg := range packages {
167+
fmt.Print(pkg, " ")
168+
}
169+
}
170+
}

tools/flakeguard/cmd/run.go

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package cmd
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"log"
7+
"os"
8+
9+
"github.com/smartcontractkit/chainlink-testing-framework/tools/flakeguard/reports"
10+
"github.com/smartcontractkit/chainlink-testing-framework/tools/flakeguard/runner"
11+
"github.com/spf13/cobra"
12+
)
13+
14+
var RunTestsCmd = &cobra.Command{
15+
Use: "run",
16+
Short: "Run tests to check if they are flaky",
17+
Run: func(cmd *cobra.Command, args []string) {
18+
testPackagesJson, _ := cmd.Flags().GetString("test-packages-json")
19+
testPackagesArg, _ := cmd.Flags().GetStringSlice("test-packages")
20+
runCount, _ := cmd.Flags().GetInt("run-count")
21+
useRace, _ := cmd.Flags().GetBool("race")
22+
failFast, _ := cmd.Flags().GetBool("fail-fast")
23+
outputPath, _ := cmd.Flags().GetString("output-json")
24+
threshold, _ := cmd.Flags().GetFloat64("threshold")
25+
26+
var testPackages []string
27+
if testPackagesJson != "" {
28+
if err := json.Unmarshal([]byte(testPackagesJson), &testPackages); err != nil {
29+
log.Fatalf("Error decoding test packages JSON: %v", err)
30+
}
31+
} else if len(testPackagesArg) > 0 {
32+
testPackages = testPackagesArg
33+
} else {
34+
log.Fatalf("Error: must specify either --test-packages-json or --test-packages")
35+
}
36+
37+
runner := runner.Runner{
38+
Verbose: true,
39+
RunCount: runCount,
40+
UseRace: useRace,
41+
FailFast: failFast,
42+
}
43+
44+
testResults, err := runner.RunTests(testPackages)
45+
if err != nil {
46+
fmt.Printf("Error running tests: %v\n", err)
47+
os.Exit(1)
48+
}
49+
50+
// Filter out failed tests based on the threshold
51+
failedTests := reports.FilterFailedTests(testResults, threshold)
52+
if len(failedTests) > 0 {
53+
jsonData, err := json.MarshalIndent(failedTests, "", " ")
54+
if err != nil {
55+
log.Fatalf("Error marshaling test results to JSON: %v", err)
56+
}
57+
fmt.Printf("Threshold for flaky tests: %.2f\n%d failed tests:\n%s\n", threshold, len(failedTests), string(jsonData))
58+
}
59+
60+
// Save the test results in JSON format
61+
if outputPath != "" && len(testResults) > 0 {
62+
jsonData, err := json.MarshalIndent(testResults, "", " ")
63+
if err != nil {
64+
log.Fatalf("Error marshaling test results to JSON: %v", err)
65+
}
66+
if err := os.WriteFile(outputPath, jsonData, 0644); err != nil {
67+
log.Fatalf("Error writing test results to file: %v", err)
68+
}
69+
fmt.Printf("All test results saved to %s\n", outputPath)
70+
}
71+
72+
if len(failedTests) > 0 {
73+
os.Exit(1)
74+
} else if len(testResults) == 0 {
75+
fmt.Printf("No tests were run for the specified packages.\n")
76+
} else {
77+
fmt.Printf("All %d tests passed.\n", len(testResults))
78+
}
79+
},
80+
}
81+
82+
func init() {
83+
RunTestsCmd.Flags().String("test-packages-json", "", "JSON-encoded string of test packages")
84+
RunTestsCmd.Flags().StringSlice("test-packages", nil, "Comma-separated list of test packages to run")
85+
RunTestsCmd.Flags().IntP("run-count", "c", 1, "Number of times to run the tests")
86+
RunTestsCmd.Flags().Bool("race", false, "Enable the race detector")
87+
RunTestsCmd.Flags().Bool("fail-fast", false, "Stop on the first test failure")
88+
RunTestsCmd.Flags().String("output-json", "", "Path to output the test results in JSON format")
89+
RunTestsCmd.Flags().Float64("threshold", 0.8, "Threshold for considering a test as flaky")
90+
}

0 commit comments

Comments
 (0)