Skip to content

Commit 8dc556d

Browse files
committed
Update golden test framework
* Move to envtest by default (instead of mock-kubeapiserver) * Create shared code for capturing requests / normalization. * Use a more conventional env var WRITE_GOLDEN_OUTPUT * Support object rewriting
1 parent b3bb66f commit 8dc556d

File tree

12 files changed

+514
-121
lines changed

12 files changed

+514
-121
lines changed

dev/test

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ cd "${REPO_ROOT}"
2727

2828
set -x
2929

30+
# Download the kubebuilder assets for envtest
31+
export KUBEBUILDER_ASSETS=$(go run sigs.k8s.io/controller-runtime/tools/setup-envtest@latest use -p path)
32+
3033
pushd mockkubeapiserver
3134
CGO_ENABLED=0 go test -count=1 -v ./...
3235
popd

docs/addon/walkthrough/tests.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ This means that the output is materialized and checked in to the repo; this
3333
proves to be very handy for understanding the impact of a change.
3434

3535
There's also a helpful "cheat" function, which rewrite the output when you run
36-
the tests locally - set the HACK_AUTOFIX_EXPECTED_OUTPUT env var to a non-empty
36+
the tests locally - set the WRITE_GOLDEN_OUTPUT env var to a non-empty
3737
string. This is useful when you have a big set of changes; it's just as easy to
3838
review the changes yourself in the diff and there's not a ton of value in typing
3939
them out.
@@ -87,7 +87,7 @@ the env-var cheat code.
8787
```bash
8888
cd pkg/controller/{{operator}}/tests
8989
touch tests/simple.out.yaml
90-
HACK_AUTOFIX_EXPECTED_OUTPUT=1 go test ./...
90+
WRITE_GOLDEN_OUTPUT=1 go test ./...
9191
```
9292

9393
1. Verify the output is reproducible

examples/guestbook-operator/controllers/guestbook_controller_test.go

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,28 @@ limitations under the License.
1717
package controllers
1818

1919
import (
20+
"path/filepath"
2021
"testing"
2122

23+
"k8s.io/klog/v2/klogr"
24+
"sigs.k8s.io/controller-runtime/pkg/envtest"
25+
"sigs.k8s.io/controller-runtime/pkg/log"
2226
api "sigs.k8s.io/kubebuilder-declarative-pattern/examples/guestbook-operator/api/v1alpha1"
2327
"sigs.k8s.io/kubebuilder-declarative-pattern/pkg/test/golden"
2428
)
2529

2630
func TestGuestbook(t *testing.T) {
27-
v := golden.NewValidator(t, api.SchemeBuilder)
31+
log.SetLogger(klogr.New())
32+
33+
env := &envtest.Environment{
34+
CRDInstallOptions: envtest.CRDInstallOptions{
35+
ErrorIfPathMissing: true,
36+
Paths: []string{
37+
filepath.Join("..", "config", "crd", "bases"),
38+
},
39+
},
40+
}
41+
v := golden.NewValidator(t, env, api.AddToScheme)
2842
dr := &GuestbookReconciler{
2943
Client: v.Client(),
3044
}
@@ -33,5 +47,5 @@ func TestGuestbook(t *testing.T) {
3347
t.Fatalf("creating reconciler: %v", err)
3448
}
3549

36-
v.Validate(dr.Reconciler)
50+
v.Validate(&dr.Reconciler, golden.ValidateOptions{})
3751
}

examples/guestbook-operator/controllers/tests/patches-stable.in.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ apiVersion: addons.example.org/v1alpha1
22
kind: Guestbook
33
metadata:
44
name: guestbook-sample
5+
namespace: default
56
spec:
67
channel: stable
78
patches:

examples/guestbook-operator/controllers/tests/simple-stable.in.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,6 @@ apiVersion: addons.example.org/v1alpha1
22
kind: Guestbook
33
metadata:
44
name: guestbook-sample
5+
namespace: default
56
spec:
67
channel: stable

ktest/httprecorder/kube_normalize.go

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package httprecorder
2+
3+
import (
4+
"sort"
5+
"strconv"
6+
"strings"
7+
"testing"
8+
"time"
9+
10+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
11+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
12+
"k8s.io/client-go/rest"
13+
"sigs.k8s.io/kubebuilder-declarative-pattern/applylib/forked/k8s.io/apimachinery/pkg/util/sets"
14+
)
15+
16+
// placeholderTime is a placeholder time that is used to replace the timestamps, so that we can golden test.
17+
var placeholderTime = metav1.Date(2025, 4, 1, 0, 0, 0, 0, time.UTC)
18+
19+
// NormalizeKubeRequestLog normalizes a kube request log for golden testing,
20+
// it replaces ephemeral values with placeholder values,
21+
// sorts requests into a predictable order,
22+
// and removes non-deterministic headers.
23+
func NormalizeKubeRequestLog(t *testing.T, requestLog *RequestLog, restConfig *rest.Config) {
24+
// Whether we're using a proxy or not (http vs https), IPv6 or not, we want to normalize the host to kube-apiserver
25+
apiserverRealHost := strings.ReplaceAll(restConfig.Host, "[::]", "127.0.0.1")
26+
apiserverRealHost = strings.TrimPrefix(apiserverRealHost, "https://")
27+
apiserverRealHost = strings.TrimPrefix(apiserverRealHost, "http://")
28+
apiserverRealHost = strings.TrimSuffix(apiserverRealHost, "/")
29+
requestLog.ReplaceURLPrefix("https://"+apiserverRealHost, "https://kube-apiserver")
30+
requestLog.ReplaceURLPrefix("http://"+apiserverRealHost, "https://kube-apiserver")
31+
apiserverRealHost = strings.ReplaceAll(apiserverRealHost, "127.0.0.1", "[::]")
32+
requestLog.ReplaceURLPrefix("https://"+apiserverRealHost, "https://kube-apiserver")
33+
requestLog.ReplaceURLPrefix("http://"+apiserverRealHost, "https://kube-apiserver")
34+
35+
requestLog.RemoveUserAgent()
36+
37+
requestLog.RemoveHeader("X-Kubernetes-Pf-Flowschema-Uid")
38+
requestLog.RemoveHeader("X-Kubernetes-Pf-Prioritylevel-Uid")
39+
requestLog.RemoveHeader("Audit-Id")
40+
41+
requestLog.RewriteBodies(t, func(body map[string]any) {
42+
u := unstructured.Unstructured{Object: body}
43+
uid := u.GetUID()
44+
if uid != "" {
45+
u.SetUID("fake-uid")
46+
}
47+
48+
replaceIfPresent(t, body, placeholderTime.Format(time.RFC3339), "metadata", "creationTimestamp")
49+
50+
if managedFields := u.GetManagedFields(); managedFields != nil {
51+
for i := range managedFields {
52+
managedFields[i].Time = &placeholderTime
53+
}
54+
u.SetManagedFields(managedFields)
55+
}
56+
})
57+
58+
// Rewrite the resource version to a predictable value
59+
{
60+
resourceVersions := sets.New[int]()
61+
requestLog.RewriteBodies(t, func(body map[string]any) {
62+
u := unstructured.Unstructured{Object: body}
63+
rv := u.GetResourceVersion()
64+
if rv != "" {
65+
// These aren't guaranteed to be ints, but in practice they are, and this is test code.
66+
rvInt, err := strconv.Atoi(rv)
67+
if err != nil {
68+
t.Errorf("error converting resource version %q to int: %v", rv, err)
69+
return
70+
}
71+
resourceVersions.Insert(rvInt)
72+
}
73+
})
74+
resourceVersionsList := resourceVersions.UnsortedList()
75+
sort.Ints(resourceVersionsList)
76+
rewriteResourceVersion := map[string]string{}
77+
for i, rv := range resourceVersionsList {
78+
rewriteResourceVersion[strconv.Itoa(rv)] = strconv.Itoa(1000 + i)
79+
}
80+
requestLog.RewriteBodies(t, func(body map[string]any) {
81+
u := unstructured.Unstructured{Object: body}
82+
rv := u.GetResourceVersion()
83+
u.SetResourceVersion(rewriteResourceVersion[rv])
84+
})
85+
}
86+
87+
requestLog.SortGETs()
88+
}
89+
90+
func replaceIfPresent(t *testing.T, body map[string]any, replace string, path ...string) {
91+
u := unstructured.Unstructured{Object: body}
92+
_, found, err := unstructured.NestedFieldNoCopy(u.Object, path...)
93+
if err != nil {
94+
t.Errorf("error getting nested field %v: %v", path, err)
95+
return
96+
}
97+
if found {
98+
unstructured.SetNestedField(u.Object, replace, path...)
99+
}
100+
}

ktest/httprecorder/request_log.go

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ func writeBody(w io.StringWriter, body string, pretty bool) {
7070
}
7171

7272
if pretty {
73-
var obj interface{}
73+
var obj any
7474
if err := json.Unmarshal([]byte(body), &obj); err == nil {
7575
b, err := json.MarshalIndent(obj, "", " ")
7676
if err == nil {
@@ -186,8 +186,11 @@ func (l *RequestLog) RemoveHeader(k string) {
186186
defer l.mutex.Unlock()
187187

188188
for i := range l.entries {
189-
r := &l.entries[i].Request
190-
r.Header.Del(k)
189+
request := &l.entries[i].Request
190+
request.Header.Del(k)
191+
192+
response := &l.entries[i].Response
193+
response.Header.Del(k)
191194
}
192195
}
193196

@@ -260,3 +263,44 @@ func (l *RequestLog) RegexReplaceURL(t *testing.T, find string, replace string)
260263
request.URL = u
261264
}
262265
}
266+
267+
// RewriteBodies rewrites the bodies of the requests and responses.
268+
// The function fn is called with the body as a map[string]any.
269+
func (l *RequestLog) RewriteBodies(t *testing.T, fn func(body map[string]any)) {
270+
l.mutex.Lock()
271+
defer l.mutex.Unlock()
272+
273+
for i := range l.entries {
274+
request := &l.entries[i].Request
275+
request.Body = rewriteBody(t, request.Body, fn)
276+
277+
response := &l.entries[i].Response
278+
response.Body = rewriteBody(t, response.Body, fn)
279+
}
280+
}
281+
282+
// rewriteBody rewrites the body of a request or response, assuming it is a JSON object.
283+
func rewriteBody(t *testing.T, body string, fn func(body map[string]any)) string {
284+
if body == "" {
285+
return body
286+
}
287+
288+
var obj any
289+
if err := json.Unmarshal([]byte(body), &obj); err != nil {
290+
t.Errorf("failed to unmarshal body: %v", err)
291+
return body
292+
}
293+
294+
m, ok := obj.(map[string]any)
295+
if !ok {
296+
return body
297+
}
298+
fn(m)
299+
300+
b, err := json.MarshalIndent(obj, "", " ")
301+
if err != nil {
302+
t.Errorf("failed to marshal body: %v", err)
303+
return body
304+
}
305+
return string(b)
306+
}

ktest/testharness/golden.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,12 @@ func RunGoldenTests(t *testing.T, basedir string, fn func(h *Harness, dir string
3939
}
4040
}
4141

42+
func ShouldWriteGoldenOutput() bool {
43+
return os.Getenv("WRITE_GOLDEN_OUTPUT") != ""
44+
}
45+
4246
func (h *Harness) CompareGoldenFile(p string, got string) {
43-
if os.Getenv("WRITE_GOLDEN_OUTPUT") != "" {
47+
if ShouldWriteGoldenOutput() {
4448
// Short-circuit when the output is correct
4549
b, err := os.ReadFile(p)
4650
if err == nil && bytes.Equal(b, []byte(got)) {

0 commit comments

Comments
 (0)