Skip to content

Commit cf8bf2e

Browse files
schrejkillianmuldoonsbueringer
committed
komega: add EqualObject matcher
Co-authored-by: killianmuldoon <[email protected]> Co-authored-by: Stefan Bueringer <[email protected]>
1 parent 9ee63fc commit cf8bf2e

File tree

3 files changed

+318
-1
lines changed

3 files changed

+318
-1
lines changed

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ require (
77
github.com/fsnotify/fsnotify v1.5.1
88
github.com/go-logr/logr v1.2.0
99
github.com/go-logr/zapr v1.2.0
10+
github.com/google/go-cmp v0.5.5
1011
github.com/onsi/ginkgo v1.16.5
1112
github.com/onsi/gomega v1.18.1
1213
github.com/prometheus/client_golang v1.11.1
@@ -33,7 +34,6 @@ require (
3334
github.com/gogo/protobuf v1.3.2 // indirect
3435
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
3536
github.com/golang/protobuf v1.5.2 // indirect
36-
github.com/google/go-cmp v0.5.5 // indirect
3737
github.com/google/gofuzz v1.1.0 // indirect
3838
github.com/google/uuid v1.1.2 // indirect
3939
github.com/googleapis/gnostic v0.5.5 // indirect

pkg/envtest/komega/equalobject.go

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
/*
2+
Copyright 2022 The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
http://www.apache.org/licenses/LICENSE-2.0
8+
Unless required by applicable law or agreed to in writing, software
9+
distributed under the License is distributed on an "AS IS" BASIS,
10+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
See the License for the specific language governing permissions and
12+
limitations under the License.
13+
*/
14+
15+
package komega
16+
17+
import (
18+
"fmt"
19+
"reflect"
20+
"strings"
21+
22+
"github.com/google/go-cmp/cmp"
23+
"github.com/onsi/gomega/format"
24+
"github.com/onsi/gomega/types"
25+
"k8s.io/apimachinery/pkg/runtime"
26+
"k8s.io/apimachinery/pkg/util/sets"
27+
)
28+
29+
// These package variables hold pre-created commonly used options that can be used to reduce the manual work involved in
30+
// identifying the paths that need to be compared for testing equality between objects.
31+
var (
32+
// IgnoreAutogeneratedMetadata contains the paths for all the metadata fields that are commonly set by the
33+
// client and APIServer. This is used as a MatchOption for situations when only user-provided metadata is relevant.
34+
IgnoreAutogeneratedMetadata = IgnorePaths{
35+
"ObjectMeta.UID",
36+
"ObjectMeta.Generation",
37+
"ObjectMeta.CreationTimestamp",
38+
"ObjectMeta.ResourceVersion",
39+
"ObjectMeta.ManagedFields",
40+
"ObjectMeta.DeletionGracePeriodSeconds",
41+
"ObjectMeta.DeletionTimestamp",
42+
"ObjectMeta.SelfLink",
43+
"ObjectMeta.GenerateName",
44+
}
45+
)
46+
47+
// equalObjectMatcher is a Gomega matcher used to establish equality between two Kubernetes runtime.Objects.
48+
type equalObjectMatcher struct {
49+
// original holds the object that will be used to Match.
50+
original runtime.Object
51+
52+
// diffPaths contains the paths that differ between two objects.
53+
diffPaths []string
54+
55+
// options holds the options that identify what should and should not be matched.
56+
options *EqualObjectOptions
57+
}
58+
59+
// EqualObject returns a Matcher for the passed Kubernetes runtime.Object with the passed Options. This function can be
60+
// used as a Gomega Matcher in Gomega Assertions.
61+
func EqualObject(original runtime.Object, opts ...EqualObjectOption) types.GomegaMatcher {
62+
matchOptions := &EqualObjectOptions{}
63+
matchOptions = matchOptions.ApplyOptions(opts)
64+
65+
return &equalObjectMatcher{
66+
options: matchOptions,
67+
original: original,
68+
}
69+
}
70+
71+
// Match compares the current object to the passed object and returns true if the objects are the same according to
72+
// the Matcher and MatchOptions.
73+
func (m *equalObjectMatcher) Match(actual interface{}) (success bool, err error) {
74+
// Nil checks required first here for:
75+
// 1) Nil equality which returns true
76+
// 2) One object nil which returns an error
77+
actualIsNil := reflect.ValueOf(actual).IsNil()
78+
originalIsNil := reflect.ValueOf(m.original).IsNil()
79+
80+
if actualIsNil && originalIsNil {
81+
return true, nil
82+
}
83+
if actualIsNil || originalIsNil {
84+
return false, fmt.Errorf("can not compare an object with a nil. original %v , actual %v", m.original, actual)
85+
}
86+
87+
m.diffPaths = m.calculateDiff(actual)
88+
89+
return len(m.diffPaths) == 0, nil
90+
}
91+
92+
// FailureMessage returns a message comparing the full objects after an unexpected failure to match has occurred.
93+
func (m *equalObjectMatcher) FailureMessage(actual interface{}) (message string) {
94+
return fmt.Sprintf("the following fields were expected to match but did not:\n%v\n%s", m.diffPaths,
95+
format.Message(actual, "expected to match", m.original))
96+
}
97+
98+
// NegatedFailureMessage returns a string stating that all fields matched, even though that was not expected.
99+
func (m *equalObjectMatcher) NegatedFailureMessage(actual interface{}) (message string) {
100+
return fmt.Sprintf("it was expected that some fields do not match, but all of them did")
101+
}
102+
103+
// diffReporter is a custom recorder for cmp.Diff which records all paths that are
104+
// different between two objects.
105+
type diffReporter struct {
106+
stack []cmp.PathStep
107+
diffPaths []string
108+
}
109+
110+
func (r *diffReporter) PushStep(s cmp.PathStep) {
111+
r.stack = append(r.stack, s)
112+
}
113+
114+
func (r *diffReporter) Report(res cmp.Result) {
115+
if !res.Equal() {
116+
r.diffPaths = append(r.diffPaths, r.currPath())
117+
}
118+
}
119+
120+
func (r *diffReporter) currPath() string {
121+
p := []string{}
122+
for _, s := range r.stack[1:] {
123+
switch s := s.(type) {
124+
case cmp.StructField, cmp.SliceIndex, cmp.MapIndex:
125+
p = append(p, s.String())
126+
}
127+
}
128+
return strings.Join(p, "")[1:]
129+
}
130+
131+
func (r *diffReporter) PopStep() {
132+
r.stack = r.stack[:len(r.stack)-1]
133+
}
134+
135+
// calculateDiff calculates the difference between two objects and returns the
136+
// paths of the fields that do not match.
137+
func (m *equalObjectMatcher) calculateDiff(actual interface{}) []string {
138+
r := diffReporter{}
139+
cmp.Diff(m.original, actual, cmp.Reporter(&r))
140+
return filterDiffPaths(*m.options, r.diffPaths)
141+
}
142+
143+
// filterDiffPaths filters the diff paths using the paths in EqualObjectOptions.
144+
func filterDiffPaths(opts EqualObjectOptions, paths []string) []string {
145+
result := sets.NewString(paths...)
146+
for _, c := range result.List() {
147+
if len(opts.matchPaths) > 0 && !matchesAnyPath(c, opts.matchPaths) {
148+
result.Delete(c)
149+
continue
150+
}
151+
if matchesAnyPath(c, opts.ignorePaths) {
152+
result.Delete(c)
153+
}
154+
}
155+
return result.List()
156+
}
157+
158+
// matchesAnyPath returns true if path matches any of the path prefixes.
159+
// It respects the name boundaries within paths, so 'ObjectMeta.Name' does not
160+
// match 'ObjectMeta.Namespace' for example.
161+
func matchesAnyPath(path string, prefixes []string) bool {
162+
for _, prefix := range prefixes {
163+
if strings.HasPrefix(path, prefix) {
164+
rpath := path[len(prefix):]
165+
// It's a full attribute name if it's a full match, or the next character of the path is
166+
// either a . or a [
167+
if len(rpath) == 0 || rpath[0] == '.' || rpath[0] == '[' {
168+
return true
169+
}
170+
}
171+
}
172+
return false
173+
}
174+
175+
// EqualObjectOption describes an Option that can be applied to a Matcher.
176+
type EqualObjectOption interface {
177+
// ApplyToEqualObjectMatcher applies this configuration to the given MatchOption.
178+
ApplyToEqualObjectMatcher(options *EqualObjectOptions)
179+
}
180+
181+
// EqualObjectOptions holds the available types of EqualObjectOptions that can be applied to a Matcher.
182+
type EqualObjectOptions struct {
183+
ignorePaths []string
184+
matchPaths []string
185+
}
186+
187+
// ApplyOptions adds the passed MatchOptions to the MatchOptions struct.
188+
func (o *EqualObjectOptions) ApplyOptions(opts []EqualObjectOption) *EqualObjectOptions {
189+
for _, opt := range opts {
190+
opt.ApplyToEqualObjectMatcher(o)
191+
}
192+
return o
193+
}
194+
195+
// IgnorePaths instructs the Matcher to ignore given paths when computing a diff.
196+
type IgnorePaths []string
197+
198+
// ApplyToEqualObjectMatcher applies this configuration to the given MatchOptions.
199+
func (i IgnorePaths) ApplyToEqualObjectMatcher(opts *EqualObjectOptions) {
200+
opts.ignorePaths = append(opts.ignorePaths, i...)
201+
}
202+
203+
// MatchPaths instructs the Matcher to restrict its diff to the given paths. If empty the Matcher will look at all paths.
204+
type MatchPaths []string
205+
206+
// ApplyToEqualObjectMatcher applies this configuration to the given MatchOptions.
207+
func (i MatchPaths) ApplyToEqualObjectMatcher(opts *EqualObjectOptions) {
208+
opts.matchPaths = append(opts.matchPaths, i...)
209+
}
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
package komega
2+
3+
import (
4+
"fmt"
5+
"testing"
6+
7+
. "github.com/onsi/gomega"
8+
appsv1 "k8s.io/api/apps/v1"
9+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
10+
)
11+
12+
func TestEqualObjectMatcher(t *testing.T) {
13+
cases := []struct {
14+
desc string
15+
expected appsv1.Deployment
16+
actual appsv1.Deployment
17+
opts []EqualObjectOption
18+
result bool
19+
}{
20+
{
21+
desc: "succeed with equal objects",
22+
expected: appsv1.Deployment{
23+
ObjectMeta: metav1.ObjectMeta{
24+
Name: "test",
25+
},
26+
},
27+
actual: appsv1.Deployment{
28+
ObjectMeta: metav1.ObjectMeta{
29+
Name: "test",
30+
},
31+
},
32+
result: true,
33+
},
34+
{
35+
desc: "fail with non equal objects",
36+
expected: appsv1.Deployment{
37+
ObjectMeta: metav1.ObjectMeta{
38+
Name: "test",
39+
},
40+
},
41+
actual: appsv1.Deployment{
42+
ObjectMeta: metav1.ObjectMeta{
43+
Name: "somethingelse",
44+
},
45+
},
46+
result: false,
47+
},
48+
{
49+
desc: "succeeds if ignored field does not match",
50+
expected: appsv1.Deployment{
51+
ObjectMeta: metav1.ObjectMeta{
52+
Name: "test",
53+
Labels: map[string]string{"somelabel": "somevalue"},
54+
OwnerReferences: []metav1.OwnerReference{{
55+
Name: "controller",
56+
}},
57+
},
58+
},
59+
actual: appsv1.Deployment{
60+
ObjectMeta: metav1.ObjectMeta{
61+
Name: "somethingelse",
62+
Labels: map[string]string{"somelabel": "anothervalue"},
63+
OwnerReferences: []metav1.OwnerReference{{
64+
Name: "another",
65+
}},
66+
},
67+
},
68+
result: true,
69+
opts: []EqualObjectOption{
70+
IgnorePaths{
71+
"ObjectMeta.Name",
72+
"ObjectMeta.Labels[\"somelabel\"]",
73+
"ObjectMeta.OwnerReferences[0].Name",
74+
},
75+
},
76+
},
77+
{
78+
desc: "succeeds if all allowed fields match, and some others do not",
79+
expected: appsv1.Deployment{
80+
ObjectMeta: metav1.ObjectMeta{
81+
Name: "test",
82+
Namespace: "default",
83+
},
84+
},
85+
actual: appsv1.Deployment{
86+
ObjectMeta: metav1.ObjectMeta{
87+
Name: "test",
88+
Namespace: "special",
89+
},
90+
},
91+
result: true,
92+
opts: []EqualObjectOption{
93+
MatchPaths{
94+
"ObjectMeta.Name",
95+
},
96+
},
97+
},
98+
}
99+
100+
for _, c := range cases {
101+
t.Run(c.desc, func(t *testing.T) {
102+
g := NewWithT(t)
103+
m := EqualObject(&c.expected, c.opts...)
104+
g.Expect(m.Match(&c.actual)).To(Equal(c.result))
105+
fmt.Println(m.FailureMessage(&c.actual))
106+
})
107+
}
108+
}

0 commit comments

Comments
 (0)