ktesting: more flexible Eventually/Consistently

Being able to call arbitrary functions is useful, even if it means giving up
some compile-time checking. Because we now use reflection,
Eventually/Consistently can be methods.
This commit is contained in:
Patrick Ohly
2025-12-16 12:34:16 +01:00
parent c47ad64820
commit 01765f4a41
2 changed files with 257 additions and 37 deletions

View File

@@ -20,6 +20,7 @@ import (
"context"
"errors"
"fmt"
"reflect"
"github.com/onsi/gomega"
"github.com/onsi/gomega/format"
@@ -163,32 +164,38 @@ func buildDescription(explain ...interface{}) string {
return fmt.Sprintf(explain[0].(string), explain[1:]...)
}
// Eventually wraps [gomega.Eventually] such that a failure will be reported via
// TContext.Fatal.
// Deprecated: use tCtx.Eventually instead.
func Eventually[T any](tCtx TContext, cb func(TContext) T) gomega.AsyncAssertion {
tCtx.Helper()
return tCtx.Eventually(cb)
}
// Eventually wraps [gomega.Eventually]. Supported argument types are:
// - A function with a `tCtx ktesting.TContext` or `ctx context.Context`
// parameter plus additional parameters and arbitrary return values.
// - A value which changes over time, usually a channel.
//
// In contrast to [gomega.Eventually], the parameter is strongly typed. It must
// accept a TContext as first argument and return one value, the one which is
// then checked with the matcher.
// For functions, the context is provided by Eventually. Additional parameters
// must be passed via WithParameters. The first return value is passed to the Gomega
// matcher, all others (in particular an additional error) must be null.
// As a special case, a function with `tCtx ktesting.TContext` and no return
// values can be combined with gomega.Succeed as matcher.
//
// In contrast to direct usage of [gomega.Eventually], make additional
// assertions inside the callback is okay as long as they use the TContext that
// is passed in. For example, errors can be checked with ExpectNoError:
// In contrast to direct usage of [gomega.Eventually], making additional
// assertions inside the callback function is okay in all cases as long as they
// use the TContext that is passed in. An assertion failure is considered
// temporary, so Eventually will continue to poll. This can be used to check a
// value with multiple assertions instead of writing a custom matcher:
//
// cb := func(func(tCtx ktesting.TContext) int {
// cb := func(tCtx ktesting.TContext) {
// value, err := doSomething(...)
// tCtx.ExpectNoError(err, "something failed")
// assert(tCtx, 42, value, "the answer")
// return value
// tCtx.Assert(value.a).To(gomega.Equal(42), "the answer")
// tCtx.Assert(value.b).To(gomega.Equal("the fish"), "thanks")
// }
// tCtx.Eventually(cb).Should(gomega.Equal(42), "should be the answer to everything")
// tCtx.Eventually(cb).Should(gomega.Succeed())
//
// If there is no value, then an error can be returned:
//
// cb := func(func(tCtx ktesting.TContext) error {
// err := doSomething(...)
// return err
// }
// tCtx.Eventually(cb).Should(gomega.Succeed(), "foobar should succeed")
// The test stops in case of a failure. To continue, use AssertEventually.
//
// The default Gomega poll interval and timeout are used. Setting a specific
// timeout may be useful:
@@ -214,7 +221,7 @@ func buildDescription(explain ...interface{}) string {
// gomega.StopTrying("permanent failure, last value:\n%s", format.Object(value, 1 /* indent one level */)).
// Wrap(err).Now()
// }
// ktesting.ExpectNoError(tCtx, err, "something failed")
// tCtx.ExpectNoError(err, "something failed")
// return value
// }
// tCtx.Eventually(cb).Should(gomega.Equal(42), "should be the answer to everything")
@@ -228,29 +235,127 @@ func buildDescription(explain ...interface{}) string {
// if errors.As(err, &intermittentErr) {
// gomega.TryAgainAfter(intermittentErr.RetryPeriod).Wrap(err).Now()
// }
// ktesting.ExpectNoError(tCtx, err, "something failed")
// tCtx.ExpectNoError(err, "something failed")
// return value
// }
// tCtx.Eventually(cb).Should(gomega.Equal(42), "should be the answer to everything")
func Eventually[T any](tCtx TContext, cb func(TContext) T) gomega.AsyncAssertion {
func (tc *TC) Eventually(arg any) gomega.AsyncAssertion {
tc.Helper()
return tc.newAsyncAssertion(gomega.NewWithT(tc).Eventually, arg)
}
// AssertEventually is a variant of Eventually which merely records a failure
// without stopping the test.
func (tc *TC) AssertEventually(arg any) gomega.AsyncAssertion {
tc.Helper()
return tc.newAsyncAssertion(gomega.NewWithT(assertTestingT{tc}).Eventually, arg)
}
// Deprecated: use tCtx.Consistently instead.
func Consistently[T any](tCtx TContext, cb func(TContext) T) gomega.AsyncAssertion {
tCtx.Helper()
return gomega.NewWithT(tCtx).Eventually(tCtx, func(ctx context.Context) (val T, err error) {
tCtx := WithContext(tCtx, ctx)
tCtx, finalize := WithError(tCtx, &err)
defer finalize()
tCtx = WithCancel(tCtx)
return cb(tCtx), nil
})
return tCtx.Consistently(cb)
}
// Consistently wraps [gomega.Consistently] the same way as [Eventually] wraps
// [gomega.Eventually].
func Consistently[T any](tCtx TContext, cb func(TContext) T) gomega.AsyncAssertion {
tCtx.Helper()
return gomega.NewWithT(tCtx).Consistently(tCtx, func(ctx context.Context) (val T, err error) {
tCtx := WithContext(tCtx, ctx)
tCtx, finalize := WithError(tCtx, &err)
defer finalize()
return cb(tCtx), nil
})
func (tc *TC) Consistently(arg any) gomega.AsyncAssertion {
tc.Helper()
return tc.newAsyncAssertion(gomega.NewWithT(tc).Consistently, arg)
}
// AssertConsistently is a variant of Consistently which merely records a failure
// without stopping the test.
func (tc *TC) AssertConsistently(arg any) gomega.AsyncAssertion {
tc.Helper()
return tc.newAsyncAssertion(gomega.NewWithT(assertTestingT{tc}).Consistently, arg)
}
func (tc *TC) newAsyncAssertion(eventuallyOrConsistently func(actualOrCtx any, args ...any) gomega.AsyncAssertion, arg any) gomega.AsyncAssertion {
tc.Helper()
// switch arg := arg.(type) {
// case func(tCtx TContext):
// // Tricky to handle via reflect, so let's cover this directly...
// return eventuallyOrConsistently(tc, func(g gomega.Gomega, ctx context.Context) (err error) {
// tCtx := WithContext(tc, ctx)
// tCtx, finalize := WithError(tCtx, &err)
// defer finalize()
// arg(tCtx)
// })
// default:
v := reflect.ValueOf(arg)
if v.Kind() != reflect.Func {
// Gomega must deal with it.
return eventuallyOrConsistently(tc, arg)
}
t := v.Type()
if t.NumIn() == 0 || t.In(0) != tContextType {
// Not a function we can wrap.
return eventuallyOrConsistently(tc, arg)
}
// Build a wrapper function with context instead of TContext as first parameter.
// The wrapper then builds that TContext when called and invokes the actual function.
in := make([]reflect.Type, t.NumIn())
in[0] = contextType
for i := 1; i < t.NumIn(); i++ {
in[i] = t.In(i)
}
out := make([]reflect.Type, t.NumOut())
for i := range t.NumOut() {
out[i] = t.Out(i)
}
// The last result must always be an error because we need the ability to return assertion
// failures, so we may have to add an error result value if the function doesn't
// already have it.
addErrResult := t.NumOut() == 0 || t.Out(t.NumOut()-1) != errorType
if addErrResult {
out = append(out, errorType)
}
wrapperType := reflect.FuncOf(in, out, t.IsVariadic())
wrapper := reflect.MakeFunc(wrapperType, func(args []reflect.Value) (results []reflect.Value) {
var err error
tCtx, finalize := tc.WithContext(args[0].Interface().(context.Context)).
WithCancel().
WithError(&err)
args[0] = reflect.ValueOf(tCtx)
defer func() {
// This runs *after* finalize.
// If we are returning normally, then we must inject back the err
// value that was set by finalize.
if r := recover(); r != nil {
// Nope, no results needed.
panic(r)
}
errValue := reflect.ValueOf(err)
if err == nil {
// reflect doesn't like this ("returned zero Value").
// We need a value of the right type.
errValue = reflect.New(errorType).Elem()
}
// If the call panicked and the panic was recoved
// by finalize(), then results is still nil.
// We need to fill in null values.
if len(results) == 0 && t.NumOut() > 0 {
for i := range t.NumOut() {
results = append(results, reflect.New(t.Out(i)).Elem())
}
}
if addErrResult {
results = append(results, errValue)
return
}
if results[len(results)-1].IsNil() && err != nil {
results[len(results)-1] = errValue
}
}()
defer finalize() // Must be called directly, otherwise it cannot recover a panic.
return v.Call(args)
})
return eventuallyOrConsistently(tc, wrapper.Interface())
}
var (
contextType = reflect.TypeFor[context.Context]()
errorType = reflect.TypeFor[error]()
tContextType = reflect.TypeFor[TContext]()
)

View File

@@ -63,6 +63,7 @@ func TestAssert(t *testing.T) {
tCtx.Fatal("some error")
return 0
}).WithTimeout(time.Second).Should(gomega.Equal(1.0))
tCtx.Log("not reached")
},
expectDuration: time.Second,
expectTrace: `(FATAL) FATAL ERROR: <klog header>:
@@ -75,6 +76,102 @@ func TestAssert(t *testing.T) {
<*errors.errorString | 0xXXXX>{s: "some error"},
],
}
`,
},
"assert-eventually-error": {
cb: func(tCtx TContext) {
tCtx.AssertEventually(func(tCtx TContext) float64 {
tCtx.Fatal("some error")
return 0
}).WithTimeout(time.Second).Should(gomega.Equal(1.0))
tCtx.Log("reached")
},
expectDuration: time.Second,
expectTrace: `(ERROR) ERROR: <klog header>:
Timed out after x.y s.
The function passed to Eventually returned the following error:
<ktesting.failures>:
some error
(LOG) <klog header>: reached
`,
},
"eventually-no-return-okay": {
cb: func(tCtx TContext) {
tCtx.Eventually(func(tCtx TContext) {}).WithTimeout(time.Second).Should(gomega.Succeed())
},
expectDuration: 0,
expectTrace: ``,
},
"eventually-no-return-failure": {
cb: func(tCtx TContext) {
tCtx.Eventually(func(tCtx TContext) {
tCtx.Assert(1).To(gomega.Equal(2))
tCtx.Assert("hello").To(gomega.Equal("world"))
}).WithTimeout(time.Second).Should(gomega.Succeed())
},
expectDuration: time.Second,
expectTrace: `(FATAL) FATAL ERROR: <klog header>:
Timed out after x.y s.
Expected success, but got an error:
<*errors.joinError | 0xXXXX>:
Expected
<int>: 1
to equal
<int>: 2
Expected
<string>: hello
to equal
<string>: world
{
errs: [
<*errors.errorString | 0xXXXX>{
s: "Expected\n <int>: 1\nto equal\n <int>: 2",
},
<*errors.errorString | 0xXXXX>{
s: "Expected\n <string>: hello\nto equal\n <string>: world",
},
],
}
`,
},
"eventually-return-okay": {
cb: func(tCtx TContext) {
tCtx.Eventually(func(tCtx TContext) error { return nil }).WithTimeout(time.Second).Should(gomega.Succeed())
},
expectDuration: 0,
expectTrace: ``,
},
"eventually-return-failure": {
cb: func(tCtx TContext) {
tCtx.Eventually(func(tCtx TContext) error {
tCtx.Assert(1).To(gomega.Equal(2))
tCtx.Assert("hello").To(gomega.Equal("world"))
return nil
}).WithTimeout(time.Second).Should(gomega.Succeed())
},
expectDuration: time.Second,
expectTrace: `(FATAL) FATAL ERROR: <klog header>:
Timed out after x.y s.
Expected success, but got an error:
<*errors.joinError | 0xXXXX>:
Expected
<int>: 1
to equal
<int>: 2
Expected
<string>: hello
to equal
<string>: world
{
errs: [
<*errors.errorString | 0xXXXX>{
s: "Expected\n <int>: 1\nto equal\n <int>: 2",
},
<*errors.errorString | 0xXXXX>{
s: "Expected\n <string>: hello\nto equal\n <string>: world",
},
],
}
`,
},
"eventually-success": {
@@ -136,6 +233,7 @@ func TestAssert(t *testing.T) {
tCtx.Fatal("some error")
return 0
}).WithTimeout(time.Second).Should(gomega.Equal(1.0))
tCtx.Log("not reached")
},
expectDuration: 0,
expectTrace: `(FATAL) FATAL ERROR: <klog header>:
@@ -148,6 +246,23 @@ func TestAssert(t *testing.T) {
<*errors.errorString | 0xXXXX>{s: "some error"},
],
}
`,
},
"assert-consistently-error": {
cb: func(tCtx TContext) {
tCtx.AssertConsistently(func(tCtx TContext) float64 {
tCtx.Fatal("some error")
return 0
}).WithTimeout(time.Second).Should(gomega.Equal(1.0))
tCtx.Log("reached")
},
expectDuration: 0,
expectTrace: `(ERROR) ERROR: <klog header>:
Failed after x.y s.
The function passed to Consistently returned the following error:
<ktesting.failures>:
some error
(LOG) <klog header>: reached
`,
},
"consistently-success": {