Skip to content

Commit e457683

Browse files
authored
Merge pull request kubernetes#123444 from pohly/test-context-expect-no-error
ktesting: several fixes and better unit testing
2 parents a882a2b + 840ef14 commit e457683

File tree

10 files changed

+445
-66
lines changed

10 files changed

+445
-66
lines changed

test/utils/ktesting/assert.go

Lines changed: 44 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -63,17 +63,23 @@ func expect(tCtx TContext, actual interface{}, extra ...interface{}) gomega.Asse
6363
}
6464

6565
func expectNoError(tCtx TContext, err error, explain ...interface{}) {
66+
if err == nil {
67+
return
68+
}
69+
6670
tCtx.Helper()
6771

68-
description := buildDescription(explain)
72+
description := buildDescription(explain...)
6973

70-
var failure FailureError
71-
if errors.As(err, &failure) {
72-
if backtrace := failure.Backtrace(); backtrace != "" {
73-
if description != "" {
74-
tCtx.Log(description)
74+
if errors.Is(err, ErrFailure) {
75+
var failure FailureError
76+
if errors.As(err, &failure) {
77+
if backtrace := failure.Backtrace(); backtrace != "" {
78+
if description != "" {
79+
tCtx.Log(description)
80+
}
81+
tCtx.Logf("Failed at:\n %s", strings.ReplaceAll(backtrace, "\n", "\n "))
7582
}
76-
tCtx.Logf("Failed at:\n %s", strings.ReplaceAll(backtrace, "\n", "\n "))
7783
}
7884
if description != "" {
7985
tCtx.Fatalf("%s: %s", description, err.Error())
@@ -84,7 +90,7 @@ func expectNoError(tCtx TContext, err error, explain ...interface{}) {
8490
if description == "" {
8591
description = "Unexpected error"
8692
}
87-
tCtx.Logf("%s: %s\n%s", description, format.Object(err, 1))
93+
tCtx.Logf("%s:\n%s", description, format.Object(err, 1))
8894
tCtx.Fatalf("%s: %v", description, err.Error())
8995
}
9096

@@ -112,17 +118,18 @@ func buildDescription(explain ...interface{}) string {
112118
// is passed in. For example, errors can be checked with ExpectNoError:
113119
//
114120
// cb := func(func(tCtx ktesting.TContext) int {
115-
// value, err := doSomething(...)
116-
// ktesting.ExpectNoError(tCtx, err, "something failed")
117-
// return value
121+
// value, err := doSomething(...)
122+
// tCtx.ExpectNoError(err, "something failed")
123+
// assert(tCtx, 42, value, "the answer")
124+
// return value
118125
// }
119126
// tCtx.Eventually(cb).Should(gomega.Equal(42), "should be the answer to everything")
120127
//
121128
// If there is no value, then an error can be returned:
122129
//
123130
// cb := func(func(tCtx ktesting.TContext) error {
124-
// err := doSomething(...)
125-
// return err
131+
// err := doSomething(...)
132+
// return err
126133
// }
127134
// tCtx.Eventually(cb).Should(gomega.Succeed(), "foobar should succeed")
128135
//
@@ -137,28 +144,37 @@ func buildDescription(explain ...interface{}) string {
137144
// anymore, use [gomega.StopTrying]:
138145
//
139146
// cb := func(func(tCtx ktesting.TContext) int {
140-
// value, err := doSomething(...)
141-
// if errors.Is(err, SomeFinalErr) {
142-
// gomega.StopTrying("permanent failure).Wrap(err).Now()
143-
// }
144-
// ktesting.ExpectNoError(tCtx, err, "something failed")
145-
// return value
147+
// value, err := doSomething(...)
148+
// if errors.Is(err, SomeFinalErr) {
149+
// // This message completely replaces the normal
150+
// // failure message and thus should include all
151+
// // relevant information.
152+
// //
153+
// // github.com/onsi/gomega/format is a good way
154+
// // to format arbitrary data. It uses indention
155+
// // and falls back to YAML for Kubernetes API
156+
// // structs for readability.
157+
// gomega.StopTrying("permanent failure, last value:\n%s", format.Object(value, 1 /* indent one level */)).
158+
// Wrap(err).Now()
159+
// }
160+
// ktesting.ExpectNoError(tCtx, err, "something failed")
161+
// return value
146162
// }
147163
// tCtx.Eventually(cb).Should(gomega.Equal(42), "should be the answer to everything")
148164
//
149165
// To poll again after some specific timeout, use [gomega.TryAgainAfter]. This is
150166
// particularly useful in [Consistently] to ignore some intermittent error.
151167
//
152168
// cb := func(func(tCtx ktesting.TContext) int {
153-
// value, err := doSomething(...)
154-
// var intermittentErr SomeIntermittentError
155-
// if errors.As(err, &intermittentErr) {
156-
// gomega.TryAgainAfter(intermittentErr.RetryPeriod).Wrap(err).Now()
157-
// }
158-
// ktesting.ExpectNoError(tCtx, err, "something failed")
159-
// return value
160-
// }
161-
// tCtx.Eventually(cb).Should(gomega.Equal(42), "should be the answer to everything")
169+
// value, err := doSomething(...)
170+
// var intermittentErr SomeIntermittentError
171+
// if errors.As(err, &intermittentErr) {
172+
// gomega.TryAgainAfter(intermittentErr.RetryPeriod).Wrap(err).Now()
173+
// }
174+
// ktesting.ExpectNoError(tCtx, err, "something failed")
175+
// return value
176+
// }
177+
// tCtx.Eventually(cb).Should(gomega.Equal(42), "should be the answer to everything")
162178
func Eventually[T any](tCtx TContext, cb func(TContext) T) gomega.AsyncAssertion {
163179
tCtx.Helper()
164180
return gomega.NewWithT(tCtx).Eventually(tCtx, func(ctx context.Context) (val T, err error) {

test/utils/ktesting/assert_test.go

Lines changed: 106 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -19,21 +19,14 @@ package ktesting
1919
import (
2020
"errors"
2121
"fmt"
22-
"regexp"
2322
"testing"
2423
"time"
2524

2625
"github.com/onsi/gomega"
27-
"github.com/stretchr/testify/assert"
2826
)
2927

30-
func TestAsync(t *testing.T) {
31-
for name, tc := range map[string]struct {
32-
cb func(TContext)
33-
expectNoFail bool
34-
expectError string
35-
expectDuration time.Duration
36-
}{
28+
func TestAssert(t *testing.T) {
29+
for name, tc := range map[string]testcase{
3730
"eventually-timeout": {
3831
cb: func(tCtx TContext) {
3932
Eventually(tCtx, func(tCtx TContext) int {
@@ -165,30 +158,114 @@ The function passed to Consistently returned the following error:
165158
expectError: `Timed out while waiting on TryAgainAfter after x.y s.
166159
told to try again after 1ms: intermittent error`,
167160
},
161+
162+
"expect-equal": {
163+
cb: func(tCtx TContext) {
164+
tCtx.Expect(1).To(gomega.Equal(42))
165+
},
166+
expectError: `Expected
167+
<int>: 1
168+
to equal
169+
<int>: 42`,
170+
},
171+
172+
"expect-no-error-success": {
173+
cb: func(tCtx TContext) {
174+
tCtx.ExpectNoError(nil)
175+
},
176+
expectNoFail: true,
177+
},
178+
"expect-no-error-normal-error": {
179+
cb: func(tCtx TContext) {
180+
tCtx.ExpectNoError(errors.New("fake error"))
181+
},
182+
expectError: `Unexpected error: fake error`,
183+
expectLog: `<klog header>: Unexpected error:
184+
<*errors.errorString | 0xXXXX>:
185+
fake error
186+
{s: "fake error"}
187+
`,
188+
},
189+
"expect-no-error-failure": {
190+
cb: func(tCtx TContext) {
191+
tCtx.ExpectNoError(fmt.Errorf("doing something: %w", FailureError{Msg: "fake error"}))
192+
},
193+
expectError: `doing something: fake error`,
194+
},
195+
"expect-no-error-explanation-string": {
196+
cb: func(tCtx TContext) {
197+
tCtx.ExpectNoError(fmt.Errorf("doing something: %w", FailureError{Msg: "fake error"}), "testing error checking")
198+
},
199+
expectError: `testing error checking: doing something: fake error`,
200+
},
201+
"expect-no-error-explanation-printf": {
202+
cb: func(tCtx TContext) {
203+
tCtx.ExpectNoError(fmt.Errorf("doing something: %w", FailureError{Msg: "fake error"}), "testing %s %d checking", "error", 42)
204+
},
205+
expectError: `testing error 42 checking: doing something: fake error`,
206+
},
207+
"expect-no-error-explanation-callback": {
208+
cb: func(tCtx TContext) {
209+
tCtx.ExpectNoError(fmt.Errorf("doing something: %w", FailureError{Msg: "fake error"}), func() string { return "testing error checking" })
210+
},
211+
expectError: `testing error checking: doing something: fake error`,
212+
},
213+
"expect-no-error-backtrace": {
214+
cb: func(tCtx TContext) {
215+
tCtx.ExpectNoError(fmt.Errorf("doing something: %w", FailureError{Msg: "fake error", FullStackTrace: "abc\nxyz"}))
216+
},
217+
expectError: `doing something: fake error`,
218+
expectLog: `<klog header>: Failed at:
219+
abc
220+
xyz
221+
`,
222+
},
223+
"expect-no-error-backtrace-and-explanation": {
224+
cb: func(tCtx TContext) {
225+
tCtx.ExpectNoError(fmt.Errorf("doing something: %w", FailureError{Msg: "fake error", FullStackTrace: "abc\nxyz"}), "testing error checking")
226+
},
227+
expectError: `testing error checking: doing something: fake error`,
228+
expectLog: `<klog header>: testing error checking
229+
<klog header>: Failed at:
230+
abc
231+
xyz
232+
`,
233+
},
234+
235+
"output": {
236+
cb: func(tCtx TContext) {
237+
tCtx.Log("Log", "a", "b", 42)
238+
tCtx.Logf("Logf %s %s %d", "a", "b", 42)
239+
tCtx.Error("Error", "a", "b", 42)
240+
tCtx.Errorf("Errorf %s %s %d", "a", "b", 42)
241+
},
242+
expectLog: `<klog header>: Log a b 42
243+
<klog header>: Logf a b 42
244+
`,
245+
expectError: `Error a b 42
246+
Errorf a b 42`,
247+
},
248+
"fatal": {
249+
cb: func(tCtx TContext) {
250+
tCtx.Fatal("Error", "a", "b", 42)
251+
// not reached
252+
tCtx.Log("Log")
253+
},
254+
expectError: `Error a b 42`,
255+
},
256+
"fatalf": {
257+
cb: func(tCtx TContext) {
258+
tCtx.Fatalf("Error %s %s %d", "a", "b", 42)
259+
// not reached
260+
tCtx.Log("Log")
261+
},
262+
expectError: `Error a b 42`,
263+
},
168264
} {
169265
tc := tc
170266
t.Run(name, func(t *testing.T) {
171267
t.Parallel()
172-
tCtx := Init(t)
173-
var err error
174-
tCtx, finalize := WithError(tCtx, &err)
175-
start := time.Now()
176-
func() {
177-
defer finalize()
178-
tc.cb(tCtx)
179-
}()
180-
duration := time.Since(start)
181-
assert.InDelta(t, tc.expectDuration.Seconds(), duration.Seconds(), 0.1, fmt.Sprintf("callback invocation duration %s", duration))
182-
assert.Equal(t, !tc.expectNoFail, tCtx.Failed(), "Failed()")
183-
if tc.expectError == "" {
184-
assert.NoError(t, err)
185-
} else if assert.NotNil(t, err) {
186-
t.Logf("Result:\n%s", err.Error())
187-
errMsg := err.Error()
188-
errMsg = regexp.MustCompile(`[[:digit:]]+\.[[:digit:]]+s`).ReplaceAllString(errMsg, "x.y s")
189-
errMsg = regexp.MustCompile(`0x[[:xdigit:]]+`).ReplaceAllString(errMsg, "0xXXXX")
190-
assert.Equal(t, tc.expectError, errMsg)
191-
}
268+
tc.run(t)
192269
})
193270
}
194271
}

test/utils/ktesting/errorcontext.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
"strings"
2323
"sync"
2424

25+
"github.com/onsi/gomega"
2526
"k8s.io/klog/v2"
2627
)
2728

@@ -138,6 +139,16 @@ func (eCtx *errorContext) CleanupCtx(cb func(TContext)) {
138139
cleanupCtx(eCtx, cb)
139140
}
140141

142+
func (eCtx *errorContext) Expect(actual interface{}, extra ...interface{}) gomega.Assertion {
143+
eCtx.Helper()
144+
return expect(eCtx, actual, extra...)
145+
}
146+
147+
func (eCtx *errorContext) ExpectNoError(err error, explain ...interface{}) {
148+
eCtx.Helper()
149+
expectNoError(eCtx, err, explain...)
150+
}
151+
141152
func (eCtx *errorContext) Logger() klog.Logger {
142153
return klog.FromContext(eCtx)
143154
}

test/utils/ktesting/helper_test.go

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/*
2+
Copyright 2024 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+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package ktesting
18+
19+
import (
20+
"fmt"
21+
"regexp"
22+
"strings"
23+
"testing"
24+
"time"
25+
26+
"github.com/stretchr/testify/assert"
27+
)
28+
29+
// testcase wraps a callback which is called with a TContext that intercepts
30+
// errors and log output. Those get compared.
31+
type testcase struct {
32+
cb func(TContext)
33+
expectNoFail bool
34+
expectError string
35+
expectDuration time.Duration
36+
expectLog string
37+
}
38+
39+
func (tc testcase) run(t *testing.T) {
40+
bufferT := &logBufferT{T: t}
41+
tCtx := Init(bufferT)
42+
var err error
43+
tCtx, finalize := WithError(tCtx, &err)
44+
start := time.Now()
45+
func() {
46+
defer finalize()
47+
tc.cb(tCtx)
48+
}()
49+
50+
log := bufferT.log.String()
51+
t.Logf("Log output:\n%s\n", log)
52+
if tc.expectLog != "" {
53+
assert.Equal(t, tc.expectLog, normalize(log))
54+
} else if log != "" {
55+
t.Error("Expected no log output.")
56+
}
57+
58+
duration := time.Since(start)
59+
assert.InDelta(t, tc.expectDuration.Seconds(), duration.Seconds(), 0.1, fmt.Sprintf("callback invocation duration %s", duration))
60+
assert.Equal(t, !tc.expectNoFail, tCtx.Failed(), "Failed()")
61+
if tc.expectError == "" {
62+
assert.NoError(t, err)
63+
} else if assert.NotNil(t, err) {
64+
t.Logf("Result:\n%s", err.Error())
65+
assert.Equal(t, tc.expectError, normalize(err.Error()))
66+
}
67+
}
68+
69+
// normalize replaces parts of message texts which may vary with constant strings.
70+
func normalize(msg string) string {
71+
// duration
72+
msg = regexp.MustCompile(`[[:digit:]]+\.[[:digit:]]+s`).ReplaceAllString(msg, "x.y s")
73+
// hex pointer value
74+
msg = regexp.MustCompile(`0x[[:xdigit:]]+`).ReplaceAllString(msg, "0xXXXX")
75+
// per-test klog header
76+
msg = regexp.MustCompile(`[EI][[:digit:]]{4} [[:digit:]]{2}:[[:digit:]]{2}:[[:digit:]]{2}\.[[:digit:]]{6}\]`).ReplaceAllString(msg, "<klog header>:")
77+
return msg
78+
}
79+
80+
type logBufferT struct {
81+
*testing.T
82+
log strings.Builder
83+
}
84+
85+
func (l *logBufferT) Log(args ...any) {
86+
l.log.WriteString(fmt.Sprintln(args...))
87+
}
88+
89+
func (l *logBufferT) Logf(format string, args ...any) {
90+
l.log.WriteString(fmt.Sprintf(format, args...))
91+
l.log.WriteRune('\n')
92+
}

0 commit comments

Comments
 (0)