/* Copyright 2023 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" "flag" "fmt" "time" "github.com/onsi/gomega" apiextensions "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset" "k8s.io/client-go/dynamic" clientset "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" "k8s.io/client-go/restmapper" "k8s.io/klog/v2" "k8s.io/klog/v2/ktesting" "k8s.io/kubernetes/test/utils/format" "k8s.io/kubernetes/test/utils/ktesting/initoption" "k8s.io/kubernetes/test/utils/ktesting/internal" ) // Underlier is the additional interface implemented by the per-test LogSink // behind [TContext.Logger]. type Underlier = ktesting.Underlier // CleanupGracePeriod is the time that a [TContext] gets canceled before the // deadline of its underlying test suite (usually determined via "go test // -timeout"). This gives the running test(s) time to fail with an informative // timeout error. After that, all cleanup callbacks then have the remaining // time to complete before the test binary is killed. // // For this to work, each blocking calls in a test must respect the // cancellation of the [TContext]. // // When using Ginkgo to manage the test suite and running tests, the // CleanupGracePeriod is ignored because Ginkgo itself manages timeouts. const CleanupGracePeriod = 5 * time.Second // TContext combines [context.Context], [TB] and some additional // methods. Log output is associated with the current test. Errors ([Error], // [Errorf]) are recorded with "ERROR" as prefix, fatal errors ([Fatal], // [Fatalf]) with "FATAL ERROR". // // TContext provides features offered by Ginkgo also when using normal Go [testing]: // - The context contains a deadline that expires soon enough before // the overall timeout that cleanup code can still run. // - Cleanup callbacks can get their own, separate contexts when // registered via [CleanupCtx]. // - CTRL-C aborts, prints a progress report, and then cleans up // before terminating. // - SIGUSR1 prints a progress report without aborting. // // Progress reporting is more informative when doing polling with // [gomega.Eventually] and [gomega.Consistently]. Without that, it // can only report which tests are active. type TContext interface { context.Context TB // Cancel can be invoked to cancel the context before the test is completed. // Tests which use the context to control goroutines and then wait for // termination of those goroutines must call Cancel to avoid a deadlock. // // The cause, if non-empty, is turned into an error which is equivalend // to context.Canceled. context.Cause will return that error for the // context. Cancel(cause string) // Cleanup registers a callback that will get invoked when the test // has finished. Callbacks get invoked in last-in-first-out order (LIFO). // // Beware of context cancellation. The following cleanup code // will use a canceled context, which is not desirable: // // tCtx.Cleanup(func() { /* do something with tCtx */ }) // tCtx.Cancel() // // A safer way to run cleanup code is: // // tCtx.CleanupCtx(func (tCtx ktesting.TContext) { /* do something with cleanup tCtx */ }) Cleanup(func()) // CleanupCtx is an alternative for Cleanup. The callback is passed a // new TContext with the same logger and clients as the one CleanupCtx // was invoked for. CleanupCtx(func(TContext)) // Expect wraps [gomega.Expect] such that a failure will be reported via // [TContext.Fatal]. As with [gomega.Expect], additional values // may get passed. Those values then all must be nil for the assertion // to pass. This can be used with functions which return a value // plus error: // // myAmazingThing := func(int, error) { ...} // tCtx.Expect(myAmazingThing()).Should(gomega.Equal(1)) Expect(actual interface{}, extra ...interface{}) gomega.Assertion // ExpectNoError asserts that no error has occurred. // // As in [gomega], the optional explanation can be: // - a [fmt.Sprintf] format string plus its argument // - a function returning a string, which will be called // lazy to construct the explanation if needed // // If an explanation is provided, then it replaces the default "Unexpected // error" in the failure message. It's combined with additional details by // adding a colon at the end, as when wrapping an error. Therefore it should // not end with a punctuation mark or line break. // // Using ExpectNoError instead of the corresponding Gomega or testify // assertions has the advantage that the failure message is short (good for // aggregation in https://go.k8s.io/triage) with more details captured in the // test log output (good when investigating one particular failure). ExpectNoError(err error, explain ...interface{}) // Logger returns a logger for the current test. This is a shortcut // for calling klog.FromContext. // // Output emitted via this logger and the TB interface (like Logf) // is formatted consistently. The TB interface generates a single // message string, while Logger enables structured logging and can // be passed down into code which expects a logger. // // To skip intermediate helper functions during stack unwinding, // TB.Helper can be called in those functions. Logger() klog.Logger // TB returns the underlying TB. This can be used to "break the glass" // and cast back into a testing.T or TB. Calling TB is necessary // because TContext wraps the underlying TB. TB() TB // RESTConfig returns a config for a rest client with the UserAgent set // to include the current test name or nil if not available. Several // typed clients using this config are available through [Client], // [Dynamic], [APIExtensions]. RESTConfig() *rest.Config RESTMapper() *restmapper.DeferredDiscoveryRESTMapper Client() clientset.Interface Dynamic() dynamic.Interface APIExtensions() apiextensions.Interface // The following methods must be implemented by every implementation // of TContext to ensure that the leaf TContext is used, not some // embedded TContext: // - CleanupCtx // - Expect // - ExpectNoError // - Logger // // Usually these methods would be stand-alone functions with a TContext // parameter. Offering them as methods simplifies the test code. } // TB is the interface common to [testing.T], [testing.B], [testing.F] and // [github.com/onsi/ginkgo/v2]. In contrast to [testing.TB], it can be // implemented also outside of the testing package. type TB interface { Cleanup(func()) Error(args ...any) Errorf(format string, args ...any) Fail() FailNow() Failed() bool Fatal(args ...any) Fatalf(format string, args ...any) Helper() Log(args ...any) Logf(format string, args ...any) Name() string Setenv(key, value string) Skip(args ...any) SkipNow() Skipf(format string, args ...any) Skipped() bool TempDir() string } // ContextTB adds support for cleanup callbacks with explicit context // parameter. This is used when integrating with Ginkgo: then CleanupCtx // gets implemented via ginkgo.DeferCleanup. type ContextTB interface { TB CleanupCtx(func(ctx context.Context)) } // Init can be called in a unit or integration test to create // a test context which: // - has a per-test logger with verbosity derived from the -v command line flag // - gets canceled when the test finishes (via [TB.Cleanup]) // // Note that the test context supports the interfaces of [TB] and // [context.Context] and thus can be used like one of those where needed. // It also has additional methods for retrieving the logger and canceling // the context early, which can be useful in tests which want to wait // for goroutines to terminate after cancellation. // // If the [TB] implementation also implements [ContextTB], then // [TContext.CleanupCtx] uses [ContextTB.CleanupCtx] and uses // the context passed into that callback. This can be used to let // Ginkgo create a fresh context for cleanup code. // // Can be called more than once per test to get different contexts with // independent cancellation. The default behavior describe above can be // modified via optional functional options defined in [initoption]. func Init(tb TB, opts ...InitOption) TContext { tb.Helper() c := internal.InitConfig{ PerTestOutput: true, } for _, opt := range opts { opt(&c) } // We don't need a Deadline implementation, testing.B doesn't have it. // But if we have one, we'll use it to set a timeout shortly before // the deadline. This needs to come before we wrap tb. deadlineTB, deadlineOK := tb.(interface { Deadline() (time.Time, bool) }) ctx := interruptCtx if c.PerTestOutput { config := ktesting.NewConfig( ktesting.AnyToString(func(v interface{}) string { return format.Object(v, 1) }), ktesting.VerbosityFlagName("v"), ktesting.VModuleFlagName("vmodule"), ) // Copy klog settings instead of making the ktesting logger // configurable directly. var fs flag.FlagSet config.AddFlags(&fs) for _, name := range []string{"v", "vmodule"} { from := flag.CommandLine.Lookup(name) to := fs.Lookup(name) if err := to.Value.Set(from.Value.String()); err != nil { panic(err) } } // Ensure consistent logging: this klog.Logger writes to tb, adding the // date/time header, and our own wrapper emulates that behavior for // Log/Logf/... logger := ktesting.NewLogger(tb, config) ctx = klog.NewContext(interruptCtx, logger) tb = withKlogHeader(tb) } if deadlineOK { if deadline, ok := deadlineTB.Deadline(); ok { timeLeft := time.Until(deadline) timeLeft -= CleanupGracePeriod ctx, cancel := withTimeout(ctx, tb, timeLeft, fmt.Sprintf("test suite deadline (%s) is close, need to clean up before the %s cleanup grace period", deadline.Truncate(time.Second), CleanupGracePeriod)) tCtx := tContext{ Context: ctx, testingTB: testingTB{TB: tb}, cancel: cancel, } return tCtx } } return WithCancel(InitCtx(ctx, tb)) } type InitOption = initoption.InitOption // InitCtx is a variant of [Init] which uses an already existing context and // whatever logger and timeouts are stored there. // Functional options are part of the API, but currently // there are none which have an effect. func InitCtx(ctx context.Context, tb TB, _ ...InitOption) TContext { tCtx := tContext{ Context: ctx, testingTB: testingTB{TB: tb}, } return tCtx } // WithTB constructs a new TContext with a different TB instance. // This can be used to set up some of the context, in particular // clients, in the root test and then run sub-tests: // // func TestSomething(t *testing.T) { // tCtx := ktesting.Init(t) // ... // tCtx = ktesting.WithRESTConfig(tCtx, config) // // t.Run("sub", func (t *testing.T) { // tCtx := ktesting.WithTB(tCtx, t) // ... // }) // // WithTB sets up cancellation for the sub-test. func WithTB(parentCtx TContext, tb TB) TContext { tCtx := InitCtx(parentCtx, tb) tCtx = WithCancel(tCtx) tCtx = WithClients(tCtx, parentCtx.RESTConfig(), parentCtx.RESTMapper(), parentCtx.Client(), parentCtx.Dynamic(), parentCtx.APIExtensions(), ) return tCtx } // WithContext constructs a new TContext with a different Context instance. // This can be used in callbacks which receive a Context, for example // from Gomega: // // gomega.Eventually(tCtx, func(ctx context.Context) { // tCtx := ktesting.WithContext(tCtx, ctx) // ... // // This is important because the Context in the callback could have // a different deadline than in the parent TContext. func WithContext(parentCtx TContext, ctx context.Context) TContext { tCtx := InitCtx(ctx, parentCtx.TB()) tCtx = WithClients(tCtx, parentCtx.RESTConfig(), parentCtx.RESTMapper(), parentCtx.Client(), parentCtx.Dynamic(), parentCtx.APIExtensions(), ) return tCtx } // WithValue wraps context.WithValue such that the result is again a TContext. func WithValue(parentCtx TContext, key, val any) TContext { ctx := context.WithValue(parentCtx, key, val) return WithContext(parentCtx, ctx) } type tContext struct { context.Context testingTB cancel func(cause string) } // testingTB is needed to avoid a name conflict // between field and method in tContext. type testingTB struct { TB } func (tCtx tContext) Cancel(cause string) { if tCtx.cancel != nil { tCtx.cancel(cause) } } func (tCtx tContext) CleanupCtx(cb func(TContext)) { tCtx.Helper() cleanupCtx(tCtx, cb) } func (tCtx tContext) Expect(actual interface{}, extra ...interface{}) gomega.Assertion { tCtx.Helper() return expect(tCtx, actual, extra...) } func (tCtx tContext) ExpectNoError(err error, explain ...interface{}) { tCtx.Helper() expectNoError(tCtx, err, explain...) } func cleanupCtx(tCtx TContext, cb func(TContext)) { tCtx.Helper() if tb, ok := tCtx.TB().(ContextTB); ok { // Use context from base TB (most likely Ginkgo). tb.CleanupCtx(func(ctx context.Context) { tCtx := WithContext(tCtx, ctx) cb(tCtx) }) return } tCtx.Cleanup(func() { // Use new context. This is the code path for "go test". The // context then has *no* deadline. In the code path above for // Ginkgo, Ginkgo is more sophisticated and also applies // timeouts to cleanup calls which accept a context. childCtx := WithContext(tCtx, context.WithoutCancel(tCtx)) cb(childCtx) }) } func (tCtx tContext) Logger() klog.Logger { return klog.FromContext(tCtx) } func (tCtx tContext) Error(args ...any) { tCtx.Helper() args = append([]any{"ERROR:"}, args...) tCtx.testingTB.Error(args...) } func (tCtx tContext) Errorf(format string, args ...any) { tCtx.Helper() error := fmt.Sprintf(format, args...) error = "ERROR: " + error tCtx.testingTB.Error(error) } func (tCtx tContext) Fatal(args ...any) { tCtx.Helper() args = append([]any{"FATAL ERROR:"}, args...) tCtx.testingTB.Fatal(args...) } func (tCtx tContext) Fatalf(format string, args ...any) { tCtx.Helper() error := fmt.Sprintf(format, args...) error = "FATAL ERROR: " + error tCtx.testingTB.Fatal(error) } func (tCtx tContext) TB() TB { // Might have to unwrap twice, depending on how // this tContext was constructed. tb := tCtx.testingTB.TB if k, ok := tb.(klogTB); ok { return k.TB } return tb } func (tCtx tContext) RESTConfig() *rest.Config { return nil } func (tCtx tContext) RESTMapper() *restmapper.DeferredDiscoveryRESTMapper { return nil } func (tCtx tContext) Client() clientset.Interface { return nil } func (tCtx tContext) Dynamic() dynamic.Interface { return nil } func (tCtx tContext) APIExtensions() apiextensions.Interface { return nil }