| /* |
| Copyright 2024 The Kubernetes Authors. |
| |
| Licensed under the Apache License, Version 2.0 (the "License"); |
| you may not use this file except in compliance with the License. |
| You may obtain a copy of the License at |
| |
| http://www.apache.org/licenses/LICENSE-2.0 |
| |
| Unless required by applicable law or agreed to in writing, software |
| distributed under the License is distributed on an "AS IS" BASIS, |
| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| See the License for the specific language governing permissions and |
| limitations under the License. |
| */ |
| |
| package ktesting |
| |
| import ( |
| "context" |
| "errors" |
| "fmt" |
| "strings" |
| |
| "github.com/onsi/gomega" |
| "github.com/onsi/gomega/format" |
| ) |
| |
| // FailureError is an error where the error string is meant to be passed to |
| // [TContext.Fatal] directly, i.e. adding some prefix like "unexpected error" is not |
| // necessary. It is also not necessary to dump the error struct. |
| type FailureError struct { |
| Msg string |
| FullStackTrace string |
| } |
| |
| func (f FailureError) Error() string { |
| return f.Msg |
| } |
| |
| func (f FailureError) Backtrace() string { |
| return f.FullStackTrace |
| } |
| |
| func (f FailureError) Is(target error) bool { |
| return target == ErrFailure |
| } |
| |
| // ErrFailure is an empty error that can be wrapped to indicate that an error |
| // is a FailureError. It can also be used to test for a FailureError:. |
| // |
| // return fmt.Errorf("some problem%w", ErrFailure) |
| // ... |
| // err := someOperation() |
| // if errors.Is(err, ErrFailure) { |
| // ... |
| // } |
| var ErrFailure error = FailureError{} |
| |
| func expect(tCtx TContext, actual interface{}, extra ...interface{}) gomega.Assertion { |
| tCtx.Helper() |
| return gomega.NewWithT(tCtx).Expect(actual, extra...) |
| } |
| |
| func expectNoError(tCtx TContext, err error, explain ...interface{}) { |
| if err == nil { |
| return |
| } |
| |
| tCtx.Helper() |
| |
| description := buildDescription(explain...) |
| |
| if errors.Is(err, ErrFailure) { |
| var failure FailureError |
| if errors.As(err, &failure) { |
| if backtrace := failure.Backtrace(); backtrace != "" { |
| if description != "" { |
| tCtx.Log(description) |
| } |
| tCtx.Logf("Failed at:\n %s", strings.ReplaceAll(backtrace, "\n", "\n ")) |
| } |
| } |
| if description != "" { |
| tCtx.Fatalf("%s: %s", description, err.Error()) |
| } |
| tCtx.Fatal(err.Error()) |
| } |
| |
| if description == "" { |
| description = "Unexpected error" |
| } |
| tCtx.Logf("%s:\n%s", description, format.Object(err, 1)) |
| tCtx.Fatalf("%s: %v", description, err.Error()) |
| } |
| |
| func buildDescription(explain ...interface{}) string { |
| switch len(explain) { |
| case 0: |
| return "" |
| case 1: |
| if describe, ok := explain[0].(func() string); ok { |
| return describe() |
| } |
| } |
| return fmt.Sprintf(explain[0].(string), explain[1:]...) |
| } |
| |
| // Eventually wraps [gomega.Eventually] such that a failure will be reported via |
| // TContext.Fatal. |
| // |
| // 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. |
| // |
| // 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: |
| // |
| // cb := func(func(tCtx ktesting.TContext) int { |
| // value, err := doSomething(...) |
| // tCtx.ExpectNoError(err, "something failed") |
| // assert(tCtx, 42, value, "the answer") |
| // return value |
| // } |
| // tCtx.Eventually(cb).Should(gomega.Equal(42), "should be the answer to everything") |
| // |
| // 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 default Gomega poll interval and timeout are used. Setting a specific |
| // timeout may be useful: |
| // |
| // tCtx.Eventually(cb).Timeout(5 * time.Second).Should(gomega.Succeed(), "foobar should succeed") |
| // |
| // Canceling the context in the callback only affects code in the callback. The |
| // context passed to Eventually is not getting canceled. To abort polling |
| // immediately because the expected condition is known to not be reached |
| // anymore, use [gomega.StopTrying]: |
| // |
| // cb := func(func(tCtx ktesting.TContext) int { |
| // value, err := doSomething(...) |
| // if errors.Is(err, SomeFinalErr) { |
| // // This message completely replaces the normal |
| // // failure message and thus should include all |
| // // relevant information. |
| // // |
| // // github.com/onsi/gomega/format is a good way |
| // // to format arbitrary data. It uses indention |
| // // and falls back to YAML for Kubernetes API |
| // // structs for readability. |
| // gomega.StopTrying("permanent failure, last value:\n%s", format.Object(value, 1 /* indent one level */)). |
| // Wrap(err).Now() |
| // } |
| // ktesting.ExpectNoError(tCtx, err, "something failed") |
| // return value |
| // } |
| // tCtx.Eventually(cb).Should(gomega.Equal(42), "should be the answer to everything") |
| // |
| // To poll again after some specific timeout, use [gomega.TryAgainAfter]. This is |
| // particularly useful in [Consistently] to ignore some intermittent error. |
| // |
| // cb := func(func(tCtx ktesting.TContext) int { |
| // value, err := doSomething(...) |
| // var intermittentErr SomeIntermittentError |
| // if errors.As(err, &intermittentErr) { |
| // gomega.TryAgainAfter(intermittentErr.RetryPeriod).Wrap(err).Now() |
| // } |
| // ktesting.ExpectNoError(tCtx, 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 { |
| 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 |
| }) |
| } |
| |
| // 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 |
| }) |
| } |