Skip to content

Commit 39bf45a

Browse files
authored
Merge pull request #71 from numtide/introduce-envtestutil
Add envtest utility package as a separate module
2 parents 4d8b69e + f887c00 commit 39bf45a

28 files changed

Lines changed: 6757 additions & 12 deletions

Makefile

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,25 +5,29 @@
55
# Each module must be tagged with its directory path prefix for Go module resolution
66
# Example tags: v0.1.0 (root), api/v0.1.0, pkg/cluster-handler/v0.1.0, etc.
77
#
8+
# Auto-discovered modules: finds all directories with go.mod files
89
# Can be overridden: MODULES="./api ./pkg/resource-handler" make test
9-
# or: MODULES="$(make changed-modules)" make test
10-
MODULES ?= . ./api ./pkg/cluster-handler ./pkg/data-handler ./pkg/resource-handler
10+
# or: MODULES="$(shell make changed-modules)" make test
11+
MODULES ?= $(shell find . -name go.mod -exec dirname {} \; | sort)
1112

1213
# Detect changed modules compared to origin/main
1314
# Usage: make test MODULES="$(shell make changed-modules)"
1415
.PHONY: changed-modules
1516
changed-modules:
16-
@if git rev-parse --verify origin/main >/dev/null 2>&1; then \
17+
@changed_files=$$(if git rev-parse --verify origin/main >/dev/null 2>&1; then \
1718
git diff --name-only origin/main...HEAD 2>/dev/null; \
1819
else \
1920
git diff --name-only HEAD 2>/dev/null; \
20-
fi | awk -F/ '{ \
21-
if ($$1 == "api") print "./api"; \
22-
else if ($$1 == "pkg" && $$2 == "cluster-handler") print "./pkg/cluster-handler"; \
23-
else if ($$1 == "pkg" && $$2 == "data-handler") print "./pkg/data-handler"; \
24-
else if ($$1 == "pkg" && $$2 == "resource-handler") print "./pkg/resource-handler"; \
25-
else if ($$1 == "cmd" || $$1 == "main.go" || $$1 == "Dockerfile") print "."; \
26-
}' | sort -u | tr '\n' ' ' || echo "$(MODULES)"
21+
fi); \
22+
modules=$$(find . -name go.mod -exec dirname {} \;); \
23+
for mod in $$modules; do \
24+
modpath=$${mod#./}; \
25+
if [ "$$mod" = "." ]; then \
26+
echo "$$changed_files" | grep -qE '^[^/]+\.(go|mod|sum)$$|^(cmd|internal)/' && echo "$$mod"; \
27+
elif echo "$$changed_files" | grep -q "^$$modpath/"; then \
28+
echo "$$mod"; \
29+
fi; \
30+
done 2>/dev/null | sort -u | tr '\n' ' '
2731

2832
# Version from git tags (for root module - operator binary)
2933
# Root module uses tags like v0.1.0 (without prefix)
@@ -281,7 +285,7 @@ test-integration: manifests generate fmt vet setup-envtest ## Run integration te
281285
echo "==> Integration testing $$mod..."; \
282286
(cd $$mod && \
283287
KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)" \
284-
GOWORK=off go test -tags=integration $$(go list ./... | grep -v /e2e) -coverprofile=cover.out) || exit 1; \
288+
GOWORK=off go test -tags=integration,verbose $$(go list ./... | grep -v /e2e) -coverprofile=cover.out) || exit 1; \
285289
done
286290

287291
.PHONY: test-coverage
@@ -295,7 +299,7 @@ test-coverage: manifests generate fmt vet setup-envtest ## Generate coverage rep
295299
echo "==> Coverage for $$mod..."; \
296300
(cd $$mod && \
297301
KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)" \
298-
GOWORK=off go test -tags=integration ./... -coverprofile=$$project_root/coverage/$$modname.out -covermode=atomic && \
302+
GOWORK=off go test -tags=integration,verbose ./... -coverprofile=$$project_root/coverage/$$modname.out -covermode=atomic && \
299303
GOWORK=off go tool cover -html=$$project_root/coverage/$$modname.out -o=$$project_root/coverage/$$modname.html) || exit 1; \
300304
echo "Generated: coverage/$$modname.html"; \
301305
done

pkg/testutil/compare.go

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
package testutil
2+
3+
import (
4+
"time"
5+
6+
"github.com/google/go-cmp/cmp"
7+
"github.com/google/go-cmp/cmp/cmpopts"
8+
appsv1 "k8s.io/api/apps/v1"
9+
corev1 "k8s.io/api/core/v1"
10+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
11+
"k8s.io/apimachinery/pkg/types"
12+
)
13+
14+
// IgnoreMetaRuntimeFields returns cmp.Options that ignore runtime-generated
15+
// Kubernetes fields. Use this when comparing expected vs actual Kubernetes
16+
// resources in tests.
17+
func IgnoreMetaRuntimeFields() cmp.Options {
18+
return cmp.Options{
19+
// Ignore ObjectMeta fields that are set by the API server
20+
cmpopts.IgnoreFields(metav1.ObjectMeta{},
21+
"UID", // Generated by API server
22+
"ResourceVersion", // Changes on every update
23+
"Generation", // Auto-incremented
24+
"CreationTimestamp", // Set at creation time
25+
"DeletionTimestamp", // Set when deletion starts
26+
"DeletionGracePeriodSeconds",
27+
"ManagedFields", // Server-side apply tracking
28+
"SelfLink", // Deprecated but may exist
29+
),
30+
31+
// Ignore TypeMeta fields (often empty in client responses)
32+
// NOTE: This is commented out as this has no impact in most cases.
33+
// Consider removing this altogether.
34+
// cmpopts.IgnoreFields(metav1.TypeMeta{},
35+
// "Kind",
36+
// "APIVersion",
37+
// ),
38+
39+
// Ignore Status subresource (tested separately)
40+
cmpopts.IgnoreFields(metav1.Condition{},
41+
"LastTransitionTime", // Time-based, always different
42+
),
43+
44+
// Ignore time.Time fields (always different)
45+
cmpopts.IgnoreTypes(time.Time{}),
46+
cmpopts.IgnoreTypes(metav1.Time{}),
47+
48+
// Ignore UID type
49+
cmpopts.IgnoreTypes(types.UID("")),
50+
}
51+
}
52+
53+
// IgnoreServiceRuntimeFields ignores Service fields that are assigned at
54+
// runtime by Kubernetes.
55+
func IgnoreServiceRuntimeFields() cmp.Option {
56+
return cmpopts.IgnoreFields(corev1.ServiceSpec{},
57+
// Randomly allocated by Kubernetes from service CIDR range
58+
"ClusterIP",
59+
"ClusterIPs",
60+
61+
// Derived from cluster IP stack configuration (IPv4, IPv6, or dual-stack)
62+
"IPFamilies",
63+
"IPFamilyPolicy",
64+
65+
// Default policy set based on cluster configuration
66+
"InternalTrafficPolicy",
67+
68+
// Defaults to "None"
69+
"SessionAffinity",
70+
)
71+
}
72+
73+
// IgnoreStatefulSetRuntimeFields ignores StatefulSet status fields that are
74+
// updated at runtime by the StatefulSet controller.
75+
func IgnoreStatefulSetRuntimeFields() cmp.Option {
76+
return cmpopts.IgnoreFields(appsv1.StatefulSetStatus{},
77+
// Replica state counters (updated by controller based on pod status)
78+
"Replicas",
79+
"ReadyReplicas",
80+
"CurrentReplicas",
81+
"UpdatedReplicas",
82+
"AvailableReplicas",
83+
84+
// Revision tracking (generated hashes by controller for rolling updates)
85+
"CurrentRevision",
86+
"UpdateRevision",
87+
88+
// Controller reconciliation metadata
89+
"ObservedGeneration", // Tracks which spec generation was reconciled
90+
"CollisionCount", // Counter for name collision avoidance
91+
"Conditions", // Status conditions updated by controller
92+
)
93+
}
94+
95+
// IgnoreDeploymentRuntimeFields ignores Deployment status fields that are
96+
// updated at runtime by the Deployment controller.
97+
func IgnoreDeploymentRuntimeFields() cmp.Option {
98+
return cmpopts.IgnoreFields(appsv1.DeploymentStatus{},
99+
// Replica state counters (updated by controller based on ReplicaSet status)
100+
"Replicas",
101+
"ReadyReplicas",
102+
"AvailableReplicas",
103+
"UnavailableReplicas",
104+
"UpdatedReplicas",
105+
106+
// Controller reconciliation metadata
107+
"ObservedGeneration", // Tracks which spec generation was reconciled
108+
"CollisionCount", // Counter for ReplicaSet name collision avoidance
109+
"Conditions", // Status conditions updated by controller
110+
)
111+
}
112+
113+
// IgnorePodSpecDefaults ignores all PodSpec and Container defaults applied by
114+
// Kubernetes, including ImagePullPolicy. Use when you only care about
115+
// explicitly set fields.
116+
func IgnorePodSpecDefaults() cmp.Option {
117+
return cmp.Options{
118+
cmpopts.IgnoreFields(corev1.PodSpec{},
119+
// Pod lifecycle defaults
120+
"RestartPolicy", // Defaults to "Always"
121+
"TerminationGracePeriodSeconds", // Defaults to 30
122+
"DNSPolicy", // Defaults to "ClusterFirst"
123+
"SchedulerName", // Defaults to "default-scheduler"
124+
"SecurityContext", // Default PodSecurityContext applied
125+
),
126+
cmpopts.IgnoreFields(corev1.Container{},
127+
// Container defaults
128+
"TerminationMessagePath", // Defaults to "/dev/termination-log"
129+
"TerminationMessagePolicy", // Defaults to "File"
130+
"ImagePullPolicy", // Defaults: Always for :latest, IfNotPresent otherwise
131+
),
132+
}
133+
}
134+
135+
// IgnorePodSpecDefaultsExceptPullPolicy ignores PodSpec defaults but preserves
136+
// ImagePullPolicy for verification. Use when you want to assert ImagePullPolicy
137+
// is correct.
138+
func IgnorePodSpecDefaultsExceptPullPolicy() cmp.Option {
139+
return cmp.Options{
140+
cmpopts.IgnoreFields(corev1.PodSpec{},
141+
// Pod lifecycle defaults
142+
"RestartPolicy", // Defaults to "Always"
143+
"TerminationGracePeriodSeconds", // Defaults to 30
144+
"DNSPolicy", // Defaults to "ClusterFirst"
145+
"SchedulerName", // Defaults to "default-scheduler"
146+
"SecurityContext", // Default PodSecurityContext applied
147+
),
148+
cmpopts.IgnoreFields(corev1.Container{},
149+
// Container termination logging
150+
"TerminationMessagePath", // Defaults to "/dev/termination-log"
151+
"TerminationMessagePolicy", // Defaults to "File"
152+
// Note: ImagePullPolicy NOT ignored - preserved for assertions
153+
),
154+
}
155+
}
156+
157+
// IgnoreStatefulSetSpecDefaults ignores StatefulSetSpec fields that have
158+
// Kubernetes defaults applied.
159+
func IgnoreStatefulSetSpecDefaults() cmp.Option {
160+
return cmpopts.IgnoreFields(appsv1.StatefulSetSpec{},
161+
// StatefulSet-specific defaults
162+
"PodManagementPolicy", // Defaults to "OrderedReady"
163+
"RevisionHistoryLimit", // Defaults to 10
164+
"UpdateStrategy", // Defaults to RollingUpdate with partition=0
165+
"PersistentVolumeClaimRetentionPolicy", // Defaults to Retain/Retain
166+
)
167+
}
168+
169+
// IgnoreDeploymentSpecDefaults ignores DeploymentSpec fields that have
170+
// Kubernetes defaults applied.
171+
func IgnoreDeploymentSpecDefaults() cmp.Option {
172+
return cmpopts.IgnoreFields(
173+
appsv1.DeploymentSpec{},
174+
// Deployment-specific defaults
175+
"Strategy", // Defaults to RollingUpdate with MaxSurge=25%, MaxUnavailable=25%
176+
"RevisionHistoryLimit", // Defaults to 10
177+
"ProgressDeadlineSeconds", // Defaults to 600 seconds (10 minutes)
178+
)
179+
}
180+
181+
// filterByFieldName returns a filter function that checks if the last path element
182+
// is a struct field with the given name. Extracted for testability.
183+
func filterByFieldName(fieldName string) func(cmp.Path) bool {
184+
return func(p cmp.Path) bool {
185+
// Check if the current path step is a struct field with the target name
186+
if len(p) == 0 {
187+
return false
188+
}
189+
if sf, ok := p[len(p)-1].(cmp.StructField); ok {
190+
return sf.Name() == fieldName
191+
}
192+
return false
193+
}
194+
}
195+
196+
// IgnoreObjectMetaCompletely ignores the entire ObjectMeta (use when you only
197+
// care about Spec).
198+
// Uses a filter function to match any struct field named "ObjectMeta".
199+
func IgnoreObjectMetaCompletely() cmp.Option {
200+
return cmp.FilterPath(filterByFieldName("ObjectMeta"), cmp.Ignore())
201+
}
202+
203+
// IgnoreStatus ignores the Status subresource completely.
204+
//
205+
// Because cmpopts.IgnoreFields requires the first param to be an actual struct
206+
// type, this uses a filter function to match any struct field named "Status".
207+
func IgnoreStatus() cmp.Option {
208+
return cmp.FilterPath(filterByFieldName("Status"), cmp.Ignore())
209+
}
210+
211+
// CompareOptions returns common options for comparing Kubernetes objects.
212+
// By default, ignores metadata and status.
213+
func CompareOptions() cmp.Options {
214+
return cmp.Options{
215+
IgnoreMetaRuntimeFields(),
216+
IgnoreStatus(),
217+
}
218+
}
219+
220+
// CompareSpecOnly returns options for comparing only Spec fields.
221+
// Ignores all metadata and status.
222+
func CompareSpecOnly() cmp.Options {
223+
return cmp.Options{
224+
IgnoreObjectMetaCompletely(),
225+
IgnoreStatus(),
226+
}
227+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package testutil
2+
3+
import (
4+
"testing"
5+
6+
"github.com/google/go-cmp/cmp"
7+
corev1 "k8s.io/api/core/v1"
8+
)
9+
10+
// TestFilterByFieldName tests the filter function directly.
11+
func TestFilterByFieldName(t *testing.T) {
12+
t.Parallel()
13+
tests := map[string]struct {
14+
fieldName string
15+
path cmp.Path
16+
want bool
17+
}{
18+
"Status - empty path": {
19+
fieldName: "Status",
20+
path: cmp.Path{},
21+
want: false,
22+
},
23+
"ObjectMeta - empty path": {
24+
fieldName: "ObjectMeta",
25+
path: cmp.Path{},
26+
want: false,
27+
},
28+
}
29+
30+
for name, tc := range tests {
31+
t.Run(name, func(t *testing.T) {
32+
t.Parallel()
33+
filter := filterByFieldName(tc.fieldName)
34+
got := filter(tc.path)
35+
if got != tc.want {
36+
t.Errorf(
37+
"filterByFieldName(%s) with empty path = %v, want %v",
38+
tc.fieldName,
39+
got,
40+
tc.want,
41+
)
42+
}
43+
})
44+
}
45+
}
46+
47+
// TestFilterByFieldName_Integration verifies it works with real Service objects.
48+
func TestFilterByFieldName_Integration(t *testing.T) {
49+
t.Parallel()
50+
svc1 := &corev1.Service{
51+
Status: corev1.ServiceStatus{
52+
LoadBalancer: corev1.LoadBalancerStatus{
53+
Ingress: []corev1.LoadBalancerIngress{{IP: "1.2.3.4"}},
54+
},
55+
},
56+
}
57+
svc2 := &corev1.Service{
58+
Status: corev1.ServiceStatus{
59+
LoadBalancer: corev1.LoadBalancerStatus{
60+
Ingress: []corev1.LoadBalancerIngress{{IP: "5.6.7.8"}},
61+
},
62+
},
63+
}
64+
65+
// Should match when ignoring Status
66+
diff := cmp.Diff(svc1, svc2, IgnoreStatus())
67+
if diff != "" {
68+
t.Errorf("Services should match when ignoring Status, but found diff:\n%s", diff)
69+
}
70+
}

0 commit comments

Comments
 (0)