Fix up ErrorMatcher from feedback

a) Rename the type and drop the constructor
b) Make MatchErrors() into a Test() method

For followup:

c) Consider making ByType() assumed
d) Consider making ByField() assumed and handle nil as "don't care"
e) Consider making ByValue() assumed and handle nil as "don't care"
This commit is contained in:
Tim Hockin 2025-03-03 10:23:18 -08:00
parent 2b025e6459
commit 0a9f492eed
No known key found for this signature in database
4 changed files with 82 additions and 76 deletions

View File

@ -20894,8 +20894,8 @@ func TestValidateEndpointsCreate(t *testing.T) {
t.Run(k, func(t *testing.T) { t.Run(k, func(t *testing.T) {
errs := ValidateEndpointsCreate(&v.endpoints) errs := ValidateEndpointsCreate(&v.endpoints)
// TODO: set .RequireOriginWhenInvalid() once metadata is done // TODO: set .RequireOriginWhenInvalid() once metadata is done
matcher := fldtest.Match().ByType().ByField().ByOrigin() matcher := fldtest.ErrorMatcher{}.ByType().ByField().ByOrigin()
fldtest.MatchErrors(t, v.expectedErrs, errs, matcher) matcher.Test(t, v.expectedErrs, errs)
}) })
} }
} }
@ -22891,8 +22891,8 @@ func TestValidateTopologySpreadConstraints(t *testing.T) {
for _, tc := range testCases { for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
errs := validateTopologySpreadConstraints(tc.constraints, fieldPath, tc.opts) errs := validateTopologySpreadConstraints(tc.constraints, fieldPath, tc.opts)
matcher := fldtest.Match().ByType().ByField().ByOrigin().RequireOriginWhenInvalid() matcher := fldtest.ErrorMatcher{}.ByType().ByField().ByOrigin().RequireOriginWhenInvalid()
fldtest.MatchErrors(t, tc.wantFieldErrors, errs, matcher) matcher.Test(t, tc.wantFieldErrors, errs)
}) })
} }
} }

View File

@ -1089,7 +1089,8 @@ func TestRegisterValidate(t *testing.T) {
} else { } else {
results = s.ValidateUpdate(ctx, tc.options, tc.object, tc.oldObject, tc.subresource...) results = s.ValidateUpdate(ctx, tc.options, tc.object, tc.oldObject, tc.subresource...)
} }
fieldtesting.MatchErrors(t, tc.expected, results, fieldtesting.Match().ByType().ByField().ByOrigin()) matcher := fieldtesting.ErrorMatcher{}.ByType().ByField().ByOrigin()
matcher.Test(t, tc.expected, results)
}) })
} }
} }

View File

@ -26,50 +26,16 @@ import (
field "k8s.io/apimachinery/pkg/util/validation/field" field "k8s.io/apimachinery/pkg/util/validation/field"
) )
// MatchErrors compares two ErrorLists with the specified matcher, and fails // ErrorMatcher is a helper for comparing field.Error objects.
// the test if they don't match. If a given "want" error matches multiple "got" type ErrorMatcher struct {
// errors, they will all be consumed. This might be OK (e.g. if there are // TODO(thockin): consider whether type is ever NOT required, maybe just
// multiple errors on the same field from the same origin) or it might be an // assume it.
// insufficiently specific matcher, so these will be logged. matchType bool
func MatchErrors(t *testing.T, want, got field.ErrorList, matcher *Matcher) { // TODO(thockin): consider whether field could be assumed - if the
t.Helper() // "want" error has a nil field, don't match on field.
matchField bool
remaining := got // TODO(thockin): consider whether value could be assumed - if the
for _, w := range want { // "want" error has a nil value, don't match on field.
tmp := make(field.ErrorList, 0, len(remaining))
n := 0
for _, g := range remaining {
if matcher.Matches(w, g) {
n++
} else {
tmp = append(tmp, g)
}
}
if n == 0 {
t.Errorf("expected an error matching:\n%s", matcher.Render(w))
} else if n > 1 {
// This is not necessarily and error, but it's worth logging in
// case it's not what the test author intended.
t.Logf("multiple errors matched:\n%s", matcher.Render(w))
}
remaining = tmp
}
if len(remaining) > 0 {
for _, e := range remaining {
t.Errorf("unmatched error:\n%s", Match().Exactly().Render(e))
}
}
}
// Match returns a new Matcher.
func Match() *Matcher {
return &Matcher{}
}
// Matcher is a helper for comparing field.Error objects.
type Matcher struct {
matchType bool
matchField bool
matchValue bool matchValue bool
matchOrigin bool matchOrigin bool
matchDetail func(want, got string) bool matchDetail func(want, got string) bool
@ -78,7 +44,7 @@ type Matcher struct {
// Matches returns true if the two field.Error objects match according to the // Matches returns true if the two field.Error objects match according to the
// configured criteria. // configured criteria.
func (m *Matcher) Matches(want, got *field.Error) bool { func (m ErrorMatcher) Matches(want, got *field.Error) bool {
if m.matchType && want.Type != got.Type { if m.matchType && want.Type != got.Type {
return false return false
} }
@ -105,8 +71,8 @@ func (m *Matcher) Matches(want, got *field.Error) bool {
} }
// Render returns a string representation of the specified Error object, // Render returns a string representation of the specified Error object,
// according to the criteria configured in the Matcher. // according to the criteria configured in the ErrorMatcher.
func (m *Matcher) Render(e *field.Error) string { func (m ErrorMatcher) Render(e *field.Error) string {
buf := strings.Builder{} buf := strings.Builder{}
comma := func() { comma := func() {
@ -138,66 +104,104 @@ func (m *Matcher) Render(e *field.Error) string {
return "{" + buf.String() + "}" return "{" + buf.String() + "}"
} }
// Exactly configures the matcher to match all fields exactly. // Exactly returns a derived ErrorMatcher which matches all fields exactly.
func (m *Matcher) Exactly() *Matcher { func (m ErrorMatcher) Exactly() ErrorMatcher {
return m.ByType().ByField().ByValue().ByOrigin().ByDetailExact() return m.ByType().ByField().ByValue().ByOrigin().ByDetailExact()
} }
// Exactly configures the matcher to match errors by type. // ByType returns a derived ErrorMatcher which also matches by type.
func (m *Matcher) ByType() *Matcher { func (m ErrorMatcher) ByType() ErrorMatcher {
m.matchType = true m.matchType = true
return m return m
} }
// ByField configures the matcher to match errors by field path. // ByField returns a derived ErrorMatcher which also matches by field path.
func (m *Matcher) ByField() *Matcher { func (m ErrorMatcher) ByField() ErrorMatcher {
m.matchField = true m.matchField = true
return m return m
} }
// ByValue configures the matcher to match errors by the errant value. // ByValue returns a derived ErrorMatcher which also matches by the errant
func (m *Matcher) ByValue() *Matcher { // value.
func (m ErrorMatcher) ByValue() ErrorMatcher {
m.matchValue = true m.matchValue = true
return m return m
} }
// ByOrigin configures the matcher to match errors by the origin. // ByOrigin returns a derived ErrorMatcher which also matches by the origin.
func (m *Matcher) ByOrigin() *Matcher { func (m ErrorMatcher) ByOrigin() ErrorMatcher {
m.matchOrigin = true m.matchOrigin = true
return m return m
} }
// RequireOriginWhenInvalid configures the matcher to require the Origin field // RequireOriginWhenInvalid returns a derived ErrorMatcher which also requires
// to be set when the Type is Invalid and the matcher is matching by Origin. // the Origin field to be set when the Type is Invalid and the matcher is
func (m *Matcher) RequireOriginWhenInvalid() *Matcher { // matching by Origin.
func (m ErrorMatcher) RequireOriginWhenInvalid() ErrorMatcher {
m.requireOriginWhenInvalid = true m.requireOriginWhenInvalid = true
return m return m
} }
// ByDetailExact configures the matcher to match errors by the exact detail // ByDetailExact returns a derived ErrorMatcher which also matches errors by
// string. // the exact detail string.
func (m *Matcher) ByDetailExact() *Matcher { func (m ErrorMatcher) ByDetailExact() ErrorMatcher {
m.matchDetail = func(want, got string) bool { m.matchDetail = func(want, got string) bool {
return got == want return got == want
} }
return m return m
} }
// ByDetailSubstring configures the matcher to match errors by a substring of // ByDetailSubstring returns a derived ErrorMatcher which also matches errors
// the detail string. // by a substring of the detail string.
func (m *Matcher) ByDetailSubstring() *Matcher { func (m ErrorMatcher) ByDetailSubstring() ErrorMatcher {
m.matchDetail = func(want, got string) bool { m.matchDetail = func(want, got string) bool {
return strings.Contains(got, want) return strings.Contains(got, want)
} }
return m return m
} }
// ByDetailRegexp configures the matcher to match errors by a regular // ByDetailRegexp returns a derived ErrorMatcher which also matches errors by a
// expression of the detail string, where the "want" string is assumed to be a // regular expression of the detail string, where the "want" string is assumed
// valid regular expression. // to be a valid regular expression.
func (m *Matcher) ByDetailRegexp() *Matcher { func (m ErrorMatcher) ByDetailRegexp() ErrorMatcher {
m.matchDetail = func(want, got string) bool { m.matchDetail = func(want, got string) bool {
return regexp.MustCompile(want).MatchString(got) return regexp.MustCompile(want).MatchString(got)
} }
return m return m
} }
// Test compares two ErrorLists by the criteria configured in this matcher, and
// fails the test if they don't match. If a given "want" error matches multiple
// "got" errors, they will all be consumed. This might be OK (e.g. if there are
// multiple errors on the same field from the same origin) or it might be an
// insufficiently specific matcher, so these will be logged.
func (m ErrorMatcher) Test(tb testing.TB, want, got field.ErrorList) {
tb.Helper()
remaining := got
for _, w := range want {
tmp := make(field.ErrorList, 0, len(remaining))
n := 0
for _, g := range remaining {
if m.Matches(w, g) {
n++
} else {
tmp = append(tmp, g)
}
}
if n == 0 {
tb.Errorf("expected an error matching:\n%s", m.Render(w))
} else if n > 1 {
// This is not necessarily and error, but it's worth logging in
// case it's not what the test author intended.
tb.Logf("multiple errors matched:\n%s", m.Render(w))
}
remaining = tmp
}
if len(remaining) > 0 {
for _, e := range remaining {
exactly := m.Exactly() // makes a copy
tb.Errorf("unmatched error:\n%s", exactly.Render(e))
}
}
}

View File

@ -154,7 +154,8 @@ func TestValidateDeclaratively(t *testing.T) {
} else { } else {
results = ValidateUpdateDeclaratively(ctx, tc.options, scheme, tc.object, tc.oldObject) results = ValidateUpdateDeclaratively(ctx, tc.options, scheme, tc.object, tc.oldObject)
} }
fieldtesting.MatchErrors(t, tc.expected, results, fieldtesting.Match().ByType().ByField().ByOrigin()) matcher := fieldtesting.ErrorMatcher{}.ByType().ByField().ByOrigin()
matcher.Test(t, tc.expected, results)
}) })
} }
} }