Skip to content

Commit cf54639

Browse files
authored
testrunner: don't mask a parent test that fails on its own (#5698)
## Summary The integration test runner masks failures listed in `known_failures.txt`. A known-failure rule names a specific subtest (e.g. `TestAccept/ssh/connection`), but a parent test also fails whenever one of its subtests fails, so the runner additionally allowed the parent to fail. It did this by matching a rule against the parent *bidirectionally*: a rule whose pattern was a descendant of the failing test also matched. That allowance was unconditional. When the top-level `TestAccept` failed on its own — before any subtest ran — its failure still matched any `TestAccept/...` rule and was silently swallowed. The recent `RequireRuff` setup check (#5662) does exactly this on the integration runners (ruff isn't installed there), so `TestAccept` has been failing on every environment while the nightly reports green. This moves the parent-cascade handling out of rule matching and into failure analysis: - `ConfigRule.matches` reverts to plain forward matching — a rule matches a test or its subtree, nothing about parents. - `checkFailures` allows a failure when a rule matches **or** a subtest of it also failed. Go reports a parent's failure only after its subtests, so the failing subtest is already recorded when the parent is evaluated; no second pass is needed. Net effect: a parent failing because of a (known or unknown) subtest is still not double-counted, but a test that fails on its own with no failing subtest now surfaces as unexpected. This only makes the failure visible. Installing ruff on the integration runners is handled separately. ## Tests - `TestCheckFailures` exercises `checkFailures` end-to-end: parent-with-failing-subtest (allowed), parent-alone (the ruff case, surfaced), unlisted failure (surfaced), and fail-then-pass-on-retry (allowed). - `TestConfigRuleMatches` reverse-match cases now assert that a rule does not match a parent of the listed test. This pull request and its description were written by Isaac.
1 parent 822c719 commit cf54639

2 files changed

Lines changed: 115 additions & 14 deletions

File tree

tools/testrunner/main.go

Lines changed: 40 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ Download https://github.com/databricks/cli/blob/ciconfig/known_failures.txt in p
66
If download was successful by the time test runner finishes and test runner finishes with non-zero code,
77
analyze failures in the test output identified by --jsonfile option. If all failures are expected (listed in known_failures.txt)
88
then the failure is masked, the process exits with 0.
9+
10+
A parent test fails whenever one of its subtests fails, so a rule that lists a
11+
subtest also allows the parent to fail. That allowance only applies when a
12+
subtest actually failed; if the parent failed on its own (e.g. in setup, before
13+
any subtest ran), its failure is reported as unexpected.
914
*/
1015
package main
1116

@@ -158,6 +163,7 @@ func checkFailures(config *Config, jsonFile string, originalExitCode int) int {
158163
defer func() { _ = file.Close() }()
159164

160165
scanner := bufio.NewScanner(file)
166+
failed := map[string]bool{}
161167
unexpectedFailures := map[string]bool{}
162168

163169
for scanner.Scan() {
@@ -177,21 +183,33 @@ func checkFailures(config *Config, jsonFile string, originalExitCode int) int {
177183
}
178184

179185
result.Package, _ = strings.CutPrefix(result.Package, cutPrefix)
180-
181186
key := result.Package + " " + result.Test
182187

183-
if result.Action == "fail" {
188+
switch result.Action {
189+
case "fail":
190+
// Go reports a parent's failure only after its subtests, so any
191+
// failing subtest is already in `failed` by the time we get here.
192+
failed[key] = true
184193
matchedRule := config.matches(result.Package, result.Test)
185-
if matchedRule != "" {
194+
switch {
195+
case matchedRule != "":
186196
fmt.Printf("%s %s failure is allowed, matches rule %q\n", result.Package, result.Test, matchedRule)
187-
} else {
197+
case hasFailedSubtest(failed, key):
198+
// A parent test fails when a subtest fails; the subtest is
199+
// reported on its own, so the parent's failure is not counted.
200+
fmt.Printf("%s %s failure is allowed, a subtest failed\n", result.Package, result.Test)
201+
default:
188202
fmt.Printf("%s %s failure is not allowed\n", result.Package, result.Test)
189203
unexpectedFailures[key] = true
190204
}
191-
} else if result.Action == "pass" && unexpectedFailures[key] {
192-
fmt.Printf("%s %s passed on retry\n", result.Package, result.Test)
193-
// We run gotestsum with --rerun-fails, so we need to account for intermittent failures
194-
delete(unexpectedFailures, key)
205+
case "pass":
206+
// We run gotestsum with --rerun-fails, so a test that fails and
207+
// later passes is flaky, not a failure.
208+
delete(failed, key)
209+
if unexpectedFailures[key] {
210+
fmt.Printf("%s %s passed on retry\n", result.Package, result.Test)
211+
delete(unexpectedFailures, key)
212+
}
195213
}
196214
}
197215

@@ -207,6 +225,18 @@ func checkFailures(config *Config, jsonFile string, originalExitCode int) int {
207225
return originalExitCode
208226
}
209227

228+
// hasFailedSubtest reports whether any failing test is a strict subtest of key.
229+
// Keys are "<package> <test>", so a subtest's key is prefixed by key + "/".
230+
func hasFailedSubtest(failed map[string]bool, key string) bool {
231+
prefix := key + "/"
232+
for k := range failed {
233+
if strings.HasPrefix(k, prefix) {
234+
return true
235+
}
236+
}
237+
return false
238+
}
239+
210240
// CI Config Format
211241
//
212242
// The CI config is downloaded from the "ciconfig" branch of the repository.
@@ -328,9 +358,9 @@ func (r ConfigRule) matches(packageName, testName string) bool {
328358

329359
// Check test pattern
330360
if r.TestPrefix {
331-
return matchesPathPrefix(testName, r.TestPattern) || matchesPathPrefix(r.TestPattern, testName)
361+
return matchesPathPrefix(testName, r.TestPattern)
332362
}
333-
return testName == r.TestPattern || matchesPathPrefix(r.TestPattern, testName)
363+
return testName == r.TestPattern
334364
}
335365

336366
// matchesPathPrefix returns true if s matches pattern or starts with pattern + "/"

tools/testrunner/main_test.go

Lines changed: 75 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
package main
22

33
import (
4+
"encoding/json"
5+
"os"
6+
"path/filepath"
7+
"strings"
48
"testing"
59

610
"github.com/stretchr/testify/assert"
@@ -44,17 +48,18 @@ func TestConfigRuleMatches(t *testing.T) {
4448
{"* TestDeploy", "", "TestDeploy", true},
4549
{"bundle *", "bundle", "", true},
4650

47-
// Subtest failure results in parent test failure as well. So we allow strict prefixes to fail as well
48-
{"acceptance TestAccept/bundle/templates/default-python/combinations/classic", "acceptance", "TestAccept", true},
51+
// A rule does not match a parent of the listed test; the parent's
52+
// cascading failure is handled separately (see TestCheckFailures).
53+
{"acceptance TestAccept/bundle/templates/default-python/combinations/classic", "acceptance", "TestAccept", false},
4954
{"acceptance TestAccept/bundle/templates/default-python/combinations/classic", "acceptance", "TestAnother", false},
5055
{"acceptance TestAccept/bundle/templates/default-python/combinations/classic", "acceptance", "TestAccept/bundle/templates/default-python/combinations/classic/x", false},
5156

5257
// pattern version
53-
{"acceptance TestAccept/bundle/templates/default-python/combinations/classic/", "acceptance", "TestAccept", true},
58+
{"acceptance TestAccept/bundle/templates/default-python/combinations/classic/", "acceptance", "TestAccept", false},
5459
{"acceptance TestAccept/bundle/templates/default-python/combinations/classic/", "acceptance", "TestAnother", false},
5560
{"acceptance TestAccept/bundle/templates/default-python/combinations/classic/", "acceptance", "TestAccept/bundle/templates/default-python/combinations/classic/x", true},
5661
{"acceptance TestAccept/bundle/templates/default-python/combinations/classic/", "acceptance", "TestAccept/bundle/templates/default-python/combinations/classic", true},
57-
{"acceptance TestAccept/bundle/templates/default-python/combinations/classic/", "acceptance", "TestAccept/bundle/templates/default-python/combinations", true},
62+
{"acceptance TestAccept/bundle/templates/default-python/combinations/classic/", "acceptance", "TestAccept/bundle/templates/default-python/combinations", false},
5863
}
5964

6065
for _, tt := range tests {
@@ -66,3 +71,69 @@ func TestConfigRuleMatches(t *testing.T) {
6671
})
6772
}
6873
}
74+
75+
func TestCheckFailures(t *testing.T) {
76+
const config = "* TestAccept/ssh/connection\n"
77+
78+
tests := []struct {
79+
name string
80+
results []TestResult
81+
wantExit int
82+
}{
83+
{
84+
// A parent failing because a listed subtest failed is allowed.
85+
name: "parent allowed when subtest failed",
86+
results: []TestResult{
87+
{Action: "fail", Package: "acceptance", Test: "TestAccept/ssh/connection"},
88+
{Action: "fail", Package: "acceptance", Test: "TestAccept"},
89+
},
90+
wantExit: 0,
91+
},
92+
{
93+
// A parent failing on its own (no subtest failed) is not allowed,
94+
// even though a subtest is listed as a known failure. This is the
95+
// ruff-missing-in-setup case.
96+
name: "parent not allowed without failing subtest",
97+
results: []TestResult{
98+
{Action: "fail", Package: "acceptance", Test: "TestAccept"},
99+
},
100+
wantExit: 7,
101+
},
102+
{
103+
name: "unlisted failure is not allowed",
104+
results: []TestResult{
105+
{Action: "fail", Package: "acceptance", Test: "TestSomethingElse"},
106+
},
107+
wantExit: 7,
108+
},
109+
{
110+
// gotestsum reruns failed tests; a failure followed by a pass is
111+
// flaky, not a failure.
112+
name: "failure passing on retry is allowed",
113+
results: []TestResult{
114+
{Action: "fail", Package: "acceptance", Test: "TestAccept"},
115+
{Action: "pass", Package: "acceptance", Test: "TestAccept"},
116+
},
117+
wantExit: 0,
118+
},
119+
}
120+
121+
for _, tt := range tests {
122+
t.Run(tt.name, func(t *testing.T) {
123+
cfg, err := parseConfig(config)
124+
require.NoError(t, err)
125+
126+
jsonFile := filepath.Join(t.TempDir(), "results.json")
127+
var sb strings.Builder
128+
for _, r := range tt.results {
129+
line, err := json.Marshal(r)
130+
require.NoError(t, err)
131+
sb.Write(line)
132+
sb.WriteByte('\n')
133+
}
134+
require.NoError(t, os.WriteFile(jsonFile, []byte(sb.String()), 0o600))
135+
136+
assert.Equal(t, tt.wantExit, checkFailures(cfg, jsonFile, 7))
137+
})
138+
}
139+
}

0 commit comments

Comments
 (0)