mirror of
https://github.com/k3s-io/kubernetes.git
synced 2025-07-31 07:20:13 +00:00
ktesting: add TContext
The new TContext interface combines a normal context and the testing interface, then adds some helper methods. The context gets canceled when the test is done, but that can also be requested earlier via Cancel. The intended usage is to pass a single `tCtx ktesting.TContext` parameter around in all helper functions that get called by a unit or integration test. Logging is also more useful: Log[f] and Fatal[f] output is prefixed with "[FATAL] ERROR: " to make it stand out more from regular log output. If this approach turns out to be useful, it could be extended further (for example, with a per-test timeout) and might get moved to a staging repository to enable usage of it in other staging repositories. To allow other implementations besides testing.T and testing.B, a custom ktesting.TB interface gets defined with the methods expected from the actual implementation. One such implementation can be ginkgo.GinkgoT().
This commit is contained in:
parent
57e9981dc2
commit
63aa261583
183
test/utils/ktesting/assert.go
Normal file
183
test/utils/ktesting/assert.go
Normal file
@ -0,0 +1,183 @@
|
||||
/*
|
||||
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{}) {
|
||||
tCtx.Helper()
|
||||
|
||||
description := buildDescription(explain)
|
||||
|
||||
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: %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(...)
|
||||
// ktesting.ExpectNoError(tCtx, err, "something failed")
|
||||
// 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) {
|
||||
// gomega.StopTrying("permanent failure).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
|
||||
})
|
||||
}
|
194
test/utils/ktesting/assert_test.go
Normal file
194
test/utils/ktesting/assert_test.go
Normal file
@ -0,0 +1,194 @@
|
||||
/*
|
||||
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 (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/onsi/gomega"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestAsync(t *testing.T) {
|
||||
for name, tc := range map[string]struct {
|
||||
cb func(TContext)
|
||||
expectNoFail bool
|
||||
expectError string
|
||||
expectDuration time.Duration
|
||||
}{
|
||||
"eventually-timeout": {
|
||||
cb: func(tCtx TContext) {
|
||||
Eventually(tCtx, func(tCtx TContext) int {
|
||||
// Canceling here is a nop.
|
||||
tCtx.Cancel("testing")
|
||||
return 0
|
||||
}).WithTimeout(time.Second).Should(gomega.Equal(1))
|
||||
},
|
||||
expectDuration: time.Second,
|
||||
expectError: `Timed out after x.y s.
|
||||
Expected
|
||||
<int>: 0
|
||||
to equal
|
||||
<int>: 1`,
|
||||
},
|
||||
"eventually-final": {
|
||||
cb: func(tCtx TContext) {
|
||||
Eventually(tCtx, func(tCtx TContext) float64 {
|
||||
gomega.StopTrying("final error").Now()
|
||||
return 0
|
||||
}).WithTimeout(time.Second).Should(gomega.Equal(1.0))
|
||||
},
|
||||
expectDuration: 0,
|
||||
expectError: `Told to stop trying after x.y s.
|
||||
final error`,
|
||||
},
|
||||
"eventually-error": {
|
||||
cb: func(tCtx TContext) {
|
||||
Eventually(tCtx, func(tCtx TContext) float64 {
|
||||
tCtx.Fatal("some error")
|
||||
return 0
|
||||
}).WithTimeout(time.Second).Should(gomega.Equal(1.0))
|
||||
},
|
||||
expectDuration: time.Second,
|
||||
expectError: `Timed out after x.y s.
|
||||
The function passed to Eventually returned the following error:
|
||||
<*errors.joinError | 0xXXXX>:
|
||||
some error
|
||||
{
|
||||
errs: [
|
||||
<*errors.errorString | 0xXXXX>{s: "some error"},
|
||||
],
|
||||
}`,
|
||||
},
|
||||
"eventually-success": {
|
||||
cb: func(tCtx TContext) {
|
||||
Eventually(tCtx, func(tCtx TContext) float64 {
|
||||
return 1.0
|
||||
}).WithTimeout(time.Second).Should(gomega.Equal(1.0))
|
||||
},
|
||||
expectDuration: 0,
|
||||
expectNoFail: true,
|
||||
expectError: ``,
|
||||
},
|
||||
"eventually-retry": {
|
||||
cb: func(tCtx TContext) {
|
||||
Eventually(tCtx, func(tCtx TContext) float64 {
|
||||
gomega.TryAgainAfter(time.Millisecond).Now()
|
||||
return 0
|
||||
}).WithTimeout(time.Second).Should(gomega.Equal(1.0))
|
||||
},
|
||||
expectDuration: time.Second,
|
||||
expectError: `Timed out after x.y s.
|
||||
told to try again after 1ms`,
|
||||
},
|
||||
"consistently-timeout": {
|
||||
cb: func(tCtx TContext) {
|
||||
Consistently(tCtx, func(tCtx TContext) float64 {
|
||||
// Canceling here is a nop.
|
||||
tCtx.Cancel("testing")
|
||||
return 0
|
||||
}).WithTimeout(time.Second).Should(gomega.Equal(1.0))
|
||||
},
|
||||
expectDuration: 0,
|
||||
expectError: `Failed after x.y s.
|
||||
Expected
|
||||
<float64>: 0
|
||||
to equal
|
||||
<float64>: 1`,
|
||||
},
|
||||
"consistently-final": {
|
||||
cb: func(tCtx TContext) {
|
||||
Consistently(tCtx, func(tCtx TContext) float64 {
|
||||
gomega.StopTrying("final error").Now()
|
||||
tCtx.FailNow()
|
||||
return 0
|
||||
}).WithTimeout(time.Second).Should(gomega.Equal(1.0))
|
||||
},
|
||||
expectDuration: 0,
|
||||
expectError: `Told to stop trying after x.y s.
|
||||
final error`,
|
||||
},
|
||||
"consistently-error": {
|
||||
cb: func(tCtx TContext) {
|
||||
Consistently(tCtx, func(tCtx TContext) float64 {
|
||||
tCtx.Fatal("some error")
|
||||
return 0
|
||||
}).WithTimeout(time.Second).Should(gomega.Equal(1.0))
|
||||
},
|
||||
expectDuration: 0,
|
||||
expectError: `Failed after x.y s.
|
||||
The function passed to Consistently returned the following error:
|
||||
<*errors.joinError | 0xXXXX>:
|
||||
some error
|
||||
{
|
||||
errs: [
|
||||
<*errors.errorString | 0xXXXX>{s: "some error"},
|
||||
],
|
||||
}`,
|
||||
},
|
||||
"consistently-success": {
|
||||
cb: func(tCtx TContext) {
|
||||
Consistently(tCtx, func(tCtx TContext) float64 {
|
||||
return 1.0
|
||||
}).WithTimeout(time.Second).Should(gomega.Equal(1.0))
|
||||
},
|
||||
expectDuration: time.Second,
|
||||
expectNoFail: true,
|
||||
expectError: ``,
|
||||
},
|
||||
"consistently-retry": {
|
||||
cb: func(tCtx TContext) {
|
||||
Consistently(tCtx, func(tCtx TContext) float64 {
|
||||
gomega.TryAgainAfter(time.Millisecond).Wrap(errors.New("intermittent error")).Now()
|
||||
return 0
|
||||
}).WithTimeout(time.Second).Should(gomega.Equal(1.0))
|
||||
},
|
||||
expectDuration: time.Second,
|
||||
expectError: `Timed out while waiting on TryAgainAfter after x.y s.
|
||||
told to try again after 1ms: intermittent error`,
|
||||
},
|
||||
} {
|
||||
tc := tc
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
tCtx := Init(t)
|
||||
var err error
|
||||
tCtx, finalize := WithError(tCtx, &err)
|
||||
start := time.Now()
|
||||
func() {
|
||||
defer finalize()
|
||||
tc.cb(tCtx)
|
||||
}()
|
||||
duration := time.Since(start)
|
||||
assert.InDelta(t, tc.expectDuration.Seconds(), duration.Seconds(), 0.1, fmt.Sprintf("callback invocation duration %s", duration))
|
||||
assert.Equal(t, !tc.expectNoFail, tCtx.Failed(), "Failed()")
|
||||
if tc.expectError == "" {
|
||||
assert.NoError(t, err)
|
||||
} else if assert.NotNil(t, err) {
|
||||
t.Logf("Result:\n%s", err.Error())
|
||||
errMsg := err.Error()
|
||||
errMsg = regexp.MustCompile(`[[:digit:]]+\.[[:digit:]]+s`).ReplaceAllString(errMsg, "x.y s")
|
||||
errMsg = regexp.MustCompile(`0x[[:xdigit:]]+`).ReplaceAllString(errMsg, "0xXXXX")
|
||||
assert.Equal(t, tc.expectError, errMsg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
114
test/utils/ktesting/clientcontext.go
Normal file
114
test/utils/ktesting/clientcontext.go
Normal file
@ -0,0 +1,114 @@
|
||||
/*
|
||||
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 (
|
||||
"fmt"
|
||||
|
||||
"github.com/onsi/gomega"
|
||||
apiextensions "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset"
|
||||
"k8s.io/client-go/discovery/cached/memory"
|
||||
"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"
|
||||
)
|
||||
|
||||
// WithRESTConfig initializes all client-go clients with new clients
|
||||
// created for the config. The current test name gets included in the UserAgent.
|
||||
func WithRESTConfig(tCtx TContext, cfg *rest.Config) TContext {
|
||||
cfg = rest.CopyConfig(cfg)
|
||||
cfg.UserAgent = fmt.Sprintf("%s -- %s", rest.DefaultKubernetesUserAgent(), tCtx.Name())
|
||||
|
||||
cCtx := clientContext{
|
||||
TContext: tCtx,
|
||||
restConfig: cfg,
|
||||
client: clientset.NewForConfigOrDie(cfg),
|
||||
dynamic: dynamic.NewForConfigOrDie(cfg),
|
||||
apiextensions: apiextensions.NewForConfigOrDie(cfg),
|
||||
}
|
||||
|
||||
cachedDiscovery := memory.NewMemCacheClient(cCtx.client.Discovery())
|
||||
cCtx.restMapper = restmapper.NewDeferredDiscoveryRESTMapper(cachedDiscovery)
|
||||
|
||||
return &cCtx
|
||||
}
|
||||
|
||||
// WithClients uses an existing config and clients.
|
||||
func WithClients(tCtx TContext, cfg *rest.Config, mapper *restmapper.DeferredDiscoveryRESTMapper, client clientset.Interface, dynamic dynamic.Interface, apiextensions apiextensions.Interface) TContext {
|
||||
return clientContext{
|
||||
TContext: tCtx,
|
||||
restConfig: cfg,
|
||||
restMapper: mapper,
|
||||
client: client,
|
||||
dynamic: dynamic,
|
||||
apiextensions: apiextensions,
|
||||
}
|
||||
}
|
||||
|
||||
type clientContext struct {
|
||||
TContext
|
||||
|
||||
restConfig *rest.Config
|
||||
restMapper *restmapper.DeferredDiscoveryRESTMapper
|
||||
client clientset.Interface
|
||||
dynamic dynamic.Interface
|
||||
apiextensions apiextensions.Interface
|
||||
}
|
||||
|
||||
func (cCtx clientContext) CleanupCtx(cb func(TContext)) {
|
||||
cCtx.Helper()
|
||||
cleanupCtx(cCtx, cb)
|
||||
}
|
||||
|
||||
func (cCtx clientContext) Expect(actual interface{}, extra ...interface{}) gomega.Assertion {
|
||||
cCtx.Helper()
|
||||
return expect(cCtx, actual, extra...)
|
||||
}
|
||||
|
||||
func (cCtx clientContext) ExpectNoError(err error, explain ...interface{}) {
|
||||
cCtx.Helper()
|
||||
expectNoError(cCtx, err, explain...)
|
||||
}
|
||||
|
||||
func (cCtx clientContext) Logger() klog.Logger {
|
||||
return klog.FromContext(cCtx)
|
||||
}
|
||||
|
||||
func (cCtx clientContext) RESTConfig() *rest.Config {
|
||||
if cCtx.restConfig == nil {
|
||||
return nil
|
||||
}
|
||||
return rest.CopyConfig(cCtx.restConfig)
|
||||
}
|
||||
|
||||
func (cCtx clientContext) RESTMapper() *restmapper.DeferredDiscoveryRESTMapper {
|
||||
return cCtx.restMapper
|
||||
}
|
||||
|
||||
func (cCtx clientContext) Client() clientset.Interface {
|
||||
return cCtx.client
|
||||
}
|
||||
|
||||
func (cCtx clientContext) Dynamic() dynamic.Interface {
|
||||
return cCtx.dynamic
|
||||
}
|
||||
|
||||
func (cCtx clientContext) APIExtensions() apiextensions.Interface {
|
||||
return cCtx.apiextensions
|
||||
}
|
91
test/utils/ktesting/contexthelper.go
Normal file
91
test/utils/ktesting/contexthelper.go
Normal file
@ -0,0 +1,91 @@
|
||||
/*
|
||||
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"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// cleanupErr creates a cause when canceling a context because the test has completed.
|
||||
// It is a context.Canceled error.
|
||||
func cleanupErr(testName string) error {
|
||||
return canceledError(fmt.Sprintf("test %s is cleaning up", testName))
|
||||
}
|
||||
|
||||
type canceledError string
|
||||
|
||||
func (c canceledError) Error() string { return string(c) }
|
||||
|
||||
func (c canceledError) Is(target error) bool {
|
||||
return target == context.Canceled
|
||||
}
|
||||
|
||||
// withTimeout corresponds to [context.WithTimeout]. In contrast to
|
||||
// [context.WithTimeout], it automatically cancels during test cleanup, provides
|
||||
// the given cause when the deadline is reached, and its cancel function
|
||||
// requires a cause.
|
||||
func withTimeout(ctx context.Context, tb TB, timeout time.Duration, timeoutCause string) (context.Context, func(cause string)) {
|
||||
tb.Helper()
|
||||
|
||||
now := time.Now()
|
||||
|
||||
cancelCtx, cancel := context.WithCancelCause(ctx)
|
||||
after := time.NewTimer(timeout)
|
||||
stopCtx, stop := context.WithCancel(ctx) // Only used internally, doesn't need a cause.
|
||||
tb.Cleanup(func() {
|
||||
cancel(cleanupErr(tb.Name()))
|
||||
stop()
|
||||
})
|
||||
go func() {
|
||||
select {
|
||||
case <-stopCtx.Done():
|
||||
after.Stop()
|
||||
// No need to set a cause here. The cause or error of
|
||||
// the parent context will be used.
|
||||
case <-after.C:
|
||||
cancel(canceledError(timeoutCause))
|
||||
}
|
||||
}()
|
||||
|
||||
// Determine which deadline is sooner: ours or that of our parent.
|
||||
deadline := now.Add(timeout)
|
||||
if parentDeadline, ok := ctx.Deadline(); ok {
|
||||
if deadline.After(parentDeadline) {
|
||||
deadline = parentDeadline
|
||||
}
|
||||
}
|
||||
|
||||
// We always have a deadline.
|
||||
return deadlineContext{Context: cancelCtx, deadline: deadline}, func(cause string) {
|
||||
var cancelCause error
|
||||
if cause != "" {
|
||||
cancelCause = canceledError(cause)
|
||||
}
|
||||
cancel(cancelCause)
|
||||
}
|
||||
}
|
||||
|
||||
type deadlineContext struct {
|
||||
context.Context
|
||||
deadline time.Time
|
||||
}
|
||||
|
||||
func (d deadlineContext) Deadline() (time.Time, bool) {
|
||||
return d.deadline, true
|
||||
}
|
126
test/utils/ktesting/contexthelper_test.go
Normal file
126
test/utils/ktesting/contexthelper_test.go
Normal file
@ -0,0 +1,126 @@
|
||||
/*
|
||||
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"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCleanupErr(t *testing.T) {
|
||||
actual := cleanupErr(t.Name())
|
||||
if !errors.Is(actual, context.Canceled) {
|
||||
t.Errorf("cleanupErr %T should be a %T", actual, context.Canceled)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCause(t *testing.T) {
|
||||
timeoutCause := canceledError("I timed out")
|
||||
parentCause := errors.New("parent canceled")
|
||||
|
||||
t.Parallel()
|
||||
for name, tt := range map[string]struct {
|
||||
parentCtx context.Context
|
||||
timeout time.Duration
|
||||
sleep time.Duration
|
||||
cancelCause string
|
||||
expectErr, expectCause error
|
||||
expectDeadline time.Duration
|
||||
}{
|
||||
"nothing": {
|
||||
parentCtx: context.Background(),
|
||||
timeout: 5 * time.Millisecond,
|
||||
sleep: time.Millisecond,
|
||||
},
|
||||
"timeout": {
|
||||
parentCtx: context.Background(),
|
||||
timeout: time.Millisecond,
|
||||
sleep: 5 * time.Millisecond,
|
||||
expectErr: context.Canceled,
|
||||
expectCause: canceledError(timeoutCause),
|
||||
},
|
||||
"parent-canceled": {
|
||||
parentCtx: func() context.Context {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
return ctx
|
||||
}(),
|
||||
timeout: time.Millisecond,
|
||||
sleep: 5 * time.Millisecond,
|
||||
expectErr: context.Canceled,
|
||||
expectCause: context.Canceled,
|
||||
},
|
||||
"parent-cause": {
|
||||
parentCtx: func() context.Context {
|
||||
ctx, cancel := context.WithCancelCause(context.Background())
|
||||
cancel(parentCause)
|
||||
return ctx
|
||||
}(),
|
||||
timeout: time.Millisecond,
|
||||
sleep: 5 * time.Millisecond,
|
||||
expectErr: context.Canceled,
|
||||
expectCause: parentCause,
|
||||
},
|
||||
"deadline-no-parent": {
|
||||
parentCtx: context.Background(),
|
||||
timeout: time.Minute,
|
||||
expectDeadline: time.Minute,
|
||||
},
|
||||
"deadline-parent": {
|
||||
parentCtx: func() context.Context {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
return ctx
|
||||
}(),
|
||||
timeout: 2 * time.Minute,
|
||||
expectDeadline: time.Minute,
|
||||
},
|
||||
"deadline-child": {
|
||||
parentCtx: func() context.Context {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
return ctx
|
||||
}(),
|
||||
timeout: time.Minute,
|
||||
expectDeadline: time.Minute,
|
||||
},
|
||||
} {
|
||||
tt := tt
|
||||
t.Run(name, func(t *testing.T) {
|
||||
ctx, cancel := withTimeout(tt.parentCtx, t, tt.timeout, timeoutCause.Error())
|
||||
if tt.cancelCause != "" {
|
||||
cancel(tt.cancelCause)
|
||||
}
|
||||
if tt.expectDeadline != 0 {
|
||||
actualDeadline, ok := ctx.Deadline()
|
||||
if assert.True(t, ok, "should have had a deadline") {
|
||||
assert.InDelta(t, time.Until(actualDeadline), tt.expectDeadline, float64(time.Second), "remaining time till Deadline()")
|
||||
}
|
||||
}
|
||||
time.Sleep(tt.sleep)
|
||||
actualErr := ctx.Err()
|
||||
actualCause := context.Cause(ctx)
|
||||
assert.Equal(t, tt.expectErr, actualErr, "ctx.Err()")
|
||||
assert.Equal(t, tt.expectCause, actualCause, "context.Cause()")
|
||||
|
||||
})
|
||||
}
|
||||
}
|
32
test/utils/ktesting/doc.go
Normal file
32
test/utils/ktesting/doc.go
Normal file
@ -0,0 +1,32 @@
|
||||
/*
|
||||
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 is a wrapper around k8s.io/klog/v2/ktesting. In contrast
|
||||
// to the klog package, this one is opinionated and tailored towards testing
|
||||
// Kubernetes.
|
||||
//
|
||||
// Importing it
|
||||
// - adds the -v command line flag
|
||||
// - enables better dumping of complex datatypes
|
||||
// - sets the default verbosity to 5 (can be changed with [SetDefaultVerbosity])
|
||||
//
|
||||
// It also adds additional APIs and types for unit and integration tests
|
||||
// which are too experimental for klog and/or are unrelated to logging.
|
||||
// The ktesting package itself takes care of managing a test context
|
||||
// with deadlines, timeouts, cancellation, and some common attributes
|
||||
// as first-class members of the API. Sub-packages have additional APIs
|
||||
// for propagating values via the context, implemented via [WithValue].
|
||||
package ktesting
|
153
test/utils/ktesting/errorcontext.go
Normal file
153
test/utils/ktesting/errorcontext.go
Normal file
@ -0,0 +1,153 @@
|
||||
/*
|
||||
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 (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"k8s.io/klog/v2"
|
||||
)
|
||||
|
||||
// WithError creates a context where test failures are collected and stored in
|
||||
// the provided error instance when the caller is done. Use it like this:
|
||||
//
|
||||
// func doSomething(tCtx ktesting.TContext) (finalErr error) {
|
||||
// tCtx, finalize := WithError(tCtx, &finalErr)
|
||||
// defer finalize()
|
||||
// ...
|
||||
// tCtx.Fatal("some failure")
|
||||
//
|
||||
// Any error already stored in the variable will get overwritten by finalize if
|
||||
// there were test failures, otherwise the variable is left unchanged.
|
||||
// If there were multiple test errors, then the error will wrap all of
|
||||
// them with errors.Join.
|
||||
//
|
||||
// Test failures are not propagated to the parent context.
|
||||
func WithError(tCtx TContext, err *error) (TContext, func()) {
|
||||
eCtx := &errorContext{
|
||||
TContext: tCtx,
|
||||
}
|
||||
|
||||
return eCtx, func() {
|
||||
// Recover has to be called in the deferred function. When called inside
|
||||
// a function called by a deferred function (like finalize below), it
|
||||
// returns nil.
|
||||
if e := recover(); e != nil {
|
||||
if _, ok := e.(fatalWithError); !ok {
|
||||
// Not our own panic, pass it on instead of setting the error.
|
||||
panic(e)
|
||||
}
|
||||
}
|
||||
|
||||
eCtx.finalize(err)
|
||||
}
|
||||
}
|
||||
|
||||
type errorContext struct {
|
||||
TContext
|
||||
|
||||
mutex sync.Mutex
|
||||
errors []error
|
||||
failed bool
|
||||
}
|
||||
|
||||
func (eCtx *errorContext) finalize(err *error) {
|
||||
eCtx.mutex.Lock()
|
||||
defer eCtx.mutex.Unlock()
|
||||
|
||||
if !eCtx.failed {
|
||||
return
|
||||
}
|
||||
|
||||
errs := eCtx.errors
|
||||
if len(errs) == 0 {
|
||||
errs = []error{errFailedWithNoExplanation}
|
||||
}
|
||||
*err = errors.Join(errs...)
|
||||
}
|
||||
|
||||
func (eCtx *errorContext) Error(args ...any) {
|
||||
eCtx.mutex.Lock()
|
||||
defer eCtx.mutex.Unlock()
|
||||
|
||||
// Gomega adds a leading newline in https://github.com/onsi/gomega/blob/f804ac6ada8d36164ecae0513295de8affce1245/internal/gomega.go#L37
|
||||
// Let's strip that at start and end because ktesting will make errors
|
||||
// stand out more with the "ERROR" prefix, so there's no need for additional
|
||||
// line breaks.
|
||||
eCtx.errors = append(eCtx.errors, errors.New(strings.TrimSpace(fmt.Sprintln(args...))))
|
||||
eCtx.failed = true
|
||||
}
|
||||
|
||||
func (eCtx *errorContext) Errorf(format string, args ...any) {
|
||||
eCtx.mutex.Lock()
|
||||
defer eCtx.mutex.Unlock()
|
||||
|
||||
eCtx.errors = append(eCtx.errors, errors.New(strings.TrimSpace(fmt.Sprintf(format, args...))))
|
||||
eCtx.failed = true
|
||||
}
|
||||
|
||||
func (eCtx *errorContext) Fail() {
|
||||
eCtx.mutex.Lock()
|
||||
defer eCtx.mutex.Unlock()
|
||||
|
||||
eCtx.failed = true
|
||||
}
|
||||
|
||||
func (eCtx *errorContext) FailNow() {
|
||||
eCtx.Helper()
|
||||
eCtx.Fail()
|
||||
panic(failed)
|
||||
}
|
||||
|
||||
func (eCtx *errorContext) Failed() bool {
|
||||
eCtx.mutex.Lock()
|
||||
defer eCtx.mutex.Unlock()
|
||||
|
||||
return eCtx.failed
|
||||
}
|
||||
|
||||
func (eCtx *errorContext) Fatal(args ...any) {
|
||||
eCtx.Error(args...)
|
||||
eCtx.FailNow()
|
||||
}
|
||||
|
||||
func (eCtx *errorContext) Fatalf(format string, args ...any) {
|
||||
eCtx.Errorf(format, args...)
|
||||
eCtx.FailNow()
|
||||
}
|
||||
|
||||
func (eCtx *errorContext) CleanupCtx(cb func(TContext)) {
|
||||
eCtx.Helper()
|
||||
cleanupCtx(eCtx, cb)
|
||||
}
|
||||
|
||||
func (eCtx *errorContext) Logger() klog.Logger {
|
||||
return klog.FromContext(eCtx)
|
||||
}
|
||||
|
||||
// fatalWithError is the internal type that should never get propagated up. The
|
||||
// only case where that can happen is when the developer forgot to call
|
||||
// finalize via defer. The string explains that, in case that developers get to
|
||||
// see it.
|
||||
type fatalWithError string
|
||||
|
||||
const failed = fatalWithError("WithError TContext encountered a fatal error, but the finalize function was not called via defer as it should have been.")
|
||||
|
||||
var errFailedWithNoExplanation = errors.New("WithError context was marked as failed without recording an error")
|
116
test/utils/ktesting/errorcontext_test.go
Normal file
116
test/utils/ktesting/errorcontext_test.go
Normal file
@ -0,0 +1,116 @@
|
||||
/*
|
||||
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 (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestWithError(t *testing.T) {
|
||||
t.Run("panic", func(t *testing.T) {
|
||||
assert.Panics(t, func() {
|
||||
tCtx := Init(t)
|
||||
var err error
|
||||
_, finalize := WithError(tCtx, &err)
|
||||
defer finalize()
|
||||
|
||||
panic("pass me through")
|
||||
})
|
||||
})
|
||||
|
||||
normalErr := errors.New("normal error")
|
||||
|
||||
for name, tc := range map[string]struct {
|
||||
cb func(TContext)
|
||||
expectNoFail bool
|
||||
expectError string
|
||||
}{
|
||||
"none": {
|
||||
cb: func(tCtx TContext) {},
|
||||
expectNoFail: true,
|
||||
expectError: normalErr.Error(),
|
||||
},
|
||||
"Error": {
|
||||
cb: func(tCtx TContext) {
|
||||
tCtx.Error("some error")
|
||||
},
|
||||
expectError: "some error",
|
||||
},
|
||||
"Errorf": {
|
||||
cb: func(tCtx TContext) {
|
||||
tCtx.Errorf("some %s", "error")
|
||||
},
|
||||
expectError: "some error",
|
||||
},
|
||||
"Fatal": {
|
||||
cb: func(tCtx TContext) {
|
||||
tCtx.Fatal("some error")
|
||||
tCtx.Error("another error")
|
||||
},
|
||||
expectError: "some error",
|
||||
},
|
||||
"Fatalf": {
|
||||
cb: func(tCtx TContext) {
|
||||
tCtx.Fatalf("some %s", "error")
|
||||
tCtx.Error("another error")
|
||||
},
|
||||
expectError: "some error",
|
||||
},
|
||||
"Fail": {
|
||||
cb: func(tCtx TContext) {
|
||||
tCtx.Fatalf("some %s", "error")
|
||||
tCtx.Error("another error")
|
||||
},
|
||||
expectError: "some error",
|
||||
},
|
||||
"FailNow": {
|
||||
cb: func(tCtx TContext) {
|
||||
tCtx.FailNow()
|
||||
tCtx.Error("another error")
|
||||
},
|
||||
expectError: errFailedWithNoExplanation.Error(),
|
||||
},
|
||||
"many": {
|
||||
cb: func(tCtx TContext) {
|
||||
tCtx.Error("first error")
|
||||
tCtx.Error("second error")
|
||||
},
|
||||
expectError: `first error
|
||||
second error`,
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
tCtx := Init(t)
|
||||
err := normalErr
|
||||
tCtx, finalize := WithError(tCtx, &err)
|
||||
func() {
|
||||
defer finalize()
|
||||
tc.cb(tCtx)
|
||||
}()
|
||||
|
||||
assert.Equal(t, !tc.expectNoFail, tCtx.Failed(), "Failed()")
|
||||
if tc.expectError == "" {
|
||||
assert.NoError(t, err)
|
||||
} else if assert.NotNil(t, err) {
|
||||
assert.Equal(t, tc.expectError, err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
20
test/utils/ktesting/examples/gomega/doc.go
Normal file
20
test/utils/ktesting/examples/gomega/doc.go
Normal file
@ -0,0 +1,20 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
// The tests will fail and therefore are excluded from normal "make test" via
|
||||
// the "example" build tag. To run the tests and check the output, use "go test
|
||||
// -tags example ."
|
||||
package gomega
|
44
test/utils/ktesting/examples/gomega/example_test.go
Normal file
44
test/utils/ktesting/examples/gomega/example_test.go
Normal file
@ -0,0 +1,44 @@
|
||||
//go:build example
|
||||
// +build example
|
||||
|
||||
/*
|
||||
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 gomega
|
||||
|
||||
// The tests below will fail and therefore are excluded from
|
||||
// normal "make test" via the "example" build tag. To run
|
||||
// the tests and check the output, use "go test -tags example ."
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/onsi/gomega"
|
||||
"k8s.io/kubernetes/test/utils/ktesting"
|
||||
)
|
||||
|
||||
func TestGomega(t *testing.T) {
|
||||
tCtx := ktesting.Init(t)
|
||||
|
||||
gomega.NewWithT(tCtx).Eventually(tCtx, func(ctx context.Context) int {
|
||||
// TODO: tCtx = ktesting.WithContext(tCtx, ctx)
|
||||
// Or some dedicated tCtx.Eventually?
|
||||
|
||||
return 42
|
||||
}).WithPolling(time.Second).Should(gomega.Equal(1))
|
||||
}
|
20
test/utils/ktesting/examples/logging/doc.go
Normal file
20
test/utils/ktesting/examples/logging/doc.go
Normal file
@ -0,0 +1,20 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
// The tests will fail and therefore are excluded from normal "make test" via
|
||||
// the "example" build tag. To run the tests and check the output, use "go test
|
||||
// -tags example ."
|
||||
package logging
|
74
test/utils/ktesting/examples/logging/example_test.go
Normal file
74
test/utils/ktesting/examples/logging/example_test.go
Normal file
@ -0,0 +1,74 @@
|
||||
//go:build example
|
||||
// +build example
|
||||
|
||||
/*
|
||||
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 logging
|
||||
|
||||
// The tests below will fail and therefore are excluded from
|
||||
// normal "make test" via the "example" build tag. To run
|
||||
// the tests and check the output, use "go test -tags example ."
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"k8s.io/kubernetes/test/utils/ktesting"
|
||||
)
|
||||
|
||||
func TestError(t *testing.T) {
|
||||
tCtx := ktesting.Init(t)
|
||||
tCtx.Error("some", "thing")
|
||||
}
|
||||
|
||||
func TestErrorf(t *testing.T) {
|
||||
tCtx := ktesting.Init(t)
|
||||
tCtx.Errorf("some %s", "thing")
|
||||
}
|
||||
|
||||
func TestFatal(t *testing.T) {
|
||||
tCtx := ktesting.Init(t)
|
||||
tCtx.Fatal("some", "thing")
|
||||
tCtx.Log("not reached")
|
||||
}
|
||||
|
||||
func TestFatalf(t *testing.T) {
|
||||
tCtx := ktesting.Init(t)
|
||||
tCtx.Fatalf("some %s", "thing")
|
||||
tCtx.Log("not reached")
|
||||
}
|
||||
|
||||
func TestInfo(t *testing.T) {
|
||||
tCtx := ktesting.Init(t)
|
||||
tCtx.Log("hello via Log")
|
||||
tCtx.Logger().Info("hello via Info")
|
||||
tCtx.Error("some", "thing")
|
||||
}
|
||||
|
||||
func TestWithStep(t *testing.T) {
|
||||
tCtx := ktesting.Init(t)
|
||||
bake(ktesting.WithStep(tCtx, "bake cake"))
|
||||
}
|
||||
|
||||
func bake(tCtx ktesting.TContext) {
|
||||
heatOven(ktesting.WithStep(tCtx, "set heat for baking"))
|
||||
}
|
||||
|
||||
func heatOven(tCtx ktesting.TContext) {
|
||||
tCtx.Log("Log()")
|
||||
tCtx.Logger().Info("Logger().Info()")
|
||||
tCtx.Fatal("oven not found")
|
||||
}
|
20
test/utils/ktesting/examples/with_ktesting/doc.go
Normal file
20
test/utils/ktesting/examples/with_ktesting/doc.go
Normal file
@ -0,0 +1,20 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
// The tests will fail and therefore are excluded from normal "make test" via
|
||||
// the "example" build tag. To run the tests and check the output, use "go test
|
||||
// -tags example ."
|
||||
package withktesting
|
52
test/utils/ktesting/examples/with_ktesting/example_test.go
Normal file
52
test/utils/ktesting/examples/with_ktesting/example_test.go
Normal file
@ -0,0 +1,52 @@
|
||||
//go:build example
|
||||
// +build example
|
||||
|
||||
/*
|
||||
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 withktesting
|
||||
|
||||
// The tests below will fail and therefore are excluded from
|
||||
// normal "make test" via the "example" build tag. To run
|
||||
// the tests and check the output, use "go test -tags example ."
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"k8s.io/kubernetes/test/utils/ktesting"
|
||||
)
|
||||
|
||||
func TestTimeout(t *testing.T) {
|
||||
tCtx := ktesting.Init(t)
|
||||
tmp := t.TempDir()
|
||||
tCtx.Logf("Using %q as temporary directory.", tmp)
|
||||
tCtx.Cleanup(func() {
|
||||
t.Log("Cleaning up...")
|
||||
})
|
||||
if deadline, ok := t.Deadline(); ok {
|
||||
t.Logf("Will fail shortly before the test suite deadline at %s.", deadline)
|
||||
}
|
||||
select {
|
||||
case <-time.After(1000 * time.Hour):
|
||||
// This should not be reached.
|
||||
tCtx.Log("Huh?! I shouldn't be that old.")
|
||||
case <-tCtx.Done():
|
||||
// But this will before the test suite timeout.
|
||||
tCtx.Errorf("need to stop: %v", context.Cause(tCtx))
|
||||
}
|
||||
}
|
20
test/utils/ktesting/examples/without_ktesting/doc.go
Normal file
20
test/utils/ktesting/examples/without_ktesting/doc.go
Normal file
@ -0,0 +1,20 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
// The tests will fail and therefore are excluded from normal "make test" via
|
||||
// the "example" build tag. To run the tests and check the output, use "go test
|
||||
// -tags example ."
|
||||
package withoutktesting
|
@ -0,0 +1,40 @@
|
||||
//go:build example
|
||||
// +build example
|
||||
|
||||
/*
|
||||
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 withoutktesting
|
||||
|
||||
// The tests below will fail and therefore are excluded from
|
||||
// normal "make test" via the "example" build tag. To run
|
||||
// the tests and check the output, use "go test -tags example ."
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTimeout(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
t.Logf("Using %q as temporary directory.", tmp)
|
||||
t.Cleanup(func() {
|
||||
t.Log("Cleaning up...")
|
||||
})
|
||||
// This will not complete anytime soon...
|
||||
t.Log("Please kill me.")
|
||||
<-time.After(1000 * time.Hour)
|
||||
}
|
30
test/utils/ktesting/initoption/initoption.go
Normal file
30
test/utils/ktesting/initoption/initoption.go
Normal file
@ -0,0 +1,30 @@
|
||||
/*
|
||||
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 initoption
|
||||
|
||||
import "k8s.io/kubernetes/test/utils/ktesting/internal"
|
||||
|
||||
// InitOption is a functional option for Init and InitCtx.
|
||||
type InitOption func(c *internal.InitConfig)
|
||||
|
||||
// PerTestOutput controls whether a per-test logger gets
|
||||
// set up by Init. Has no effect in InitCtx.
|
||||
func PerTestOutput(enabled bool) InitOption {
|
||||
return func(c *internal.InitConfig) {
|
||||
c.PerTestOutput = enabled
|
||||
}
|
||||
}
|
21
test/utils/ktesting/internal/config.go
Normal file
21
test/utils/ktesting/internal/config.go
Normal file
@ -0,0 +1,21 @@
|
||||
/*
|
||||
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 internal
|
||||
|
||||
type InitConfig struct {
|
||||
PerTestOutput bool
|
||||
}
|
76
test/utils/ktesting/klogcontext.go
Normal file
76
test/utils/ktesting/klogcontext.go
Normal file
@ -0,0 +1,76 @@
|
||||
/*
|
||||
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 (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var timeNow = time.Now // Can be stubbed out for testing.
|
||||
|
||||
// withKlogHeader creates a TB where the same "I<date> <time>]" prefix
|
||||
// gets added to all log output, just as in the klog test logger.
|
||||
// This is used internally when constructing a TContext for unit testing.
|
||||
func withKlogHeader(tb TB) TB {
|
||||
return klogTB{
|
||||
TB: tb,
|
||||
}
|
||||
}
|
||||
|
||||
type klogTB struct {
|
||||
TB
|
||||
}
|
||||
|
||||
func (k klogTB) Log(args ...any) {
|
||||
k.Helper()
|
||||
k.TB.Log(header() + strings.TrimSpace(fmt.Sprintln(args...)))
|
||||
}
|
||||
|
||||
func (k klogTB) Logf(format string, args ...any) {
|
||||
k.Helper()
|
||||
k.TB.Log(header() + strings.TrimSpace(fmt.Sprintf(format, args...)))
|
||||
}
|
||||
|
||||
func (k klogTB) Error(args ...any) {
|
||||
k.Helper()
|
||||
k.TB.Error(header() + strings.TrimSpace(fmt.Sprintln(args...)))
|
||||
}
|
||||
|
||||
func (k klogTB) Errorf(format string, args ...any) {
|
||||
k.Helper()
|
||||
k.TB.Error(header() + strings.TrimSpace(fmt.Sprintf(format, args...)))
|
||||
}
|
||||
|
||||
func (k klogTB) Fatal(args ...any) {
|
||||
k.Helper()
|
||||
k.TB.Fatal(header() + strings.TrimSpace(fmt.Sprintln(args...)))
|
||||
}
|
||||
|
||||
func (k klogTB) Fatalf(format string, args ...any) {
|
||||
k.Helper()
|
||||
k.TB.Fatal(header() + strings.TrimSpace(fmt.Sprintf(format, args...)))
|
||||
}
|
||||
|
||||
func header() string {
|
||||
now := timeNow()
|
||||
_, month, day := now.Date()
|
||||
hour, minute, second := now.Clock()
|
||||
return fmt.Sprintf("I%02d%02d %02d:%02d:%02d.%06d] ",
|
||||
month, day, hour, minute, second, now.Nanosecond()/1000)
|
||||
}
|
@ -14,21 +14,18 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
// Package ktesting is a wrapper around k8s.io/klog/v2/ktesting. It provides
|
||||
// those (and only those) functions that test code in Kubernetes should use,
|
||||
// plus better dumping of complex datatypes. It adds the klog command line
|
||||
// flags and increases the default verbosity to 5.
|
||||
package ktesting
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
_ "k8s.io/component-base/logs/testinit"
|
||||
"k8s.io/klog/v2"
|
||||
"k8s.io/klog/v2/ktesting"
|
||||
"k8s.io/kubernetes/test/utils/format"
|
||||
|
||||
// Initialize command line parameters.
|
||||
_ "k8s.io/component-base/logs/testinit"
|
||||
)
|
||||
|
||||
func init() {
|
||||
@ -44,30 +41,14 @@ func SetDefaultVerbosity(v int) {
|
||||
_ = f.Value.Set(fmt.Sprintf("%d", v))
|
||||
}
|
||||
|
||||
// NewTestContext is a wrapper around ktesting.NewTestContext with settings
|
||||
// specific to Kubernetes.
|
||||
func NewTestContext(tl ktesting.TL) (klog.Logger, context.Context) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
logger := ktesting.NewLogger(tl, config)
|
||||
ctx := klog.NewContext(context.Background(), logger)
|
||||
return logger, ctx
|
||||
// NewTestContext is a drop-in replacement for ktesting.NewTestContext
|
||||
// which returns a more versatile context.
|
||||
//
|
||||
// The type of that context is still context.Context because replacing
|
||||
// it with TContext breaks tests which use `WithCancel`.
|
||||
//
|
||||
// TODO(pohly): change all of that code together with changing the return type.
|
||||
func NewTestContext(tb testing.TB) (klog.Logger, context.Context) {
|
||||
tCtx := Init(tb)
|
||||
return tCtx.Logger(), tCtx
|
||||
}
|
||||
|
139
test/utils/ktesting/signals.go
Normal file
139
test/utils/ktesting/signals.go
Normal file
@ -0,0 +1,139 @@
|
||||
/*
|
||||
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"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var (
|
||||
interruptCtx context.Context
|
||||
|
||||
defaultProgressReporter = new(progressReporter)
|
||||
)
|
||||
|
||||
const ginkgoSpecContextKey = "GINKGO_SPEC_CONTEXT"
|
||||
|
||||
type ginkgoReporter interface {
|
||||
AttachProgressReporter(reporter func() string) func()
|
||||
}
|
||||
|
||||
func init() {
|
||||
// Setting up signals is intentionally done in an init function because
|
||||
// then importing ktesting in a unit or integration test is sufficient
|
||||
// to activate the signal behavior.
|
||||
signalCtx, _ := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||
cancelCtx, cancel := context.WithCancelCause(context.Background())
|
||||
go func() {
|
||||
<-signalCtx.Done()
|
||||
cancel(errors.New("received interrupt signal"))
|
||||
}()
|
||||
|
||||
// This reimplements the contract between Ginkgo and Gomega for progress reporting.
|
||||
// When using Ginkgo contexts, Ginkgo will implement it. This here is for "go test".
|
||||
//
|
||||
// nolint:staticcheck // It complains about using a plain string. This can only be fixed
|
||||
// by Ginkgo and Gomega formalizing this interface and define a type (somewhere...
|
||||
// probably cannot be in either Ginkgo or Gomega).
|
||||
interruptCtx = context.WithValue(cancelCtx, ginkgoSpecContextKey, defaultProgressReporter)
|
||||
|
||||
signalChannel := make(chan os.Signal, 1)
|
||||
// progressSignals will be empty on Windows.
|
||||
if len(progressSignals) > 0 {
|
||||
signal.Notify(signalChannel, progressSignals...)
|
||||
}
|
||||
|
||||
// os.Stderr gets redirected by "go test". "go test -v" has to be
|
||||
// used to see the output while a test runs.
|
||||
go defaultProgressReporter.run(interruptCtx, os.Stderr, signalChannel)
|
||||
}
|
||||
|
||||
type progressReporter struct {
|
||||
mutex sync.Mutex
|
||||
reporterCounter int64
|
||||
reporters map[int64]func() string
|
||||
}
|
||||
|
||||
var _ ginkgoReporter = &progressReporter{}
|
||||
|
||||
// AttachProgressReporter implements Gomega's contextWithAttachProgressReporter.
|
||||
func (p *progressReporter) AttachProgressReporter(reporter func() string) func() {
|
||||
p.mutex.Lock()
|
||||
defer p.mutex.Unlock()
|
||||
|
||||
// TODO (?): identify the caller and record that for dumpProgress.
|
||||
p.reporterCounter++
|
||||
id := p.reporterCounter
|
||||
if p.reporters == nil {
|
||||
p.reporters = make(map[int64]func() string)
|
||||
}
|
||||
p.reporters[id] = reporter
|
||||
return func() {
|
||||
p.detachProgressReporter(id)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *progressReporter) detachProgressReporter(id int64) {
|
||||
p.mutex.Lock()
|
||||
defer p.mutex.Unlock()
|
||||
|
||||
delete(p.reporters, id)
|
||||
}
|
||||
|
||||
func (p *progressReporter) run(ctx context.Context, out io.Writer, progressSignalChannel chan os.Signal) {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-progressSignalChannel:
|
||||
p.dumpProgress(out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// dumpProgress is less useful than the Ginkgo progress report. We can't fix
|
||||
// that we don't know which tests are currently running and instead have to
|
||||
// rely on "go test -v" for that.
|
||||
//
|
||||
// But perhaps dumping goroutines and their callstacks is useful anyway? TODO:
|
||||
// look at how Ginkgo does it and replicate some of it.
|
||||
func (p *progressReporter) dumpProgress(out io.Writer) {
|
||||
p.mutex.Lock()
|
||||
defer p.mutex.Unlock()
|
||||
|
||||
var buffer strings.Builder
|
||||
buffer.WriteString("You requested a progress report.\n")
|
||||
if len(p.reporters) == 0 {
|
||||
buffer.WriteString("Currently there is no information about test progress available.\n")
|
||||
}
|
||||
for _, reporter := range p.reporters {
|
||||
report := reporter()
|
||||
buffer.WriteRune('\n')
|
||||
buffer.WriteString(report)
|
||||
if !strings.HasSuffix(report, "\n") {
|
||||
buffer.WriteRune('\n')
|
||||
}
|
||||
}
|
||||
|
||||
_, _ = out.Write([]byte(buffer.String()))
|
||||
}
|
31
test/utils/ktesting/signals_non_win.go
Normal file
31
test/utils/ktesting/signals_non_win.go
Normal file
@ -0,0 +1,31 @@
|
||||
//go:build !windows
|
||||
// +build !windows
|
||||
|
||||
/*
|
||||
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 (
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
var (
|
||||
// If there ever is a non-Windows build platform that doesn't
|
||||
// have SIGUSR1, then we need to add another exception for it.
|
||||
progressSignals = []os.Signal{syscall.SIGUSR1}
|
||||
)
|
28
test/utils/ktesting/signals_win.go
Normal file
28
test/utils/ktesting/signals_win.go
Normal file
@ -0,0 +1,28 @@
|
||||
//go:build windows
|
||||
// +build windows
|
||||
|
||||
/*
|
||||
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 (
|
||||
"os"
|
||||
)
|
||||
|
||||
var (
|
||||
progressSignals = []os.Signal{}
|
||||
)
|
101
test/utils/ktesting/stepcontext.go
Normal file
101
test/utils/ktesting/stepcontext.go
Normal file
@ -0,0 +1,101 @@
|
||||
/*
|
||||
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 (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"k8s.io/klog/v2"
|
||||
)
|
||||
|
||||
// WithStep creates a context where a prefix is added to all errors and log
|
||||
// messages, similar to how errors are wrapped. This can be nested, leaving a
|
||||
// trail of "bread crumbs" that help figure out where in a test some problem
|
||||
// occurred or why some log output gets written:
|
||||
//
|
||||
// ERROR: bake cake: set heat for baking: oven not found
|
||||
//
|
||||
// The string should describe the operation that is about to happen ("starting
|
||||
// the controller", "list items") or what is being operated on ("HTTP server").
|
||||
// Multiple different prefixes get concatenated with a colon.
|
||||
func WithStep(tCtx TContext, what string) TContext {
|
||||
sCtx := &stepContext{
|
||||
TContext: tCtx,
|
||||
what: what,
|
||||
}
|
||||
return WithLogger(sCtx, klog.LoggerWithName(sCtx.Logger(), what))
|
||||
}
|
||||
|
||||
type stepContext struct {
|
||||
TContext
|
||||
what string
|
||||
}
|
||||
|
||||
func (sCtx *stepContext) Log(args ...any) {
|
||||
sCtx.Helper()
|
||||
sCtx.TContext.Log(sCtx.what + ": " + strings.TrimSpace(fmt.Sprintln(args...)))
|
||||
}
|
||||
|
||||
func (sCtx *stepContext) Logf(format string, args ...any) {
|
||||
sCtx.Helper()
|
||||
sCtx.TContext.Log(sCtx.what + ": " + strings.TrimSpace(fmt.Sprintf(format, args...)))
|
||||
}
|
||||
|
||||
func (sCtx *stepContext) Error(args ...any) {
|
||||
sCtx.Helper()
|
||||
sCtx.TContext.Error(sCtx.what + ": " + strings.TrimSpace(fmt.Sprintln(args...)))
|
||||
}
|
||||
|
||||
func (sCtx *stepContext) Errorf(format string, args ...any) {
|
||||
sCtx.Helper()
|
||||
sCtx.TContext.Error(sCtx.what + ": " + strings.TrimSpace(fmt.Sprintf(format, args...)))
|
||||
}
|
||||
|
||||
func (sCtx *stepContext) Fatal(args ...any) {
|
||||
sCtx.Helper()
|
||||
sCtx.TContext.Fatal(sCtx.what + ": " + strings.TrimSpace(fmt.Sprintln(args...)))
|
||||
}
|
||||
|
||||
func (sCtx *stepContext) Fatalf(format string, args ...any) {
|
||||
sCtx.Helper()
|
||||
sCtx.TContext.Fatal(sCtx.what + ": " + strings.TrimSpace(fmt.Sprintf(format, args...)))
|
||||
}
|
||||
|
||||
// Value intercepts a search for the special
|
||||
func (sCtx *stepContext) Value(key any) any {
|
||||
if s, ok := key.(string); ok && s == ginkgoSpecContextKey {
|
||||
if reporter, ok := sCtx.TContext.Value(key).(ginkgoReporter); ok {
|
||||
return ginkgoReporter(&stepReporter{reporter: reporter, what: sCtx.what})
|
||||
}
|
||||
}
|
||||
return sCtx.TContext.Value(key)
|
||||
}
|
||||
|
||||
type stepReporter struct {
|
||||
reporter ginkgoReporter
|
||||
what string
|
||||
}
|
||||
|
||||
var _ ginkgoReporter = &stepReporter{}
|
||||
|
||||
func (s *stepReporter) AttachProgressReporter(reporter func() string) func() {
|
||||
return s.reporter.AttachProgressReporter(func() string {
|
||||
report := reporter()
|
||||
return s.what + ": " + report
|
||||
})
|
||||
}
|
466
test/utils/ktesting/tcontext.go
Normal file
466
test/utils/ktesting/tcontext.go
Normal file
@ -0,0 +1,466 @@
|
||||
/*
|
||||
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"
|
||||
)
|
||||
|
||||
// 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 first-in-first-out order.
|
||||
//
|
||||
// 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
|
||||
}
|
83
test/utils/ktesting/tcontext_test.go
Normal file
83
test/utils/ktesting/tcontext_test.go
Normal file
@ -0,0 +1,83 @@
|
||||
/*
|
||||
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_test
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"k8s.io/client-go/rest"
|
||||
"k8s.io/klog/v2"
|
||||
"k8s.io/kubernetes/test/utils/ktesting"
|
||||
)
|
||||
|
||||
func TestCancelManual(t *testing.T) {
|
||||
tCtx := ktesting.Init(t)
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
// Blocks until tCtx.Cancel is called below.
|
||||
<-tCtx.Done()
|
||||
}()
|
||||
tCtx.Cancel("manually canceled")
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestCancelAutomatic(t *testing.T) {
|
||||
var wg sync.WaitGroup
|
||||
// This callback gets registered first and thus
|
||||
// gets invoked last.
|
||||
t.Cleanup(wg.Wait)
|
||||
tCtx := ktesting.Init(t)
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
// Blocks until the context gets canceled automatically.
|
||||
<-tCtx.Done()
|
||||
}()
|
||||
}
|
||||
|
||||
func TestCancelCtx(t *testing.T) {
|
||||
tCtx := ktesting.Init(t)
|
||||
var discardLogger klog.Logger
|
||||
tCtx = ktesting.WithLogger(tCtx, discardLogger)
|
||||
tCtx = ktesting.WithRESTConfig(tCtx, new(rest.Config))
|
||||
baseCtx := tCtx
|
||||
|
||||
tCtx.Cleanup(func() {
|
||||
if tCtx.Err() == nil {
|
||||
t.Error("context should be canceled but isn't")
|
||||
}
|
||||
})
|
||||
tCtx.CleanupCtx(func(tCtx ktesting.TContext) {
|
||||
if tCtx.Err() != nil {
|
||||
t.Errorf("context should not be canceled but is: %v", tCtx.Err())
|
||||
}
|
||||
assert.Equal(t, baseCtx.Logger(), tCtx.Logger(), "Logger()")
|
||||
assert.Equal(t, baseCtx.RESTConfig(), tCtx.RESTConfig(), "RESTConfig()")
|
||||
assert.Equal(t, baseCtx.RESTMapper(), tCtx.RESTMapper(), "RESTMapper()")
|
||||
assert.Equal(t, baseCtx.Client(), tCtx.Client(), "Client()")
|
||||
assert.Equal(t, baseCtx.Dynamic(), tCtx.Dynamic(), "Dynamic()")
|
||||
assert.Equal(t, baseCtx.APIExtensions(), tCtx.APIExtensions(), "APIExtensions()")
|
||||
})
|
||||
|
||||
// Cancel, then let testing.T invoke test cleanup.
|
||||
tCtx.Cancel("test is complete")
|
||||
}
|
123
test/utils/ktesting/withcontext.go
Normal file
123
test/utils/ktesting/withcontext.go
Normal file
@ -0,0 +1,123 @@
|
||||
/*
|
||||
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"
|
||||
"time"
|
||||
|
||||
"github.com/onsi/gomega"
|
||||
"k8s.io/klog/v2"
|
||||
)
|
||||
|
||||
// WithCancel sets up cancellation in a [TContext.Cleanup] callback and
|
||||
// constructs a new TContext where [TContext.Cancel] cancels only the new
|
||||
// context.
|
||||
func WithCancel(tCtx TContext) TContext {
|
||||
ctx, cancel := context.WithCancelCause(tCtx)
|
||||
tCtx.Cleanup(func() {
|
||||
cancel(cleanupErr(tCtx.Name()))
|
||||
})
|
||||
|
||||
return withContext{
|
||||
TContext: tCtx,
|
||||
Context: ctx,
|
||||
cancel: func(cause string) {
|
||||
var cancelCause error
|
||||
if cause != "" {
|
||||
cancelCause = canceledError(cause)
|
||||
}
|
||||
cancel(cancelCause)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// WithTimeout sets up new context with a timeout. Canceling the timeout gets
|
||||
// registered in a [TContext.Cleanup] callback. [TContext.Cancel] cancels only
|
||||
// the new context. The cause is used as reason why the context is canceled
|
||||
// once the timeout is reached. It may be empty, in which case the usual
|
||||
// "context canceled" error is used.
|
||||
func WithTimeout(tCtx TContext, timeout time.Duration, timeoutCause string) TContext {
|
||||
tCtx.Helper()
|
||||
ctx, cancel := withTimeout(tCtx, tCtx.TB(), timeout, timeoutCause)
|
||||
|
||||
return withContext{
|
||||
TContext: tCtx,
|
||||
Context: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
}
|
||||
|
||||
// WithLogger constructs a new context with a different logger.
|
||||
func WithLogger(tCtx TContext, logger klog.Logger) TContext {
|
||||
ctx := klog.NewContext(tCtx, logger)
|
||||
|
||||
return withContext{
|
||||
TContext: tCtx,
|
||||
Context: ctx,
|
||||
cancel: tCtx.Cancel,
|
||||
}
|
||||
}
|
||||
|
||||
// withContext combines some TContext with a new [context.Context] derived
|
||||
// from it. Because both provide the [context.Context] interface, methods must
|
||||
// be defined which pick the newer one.
|
||||
type withContext struct {
|
||||
TContext
|
||||
context.Context
|
||||
|
||||
cancel func(cause string)
|
||||
}
|
||||
|
||||
func (wCtx withContext) Cancel(cause string) {
|
||||
wCtx.cancel(cause)
|
||||
}
|
||||
|
||||
func (wCtx withContext) CleanupCtx(cb func(TContext)) {
|
||||
wCtx.Helper()
|
||||
cleanupCtx(wCtx, cb)
|
||||
}
|
||||
|
||||
func (wCtx withContext) Expect(actual interface{}, extra ...interface{}) gomega.Assertion {
|
||||
wCtx.Helper()
|
||||
return expect(wCtx, actual, extra...)
|
||||
}
|
||||
|
||||
func (wCtx withContext) ExpectNoError(err error, explain ...interface{}) {
|
||||
wCtx.Helper()
|
||||
expectNoError(wCtx, err, explain...)
|
||||
}
|
||||
|
||||
func (wCtx withContext) Logger() klog.Logger {
|
||||
return klog.FromContext(wCtx)
|
||||
}
|
||||
|
||||
func (wCtx withContext) Deadline() (time.Time, bool) {
|
||||
return wCtx.Context.Deadline()
|
||||
}
|
||||
|
||||
func (wCtx withContext) Done() <-chan struct{} {
|
||||
return wCtx.Context.Done()
|
||||
}
|
||||
|
||||
func (wCtx withContext) Err() error {
|
||||
return wCtx.Context.Err()
|
||||
}
|
||||
|
||||
func (wCtx withContext) Value(key any) any {
|
||||
return wCtx.Context.Value(key)
|
||||
}
|
Loading…
Reference in New Issue
Block a user