From c7512236fcb7fce9066ea746f72319ec531581ce Mon Sep 17 00:00:00 2001 From: Lukas Krejci Date: Fri, 22 Nov 2024 15:10:10 +0100 Subject: [PATCH 1/7] proof of concept of the more type-safe way of defining the predicates. --- .../predicates/conditions/conditions.go | 107 ++++++++++++++++++ testsupport/predicates/object/object.go | 69 +++++++++++ testsupport/predicates/predicate.go | 43 +++++++ testsupport/predicates/predicatecollector.go | 42 +++++++ .../predicates/spaceprovisionerconfig/spc.go | 78 +++++++++++++ .../spaceprovisionerconfig/spc_test.go | 59 ++++++++++ testsupport/predicates/wait.go | 28 +++++ 7 files changed, 426 insertions(+) create mode 100644 testsupport/predicates/conditions/conditions.go create mode 100644 testsupport/predicates/object/object.go create mode 100644 testsupport/predicates/predicate.go create mode 100644 testsupport/predicates/predicatecollector.go create mode 100644 testsupport/predicates/spaceprovisionerconfig/spc.go create mode 100644 testsupport/predicates/spaceprovisionerconfig/spc_test.go create mode 100644 testsupport/predicates/wait.go diff --git a/testsupport/predicates/conditions/conditions.go b/testsupport/predicates/conditions/conditions.go new file mode 100644 index 000000000..a083873fb --- /dev/null +++ b/testsupport/predicates/conditions/conditions.go @@ -0,0 +1,107 @@ +package conditions + +import ( + "github.com/codeready-toolchain/api/api/v1alpha1" + "github.com/codeready-toolchain/toolchain-common/pkg/condition" + "github.com/codeready-toolchain/toolchain-e2e/testsupport/predicates" + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// Predicates are all the predicates that can be defined on any object that has +// conditions. +type Predicates[Self any, T client.Object] interface { + HasConditionWithType(typ v1alpha1.ConditionType, preds ...predicates.Predicate[v1alpha1.Condition]) Self +} + +// WithStatus checks that a condition has the provided status +func WithStatus(status corev1.ConditionStatus) predicates.Predicate[v1alpha1.Condition] { + return &withStatus{status: status} +} + +// ConditionPredicates is a struct implementing the Predicates interface in this package. +// It is meant to be embedded into other types implementing the predicate collectors for +// concrete CRDs. +type ConditionPredicates[Self any, T client.Object] struct { + predicates.EmbedablePredicates[Self, T] + + // Accessor is a function that translates from the object to its conditions. + // It returns a pointer to the slice so that the slice can also be initialized + // when it is nil and append works even if it re-allocates the array. + accessor func(T) *[]v1alpha1.Condition +} + +func (p *ConditionPredicates[Self, T]) HasConditionWithType(typ v1alpha1.ConditionType, predicates ...predicates.Predicate[v1alpha1.Condition]) Self { + *p.Preds = append(*p.Preds, &testConditionPred[T]{accessor: p.accessor, typ: typ, preds: predicates}) + return p.Self +} + +// EmbedInto is a specialized version of the embedding function that sets up the self and predicates but also +// sets the accessor function that translates from an object of type T into its list of conditions. +func (p *ConditionPredicates[Self, T]) EmbedInto(self Self, predicates *[]predicates.Predicate[T], accessor func(T) *[]v1alpha1.Condition) { + p.EmbedablePredicates.EmbedInto(self, predicates) + p.accessor = accessor +} + +type testConditionPred[T client.Object] struct { + accessor func(T) *[]v1alpha1.Condition + typ v1alpha1.ConditionType + preds []predicates.Predicate[v1alpha1.Condition] +} + +func (p *testConditionPred[T]) Matches(obj T) (bool, error) { + conds := p.accessor(obj) + cond, ok := condition.FindConditionByType(*conds, p.typ) + if !ok { + return false, nil + } + + for _, pred := range p.preds { + ok, err := pred.Matches(cond) + if err != nil { + return false, err + } + if !ok { + return false, nil + } + } + + return true, nil +} + +func (p *testConditionPred[T]) FixToMatch(obj T) (T, error) { + copy := obj.DeepCopyObject().(T) + conds := p.accessor(copy) + cond, ok := condition.FindConditionByType(*conds, p.typ) + if !ok { + return obj, nil + } + + for _, pred := range p.preds { + if pred, ok := pred.(predicates.PredicateMatchFixer[v1alpha1.Condition]); ok { + var err error + cond, err = pred.FixToMatch(cond) + if err != nil { + return obj, err + } + } + } + + *conds, _ = condition.AddOrUpdateStatusConditions(*conds, cond) + + return copy, nil +} + +type withStatus struct { + status corev1.ConditionStatus +} + +func (p *withStatus) Matches(cond v1alpha1.Condition) (bool, error) { + return cond.Status == p.status, nil +} + +func (p *withStatus) FixToMatch(cond v1alpha1.Condition) (v1alpha1.Condition, error) { + // cond is passed by value and is not a pointer so no need to copy + cond.Status = p.status + return cond, nil +} diff --git a/testsupport/predicates/object/object.go b/testsupport/predicates/object/object.go new file mode 100644 index 000000000..777de94a4 --- /dev/null +++ b/testsupport/predicates/object/object.go @@ -0,0 +1,69 @@ +package object + +import ( + "github.com/codeready-toolchain/toolchain-e2e/testsupport/predicates" + "k8s.io/utils/strings/slices" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// Predicates defines all the predicates that can be applied to any Object. +type Predicates[Self any, T client.Object] interface { + HasName(name string) Self + HasFinalizer(finalizerName string) Self +} + +// ObjectPredicates implements the Predicates interface in this package. +// It is not meant to be used directly but rather embedded into other structs that define +// the predicates for individual CRDs. +type ObjectPredicates[Self any, T client.Object] struct { + predicates.EmbedablePredicates[Self, T] +} + +func (p *ObjectPredicates[Self, T]) HasName(name string) Self { + *p.Preds = append(*p.Preds, &namePredicate[T]{name: name}) + return p.Self +} + +func (p *ObjectPredicates[Self, T]) HasFinalizer(finalizerName string) Self { + *p.Preds = append(*p.Preds, &finalizerPredicate[T]{finalizer: finalizerName}) + return p.Self +} + +var ( + _ predicates.Predicate[client.Object] = (*namePredicate[client.Object])(nil) + _ predicates.PredicateMatchFixer[client.Object] = (*namePredicate[client.Object])(nil) + _ predicates.Predicate[client.Object] = (*finalizerPredicate[client.Object])(nil) + _ predicates.PredicateMatchFixer[client.Object] = (*finalizerPredicate[client.Object])(nil) +) + +type namePredicate[T client.Object] struct { + name string +} + +func (p *namePredicate[T]) Matches(obj T) (bool, error) { + return p.name == obj.GetName(), nil +} + +func (p *namePredicate[T]) FixToMatch(obj T) (T, error) { + obj = obj.DeepCopyObject().(T) + obj.SetName(p.name) + return obj, nil +} + +type finalizerPredicate[T client.Object] struct { + finalizer string +} + +func (p *finalizerPredicate[T]) Matches(obj T) (bool, error) { + return slices.Contains(obj.GetFinalizers(), p.finalizer), nil +} + +func (p *finalizerPredicate[T]) FixToMatch(obj T) (T, error) { + obj = obj.DeepCopyObject().(T) + fs := obj.GetFinalizers() + if !slices.Contains(fs, p.finalizer) { + fs = append(fs, p.finalizer) + } + obj.SetFinalizers(fs) + return obj, nil +} diff --git a/testsupport/predicates/predicate.go b/testsupport/predicates/predicate.go new file mode 100644 index 000000000..bcb8507a9 --- /dev/null +++ b/testsupport/predicates/predicate.go @@ -0,0 +1,43 @@ +package predicates + +import ( + "fmt" + "reflect" + + "github.com/google/go-cmp/cmp" +) + +// Predicate tests if an instance of some type matches it. It is fallible so that +// we can do weird stuff like pinging the route endpoints and not have to be weird +// about error handling in such predicates. +type Predicate[T any] interface { + Matches(T) (bool, error) +} + +type PredicateMatchFixer[T any] interface { + // FixToMatch repares the provided object to match the predicate. It needs to return + // a copy of the provided object so care needs to be taken if working with slices or + // pointers. + FixToMatch(T) (T, error) +} + +func Explain[T any](obj T, predicate Predicate[T]) (string, error) { + predicateType := reflect.TypeOf(predicate) + if predicateType.Kind() == reflect.Pointer { + predicateType = predicateType.Elem() + } + + prefix := fmt.Sprintf("predicate '%s' didn't match the object", predicateType.String()) + fix, ok := predicate.(PredicateMatchFixer[T]) + if !ok { + return prefix, nil + } + + expected, err := fix.FixToMatch(obj) + if err != nil { + return prefix, err + } + diff := cmp.Diff(expected, obj) + + return fmt.Sprintf("%s because of the following differences (- indicates the expected values, + the actual values):\n%s", prefix, diff), nil +} diff --git a/testsupport/predicates/predicatecollector.go b/testsupport/predicates/predicatecollector.go new file mode 100644 index 000000000..68062878c --- /dev/null +++ b/testsupport/predicates/predicatecollector.go @@ -0,0 +1,42 @@ +package predicates + +// PredicateCollector is a helper to Waiter struct and its methods, to which it can supply +// a list of predicates to check. An implementation of the PredicateCollector interface +// dictates what kind of predicates can be applied. Therefore, a predicate collector implementation +// is expected to exist for each CRD. +type PredicateCollector[T any] interface { + Predicates() []Predicate[T] +} + +// EmbedablePredicates is meant to be embedded into other structs actually offering some +// predicates for some type of object. It provides the storage for the predicates and also a +// "self-reference" that can be used to return right type of the top level struct when calling +// predicate methods of embedded collectors. +// +// See how this is used in ObjectPredicates and ConditionPredicates which are +// meant to be embedded and SpaceProvisionerConfigPredicates which embeds these two in it. +type EmbedablePredicates[Self any, T any] struct { + // Self is a typed reference to this instance used to return the correct type from methods + // of structs embedded in each other. + // + // THIS IS ONLY MADE PUBLIC SO THAT IT CAN BE ACCESSED FROM OTHER PACKAGES. DO NOT SET + // THIS FIELD - USE THE EmbedInto() METHOD. + Self Self + + // Preds is the list predicates. + // It returns a pointer to the slice so that the slice can also be initialized + // when it is nil and append works even if it re-allocates the array. + // + // THIS IS ONLY MADE PUBLIC SO THAT IT CAN BE ACCESSED FROM OTHER PACKAGES. DO NOT SET + // THIS FIELD - USE THE EmbedInto() METHOD. + Preds *[]Predicate[T] +} + +// EmbedInto is a helper function meant to be called by the constructor functions +// to "weave" the pointers to the actual instance that should be used as return type +// of the various predicate functions and the list of predicates that should be common +// to all embedded structs. +func (pc *EmbedablePredicates[Self, T]) EmbedInto(self Self, predicates *[]Predicate[T]) { + pc.Self = self + pc.Preds = predicates +} diff --git a/testsupport/predicates/spaceprovisionerconfig/spc.go b/testsupport/predicates/spaceprovisionerconfig/spc.go new file mode 100644 index 000000000..8bf5a8c18 --- /dev/null +++ b/testsupport/predicates/spaceprovisionerconfig/spc.go @@ -0,0 +1,78 @@ +package spaceprovisionerconfig + +import ( + "github.com/codeready-toolchain/api/api/v1alpha1" + "github.com/codeready-toolchain/toolchain-e2e/testsupport/predicates" + "github.com/codeready-toolchain/toolchain-e2e/testsupport/predicates/conditions" + "github.com/codeready-toolchain/toolchain-e2e/testsupport/predicates/object" +) + +// That is a "constructor" function to instantiate and initialize an instance of +// SpaceProvisionerConfigPredicates interface. +// +// The name is a bit "funny" but will read nicely when used: +// +// spaceprovisionerconfig.That().HasName("asdf").ReferencesToolchainCluster("asdf")... +func That() SpaceProvisionerConfigPredicates { + ret := &collector{} + ret.ObjectPredicates.EmbedInto(ret, &ret.preds) + ret.ConditionPredicates.EmbedInto(ret, &ret.preds, + func(spc *v1alpha1.SpaceProvisionerConfig) *[]v1alpha1.Condition { + return &spc.Status.Conditions + }) + return ret +} + +// SpaceProvisionerConfigPredicates is an interface defining all the predicates +// that can be applied on SpaceProvisionerConfig objects. +type SpaceProvisionerConfigPredicates interface { + // This is the actual "top-level" predicate collector, so we need to make sure we implement that interface + predicates.PredicateCollector[*v1alpha1.SpaceProvisionerConfig] + + // SpaceProvisionerConfigs are CRDs so we can embed the generic object predicates + object.Predicates[SpaceProvisionerConfigPredicates, *v1alpha1.SpaceProvisionerConfig] + + // SpaceProvisionerConfigs use conditions in their status so we can embed the generic condition predicates + conditions.Predicates[SpaceProvisionerConfigPredicates, *v1alpha1.SpaceProvisionerConfig] + + // These predicates are specific to SPCs + + ReferencesToolchainCluster(tc string) SpaceProvisionerConfigPredicates +} + +// collector is a private impl of the SpaceProvisionerConfigPredicates interface. This is so +// that we force the use of the That function in this package that correctly wires stuff up. +type collector struct { + // embed in the impl of the object predicates + object.ObjectPredicates[SpaceProvisionerConfigPredicates, *v1alpha1.SpaceProvisionerConfig] + + // embed the impl of the condition predicates + conditions.ConditionPredicates[SpaceProvisionerConfigPredicates, *v1alpha1.SpaceProvisionerConfig] + + // this is where all the predicates will be collected so that we can implement the predicate collector + // interface + preds []predicates.Predicate[*v1alpha1.SpaceProvisionerConfig] +} + +func (p *collector) ReferencesToolchainCluster(tc string) SpaceProvisionerConfigPredicates { + p.preds = append(p.preds, &referencesToolchainCluster{tc: tc}) + return p +} + +func (p *collector) Predicates() []predicates.Predicate[*v1alpha1.SpaceProvisionerConfig] { + return p.preds +} + +type referencesToolchainCluster struct { + tc string +} + +func (p *referencesToolchainCluster) Matches(spc *v1alpha1.SpaceProvisionerConfig) (bool, error) { + return spc.Spec.ToolchainCluster == p.tc, nil +} + +func (p *referencesToolchainCluster) FixToMatch(spc *v1alpha1.SpaceProvisionerConfig) (*v1alpha1.SpaceProvisionerConfig, error) { + copy := spc.DeepCopy() + copy.Spec.ToolchainCluster = p.tc + return copy, nil +} diff --git a/testsupport/predicates/spaceprovisionerconfig/spc_test.go b/testsupport/predicates/spaceprovisionerconfig/spc_test.go new file mode 100644 index 000000000..7ddd2d7da --- /dev/null +++ b/testsupport/predicates/spaceprovisionerconfig/spc_test.go @@ -0,0 +1,59 @@ +package spaceprovisionerconfig + +import ( + "testing" + + "github.com/codeready-toolchain/api/api/v1alpha1" + "github.com/codeready-toolchain/toolchain-e2e/testsupport/predicates" + "github.com/codeready-toolchain/toolchain-e2e/testsupport/predicates/conditions" + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestThat(t *testing.T) { + test := That(). + HasName("expected"). + HasConditionWithType(v1alpha1.ConditionReady, conditions.WithStatus(corev1.ConditionTrue)). + ReferencesToolchainCluster("cluster-1"). + HasFinalizer("fin") + + preds := test.Predicates() + assert.Len(t, preds, 4) + + spc := &v1alpha1.SpaceProvisionerConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "actual", + }, + Spec: v1alpha1.SpaceProvisionerConfigSpec{ + ToolchainCluster: "cluster-2", + }, + Status: v1alpha1.SpaceProvisionerConfigStatus{ + Conditions: []v1alpha1.Condition{ + { + Type: v1alpha1.ConditionReady, + Status: corev1.ConditionFalse, + }, + }, + }, + } + + // not actual tests here... This is just to visually inspect the test output + // to check that the diff actually works in Explain... This should of course + // be properly tested if we go with this approach. + + expl, _ := predicates.Explain(spc, preds[0]) + assert.Equal(t, "this is not the actual output", expl) + + expl, _ = predicates.Explain(spc, preds[1]) + assert.Equal(t, "this is not the actual output", expl) + + expl, _ = predicates.Explain(spc, preds[2]) + assert.Equal(t, "this is not the actual output", expl) + + expl, _ = predicates.Explain(spc, preds[1]) + assert.Equal(t, "this is not the actual output", expl) + + expl, _ = predicates.Explain(spc, preds[3]) + assert.Equal(t, "this is not the actual output", expl) +} diff --git a/testsupport/predicates/wait.go b/testsupport/predicates/wait.go new file mode 100644 index 000000000..9bd4e2fc0 --- /dev/null +++ b/testsupport/predicates/wait.go @@ -0,0 +1,28 @@ +package predicates + +import ( + "testing" + + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func WaitFor[T client.Object](t *testing.T, cl client.Client) *Waiter[T] { + return &Waiter[T]{cl: cl} +} + +type Waiter[T client.Object] struct { + cl client.Client +} + +func (w *Waiter[T]) First(preds PredicateCollector[T]) (T, error) { + _ = preds.Predicates() + + // once we have the list of predicates, the implemetation of this method will be very + // similar to what is already present in the wait package. + // Or rather, we will change the impl in the wait package to accept the PredicateCollector[T]. + + // using this will read as: + // + // wait.WaitFor[*toolchainv1alpha1.SpaceProvisonerConfig](t, cl).First(spaceprovisionerconfig.That().HasName(...)...) + panic("not implemented") +} From 9d624755e7d542f93ac22f9077219a1b80ff855a Mon Sep 17 00:00:00 2001 From: Lukas Krejci Date: Thu, 28 Nov 2024 10:17:13 +0100 Subject: [PATCH 2/7] remove the duplicated "explain" test --- testsupport/predicates/spaceprovisionerconfig/spc_test.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/testsupport/predicates/spaceprovisionerconfig/spc_test.go b/testsupport/predicates/spaceprovisionerconfig/spc_test.go index 7ddd2d7da..be61c927a 100644 --- a/testsupport/predicates/spaceprovisionerconfig/spc_test.go +++ b/testsupport/predicates/spaceprovisionerconfig/spc_test.go @@ -51,9 +51,6 @@ func TestThat(t *testing.T) { expl, _ = predicates.Explain(spc, preds[2]) assert.Equal(t, "this is not the actual output", expl) - expl, _ = predicates.Explain(spc, preds[1]) - assert.Equal(t, "this is not the actual output", expl) - expl, _ = predicates.Explain(spc, preds[3]) assert.Equal(t, "this is not the actual output", expl) } From 620590848cd55869cbbd80461db94c3d7934f91c Mon Sep 17 00:00:00 2001 From: Lukas Krejci Date: Thu, 6 Feb 2025 17:37:05 +0100 Subject: [PATCH 3/7] Adding a variant in the assertions package that reuses assert.* methods to perform tests. --- testsupport/assertions/assertion.go | 47 +++++ .../assertions/conditions/conditions.go | 29 +++ testsupport/assertions/object/object.go | 43 ++++ testsupport/assertions/objectundertest.go | 25 +++ .../assertions/spaceprovisionerconfig/spc.go | 42 ++++ testsupport/assertions/wait.go | 194 ++++++++++++++++++ testsupport/assertions_use/assertions_test.go | 38 ++++ 7 files changed, 418 insertions(+) create mode 100644 testsupport/assertions/assertion.go create mode 100644 testsupport/assertions/conditions/conditions.go create mode 100644 testsupport/assertions/object/object.go create mode 100644 testsupport/assertions/objectundertest.go create mode 100644 testsupport/assertions/spaceprovisionerconfig/spc.go create mode 100644 testsupport/assertions/wait.go create mode 100644 testsupport/assertions_use/assertions_test.go diff --git a/testsupport/assertions/assertion.go b/testsupport/assertions/assertion.go new file mode 100644 index 000000000..e8f5615cd --- /dev/null +++ b/testsupport/assertions/assertion.go @@ -0,0 +1,47 @@ +package assertions + +import ( + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type Assertion[T any] func(t AssertT, obj T) + +type EmbeddableAssertions[Self any, T any] struct { + assertions *[]Assertion[T] + self *Self +} + +type WithAssertions[T any] interface { + Assertions() []Assertion[T] +} + +type AssertT interface { + assert.TestingT + Helper() +} + +type RequireT interface { + require.TestingT + Helper() +} + +func Test[T any, A WithAssertions[T]](t AssertT, obj T, assertions A) { + t.Helper() + for _, a := range assertions.Assertions() { + a(t, obj) + } +} + +func (a *EmbeddableAssertions[Self, T]) Self() *Self { + return a.self +} + +func (a *EmbeddableAssertions[Self, T]) EmbedInto(self *Self, assertions *[]Assertion[T]) { + a.self = self + a.assertions = assertions +} + +func (ea *EmbeddableAssertions[Self, T]) AddAssertion(a Assertion[T]) { + *ea.assertions = append(*ea.assertions, a) +} diff --git a/testsupport/assertions/conditions/conditions.go b/testsupport/assertions/conditions/conditions.go new file mode 100644 index 000000000..59ad9b783 --- /dev/null +++ b/testsupport/assertions/conditions/conditions.go @@ -0,0 +1,29 @@ +package conditions + +import ( + toolchainv1aplha1 "github.com/codeready-toolchain/api/api/v1alpha1" + "github.com/codeready-toolchain/toolchain-common/pkg/condition" + "github.com/codeready-toolchain/toolchain-e2e/testsupport/assertions" + "github.com/stretchr/testify/assert" +) + +type Assertions[Self any, T any] struct { + assertions.EmbeddableAssertions[Self, T] + + accessor func(T) []toolchainv1aplha1.Condition +} + +func (a *Assertions[Self, T]) EmbedInto(self *Self, assertions *[]assertions.Assertion[T], accessor func(T) []toolchainv1aplha1.Condition) { + a.EmbeddableAssertions.EmbedInto(self, assertions) + a.accessor = accessor +} + +func (a *Assertions[Self, T]) HasConditionWithType(typ toolchainv1aplha1.ConditionType) *Self { + a.AddAssertion(func(t assertions.AssertT, obj T) { + t.Helper() + conds := a.accessor(obj) + _, found := condition.FindConditionByType(conds, typ) + assert.True(t, found, "condition with the type %s not found", typ) + }) + return a.Self() +} diff --git a/testsupport/assertions/object/object.go b/testsupport/assertions/object/object.go new file mode 100644 index 000000000..007840731 --- /dev/null +++ b/testsupport/assertions/object/object.go @@ -0,0 +1,43 @@ +package object + +import ( + "github.com/codeready-toolchain/toolchain-e2e/testsupport/assertions" + "github.com/stretchr/testify/assert" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +type Assertions[Self any, T client.Object] struct { + assertions.EmbeddableAssertions[Self, T] +} + +func (o *Assertions[Self, T]) HasLabel(label string) *Self { + o.AddAssertion(func(t assertions.AssertT, o T) { + t.Helper() + assert.Contains(t, o.GetLabels(), label, "label '%s' not found", label) + }) + return o.Self() +} + +func (o *Assertions[Self, T]) HasLabelWithValue(label string, value string) *Self { + o.AddAssertion(func(t assertions.AssertT, o T) { + t.Helper() + assert.Equal(t, value, o.GetLabels()[label]) + }) + return o.Self() +} + +func (o *Assertions[Self, T]) HasName(name string) *Self { + o.AddAssertion(func(t assertions.AssertT, o T) { + t.Helper() + assert.Equal(t, name, o.GetName()) + }) + return o.Self() +} + +func (o *Assertions[Self, T]) IsInNamespace(namespace string) *Self { + o.AddAssertion(func(t assertions.AssertT, o T) { + t.Helper() + assert.Equal(t, namespace, o.GetNamespace()) + }) + return o.Self() +} diff --git a/testsupport/assertions/objectundertest.go b/testsupport/assertions/objectundertest.go new file mode 100644 index 000000000..e054d0213 --- /dev/null +++ b/testsupport/assertions/objectundertest.go @@ -0,0 +1,25 @@ +package assertions + +// Not used at the moment - just an experiment how to play with custom testingT instances +// and influence the output +func ObjectUnderTest(t AssertT, obj any) AssertT { + return &objectT{ + AssertT: t, + Object: obj, + } +} + +type objectT struct { + AssertT + Object any + objectReported bool +} + +func (t *objectT) Errorf(format string, args ...interface{}) { + t.Helper() + if !t.objectReported { + t.Errorf("Object failed one or more assertions\n%+v", t.Object) //nolint: testifylint + t.objectReported = true + } + t.Errorf(format, args...) +} diff --git a/testsupport/assertions/spaceprovisionerconfig/spc.go b/testsupport/assertions/spaceprovisionerconfig/spc.go new file mode 100644 index 000000000..1590a577c --- /dev/null +++ b/testsupport/assertions/spaceprovisionerconfig/spc.go @@ -0,0 +1,42 @@ +package spaceprovisionerconfig + +import ( + toolchainv1aplha1 "github.com/codeready-toolchain/api/api/v1alpha1" + "github.com/codeready-toolchain/toolchain-e2e/testsupport/assertions" + "github.com/codeready-toolchain/toolchain-e2e/testsupport/assertions/conditions" + "github.com/codeready-toolchain/toolchain-e2e/testsupport/assertions/object" + "github.com/stretchr/testify/assert" +) + +type ( + ObjectAssertions = object.Assertions[Assertions, *toolchainv1aplha1.SpaceProvisionerConfig] + ConditionAssertions = conditions.Assertions[Assertions, *toolchainv1aplha1.SpaceProvisionerConfig] + + Assertions struct { + ObjectAssertions + ConditionAssertions + assertions []assertions.Assertion[*toolchainv1aplha1.SpaceProvisionerConfig] + } +) + +func (a *Assertions) Assertions() []assertions.Assertion[*toolchainv1aplha1.SpaceProvisionerConfig] { + return a.assertions +} + +func That() *Assertions { + instance := &Assertions{assertions: []assertions.Assertion[*toolchainv1aplha1.SpaceProvisionerConfig]{}} + instance.EmbedInto(instance, &instance.assertions, func(spc *toolchainv1aplha1.SpaceProvisionerConfig) []toolchainv1aplha1.Condition { + return spc.Status.Conditions + }) + instance.ObjectAssertions.EmbedInto(instance, &instance.assertions) + return instance +} + +func (a *Assertions) ReferencesToolchainCluster(tc string) *Assertions { + a.assertions = append(a.assertions, func(t assertions.AssertT, spc *toolchainv1aplha1.SpaceProvisionerConfig) { + assert.Equal(t, tc, spc.Spec.ToolchainCluster) + }) + return a +} + +var _ assertions.WithAssertions[*toolchainv1aplha1.SpaceProvisionerConfig] = (*Assertions)(nil) diff --git a/testsupport/assertions/wait.go b/testsupport/assertions/wait.go new file mode 100644 index 000000000..ea23c05f8 --- /dev/null +++ b/testsupport/assertions/wait.go @@ -0,0 +1,194 @@ +package assertions + +import ( + "context" + "encoding/json" + "fmt" + "reflect" + "time" + + "github.com/codeready-toolchain/toolchain-e2e/testsupport/wait" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func WaitFor[T client.Object](cl client.Client) *Finder[T] { + return &Finder[T]{ + cl: cl, + timeout: wait.DefaultTimeout, + tick: wait.DefaultRetryInterval, + } +} + +type Finder[T client.Object] struct { + cl client.Client + timeout time.Duration + tick time.Duration +} + +type FinderInNamespace[T client.Object] struct { + Finder[T] + namespace string +} + +type FinderByObjectKey[T client.Object] struct { + Finder[T] + key client.ObjectKey +} + +type failureTrackingT struct { + *assert.CollectT + failed bool +} + +func (f *Finder[T]) WithTimeout(timeout time.Duration) *Finder[T] { + f.timeout = timeout + return f +} + +func (f *Finder[T]) WithRetryInterval(interval time.Duration) *Finder[T] { + f.tick = interval + return f +} + +func (f *Finder[T]) ByObjectKey(namespace, name string) *FinderByObjectKey[T] { + return &FinderByObjectKey[T]{ + Finder: *f, + key: client.ObjectKey{Name: name, Namespace: namespace}, + } +} + +func (f *Finder[T]) InNamespace(ns string) *FinderInNamespace[T] { + return &FinderInNamespace[T]{ + Finder: *f, + namespace: ns, + } +} + +func (f *FinderInNamespace[T]) WithName(name string) *FinderByObjectKey[T] { + return &FinderByObjectKey[T]{ + Finder: f.Finder, + key: client.ObjectKey{Name: name, Namespace: f.namespace}, + } +} + +func (t *failureTrackingT) Errorf(format string, args ...interface{}) { + t.failed = true + t.CollectT.Errorf(format, args...) +} + +func (f *failureTrackingT) Helper() { + // this is a wrapper of CollectT so helper should do nothing +} + +func (f *FinderInNamespace[T]) First(ctx context.Context, t RequireT, assertions WithAssertions[T]) T { + t.Helper() + + possibleGvks, _, err := f.cl.Scheme().ObjectKinds(newObject[T]()) + require.NoError(t, err) + require.Len(t, possibleGvks, 1) + + gvk := possibleGvks[0] + + var returnedObject T + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + list := &unstructured.UnstructuredList{} + list.SetGroupVersionKind(gvk) + require.NoError(c, f.cl.List(ctx, list, client.InNamespace(f.namespace))) + for _, uobj := range list.Items { + uobj := uobj + obj, err := cast[T](f.cl.Scheme(), &uobj) + require.NoError(c, err) + + f := &failureTrackingT{CollectT: c} + + Test(f, obj, assertions) + + if !f.failed { + returnedObject = obj + } + } + }, f.timeout, f.tick) // some more thorough message should be added here as a param to Eventually + + return returnedObject +} + +func (f *FinderByObjectKey[T]) Matching(ctx context.Context, t assert.TestingT, assertions WithAssertions[T]) T { + if t, ok := t.(interface{ Helper() }); ok { + t.Helper() + } + + var returnedObject T + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + obj := newObject[T]() + err := f.cl.Get(ctx, f.key, obj) + if err != nil { + assert.NoError(c, err, "failed to find the object by key %s", f.key) + return + } + + f := &failureTrackingT{CollectT: c} + + Test(f, obj, assertions) + + if !f.failed { + returnedObject = obj + } + }, f.timeout, f.tick) + + return returnedObject +} + +func (f *FinderByObjectKey[T]) Deleted(ctx context.Context, t assert.TestingT) { + if t, ok := t.(interface{ Helper() }); ok { + t.Helper() + } + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + obj := newObject[T]() + err := f.cl.Get(ctx, f.key, obj) + if err != nil && apierrors.IsNotFound(err) { + return + } + assert.Fail(c, "object with key %s still present or other error happened: %s", f.key, err) + }, f.timeout, f.tick) +} + +func cast[T client.Object](scheme *runtime.Scheme, obj *unstructured.Unstructured) (T, error) { + var empty T + raw, err := obj.MarshalJSON() + if err != nil { + return empty, fmt.Errorf("failed to obtain the raw JSON of the object: %w", err) + } + + typed, err := scheme.New(obj.GroupVersionKind()) + if err != nil { + return empty, fmt.Errorf("failed to create a new empty object from the scheme: %w", err) + } + + err = json.Unmarshal(raw, typed) + if err != nil { + return empty, fmt.Errorf("failed to unmarshal the raw JSON to the go structure: %w", err) + } + + return typed.(T), nil +} + +func newObject[T client.Object]() T { + // all client.Object implementations are pointers, so this declaration gives us just a nil pointer + var v T + + ptrT := reflect.TypeOf(v) + valT := ptrT.Elem() + ptrToZeroV := reflect.New(valT) + + zero := ptrToZeroV.Interface() + + return zero.(T) +} diff --git a/testsupport/assertions_use/assertions_test.go b/testsupport/assertions_use/assertions_test.go new file mode 100644 index 000000000..7873346b1 --- /dev/null +++ b/testsupport/assertions_use/assertions_test.go @@ -0,0 +1,38 @@ +package assertions_use + +import ( + "context" + "testing" + "time" + + toolchainv1aplha1 "github.com/codeready-toolchain/api/api/v1alpha1" + "github.com/codeready-toolchain/toolchain-e2e/testsupport/assertions" + "github.com/codeready-toolchain/toolchain-e2e/testsupport/assertions/spaceprovisionerconfig" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func Test(t *testing.T) { + spcUnderTest := &toolchainv1aplha1.SpaceProvisionerConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "kachny", + Namespace: "default", + }, + } + + scheme := runtime.NewScheme() + require.NoError(t, toolchainv1aplha1.AddToScheme(scheme)) + cl := fake.NewClientBuilder().WithScheme(scheme).WithRuntimeObjects(spcUnderTest).Build() + + // use the assertions in a simple immediate call + assertions.Test(t, spcUnderTest, spaceprovisionerconfig.That().HasLabel("asdf").HasConditionWithType(toolchainv1aplha1.ConditionReady)) + + // this is the new WaitFor + assertions.WaitFor[*toolchainv1aplha1.SpaceProvisionerConfig](cl). + WithTimeout(1*time.Second). // defaults to wait.DefaultTimeout which is 2 minutes, so let's make it shorter here + ByObjectKey("default", "kachny"). + Matching(context.TODO(), t, + spaceprovisionerconfig.That().HasLabel("asdf")) +} From 4dfd0ce2f8b7b92ce90c3637e5efef47f3ec840e Mon Sep 17 00:00:00 2001 From: Lukas Krejci Date: Fri, 7 Feb 2025 10:28:45 +0100 Subject: [PATCH 4/7] Experiment to add diffing --- testsupport/assertions/assertion.go | 49 ++++-- .../assertions/conditions/conditions.go | 27 +++- testsupport/assertions/deepcopy.go | 13 ++ testsupport/assertions/fix.go | 60 ++++++++ testsupport/assertions/object/object.go | 59 ++++++-- .../assertions/spaceprovisionerconfig/spc.go | 14 +- testsupport/assertions/t.go | 28 ++++ testsupport/assertions/wait.go | 141 +++++++++++++----- testsupport/assertions_use/assertions_test.go | 2 +- 9 files changed, 317 insertions(+), 76 deletions(-) create mode 100644 testsupport/assertions/deepcopy.go create mode 100644 testsupport/assertions/fix.go create mode 100644 testsupport/assertions/t.go diff --git a/testsupport/assertions/assertion.go b/testsupport/assertions/assertion.go index e8f5615cd..ab4fbbf81 100644 --- a/testsupport/assertions/assertion.go +++ b/testsupport/assertions/assertion.go @@ -1,11 +1,12 @@ package assertions -import ( - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) +var _ Assertion[bool] = (AssertionFunc[bool])(nil) -type Assertion[T any] func(t AssertT, obj T) +type Assertion[T any] interface { + Test(t AssertT, obj T) +} + +type AssertionFunc[T any] func(t AssertT, obj T) type EmbeddableAssertions[Self any, T any] struct { assertions *[]Assertion[T] @@ -16,23 +17,36 @@ type WithAssertions[T any] interface { Assertions() []Assertion[T] } -type AssertT interface { - assert.TestingT - Helper() -} - -type RequireT interface { - require.TestingT - Helper() +func Test[T any, A WithAssertions[T]](t AssertT, obj T, assertions A) { + t.Helper() + testInner(t, obj, assertions, false) } -func Test[T any, A WithAssertions[T]](t AssertT, obj T, assertions A) { +func testInner[T any, A WithAssertions[T]](t AssertT, obj T, assertions A, suppressLogAround bool) { t.Helper() + ft := &failureTrackingT{AssertT: t} + + if !suppressLogAround { + t.Logf("About to test object %T with assertions", obj) + } + for _, a := range assertions.Assertions() { - a(t, obj) + a.Test(ft, obj) + } + + if !suppressLogAround && ft.failed { + format, args := doExplainAfterTestFailure(obj, assertions) + t.Logf(format, args...) } } +func doExplainAfterTestFailure[T any, A WithAssertions[T]](obj T, assertions A) (format string, args []any) { + diff := Explain(obj, assertions) + format = "Some of the assertions failed to match the object (see output above). The following diff shows what the object should have looked like:\n%s" + args = []any{diff} + return +} + func (a *EmbeddableAssertions[Self, T]) Self() *Self { return a.self } @@ -45,3 +59,8 @@ func (a *EmbeddableAssertions[Self, T]) EmbedInto(self *Self, assertions *[]Asse func (ea *EmbeddableAssertions[Self, T]) AddAssertion(a Assertion[T]) { *ea.assertions = append(*ea.assertions, a) } + +func (f AssertionFunc[T]) Test(t AssertT, obj T) { + t.Helper() + f(t, obj) +} diff --git a/testsupport/assertions/conditions/conditions.go b/testsupport/assertions/conditions/conditions.go index 59ad9b783..a19646310 100644 --- a/testsupport/assertions/conditions/conditions.go +++ b/testsupport/assertions/conditions/conditions.go @@ -10,20 +10,33 @@ import ( type Assertions[Self any, T any] struct { assertions.EmbeddableAssertions[Self, T] - accessor func(T) []toolchainv1aplha1.Condition + accessor func(T) *[]toolchainv1aplha1.Condition } -func (a *Assertions[Self, T]) EmbedInto(self *Self, assertions *[]assertions.Assertion[T], accessor func(T) []toolchainv1aplha1.Condition) { +func (a *Assertions[Self, T]) EmbedInto(self *Self, assertions *[]assertions.Assertion[T], accessor func(T) *[]toolchainv1aplha1.Condition) { a.EmbeddableAssertions.EmbedInto(self, assertions) a.accessor = accessor } func (a *Assertions[Self, T]) HasConditionWithType(typ toolchainv1aplha1.ConditionType) *Self { - a.AddAssertion(func(t assertions.AssertT, obj T) { - t.Helper() - conds := a.accessor(obj) - _, found := condition.FindConditionByType(conds, typ) - assert.True(t, found, "condition with the type %s not found", typ) + a.AddAssertion(&assertions.AssertAndFixFunc[T]{ + Assert: func(t assertions.AssertT, obj T) { + t.Helper() + conds := a.accessor(obj) + _, found := condition.FindConditionByType(*conds, typ) + assert.True(t, found, "condition with the type %s not found", typ) + }, + Fix: func(obj T) T { + conds := a.accessor(obj) + if *conds == nil { + *conds = []toolchainv1aplha1.Condition{} + } + *conds, _ = condition.AddOrUpdateStatusConditions(*conds, toolchainv1aplha1.Condition{ + Type: toolchainv1aplha1.ConditionReady, + }) + + return obj + }, }) return a.Self() } diff --git a/testsupport/assertions/deepcopy.go b/testsupport/assertions/deepcopy.go new file mode 100644 index 000000000..75071c823 --- /dev/null +++ b/testsupport/assertions/deepcopy.go @@ -0,0 +1,13 @@ +package assertions + +type deepCopy[T any] interface { + DeepCopy() T +} + +func copyObject[T any](obj any) T { + if dc, ok := obj.(deepCopy[T]); ok { + return dc.DeepCopy() + } + // TODO: should we go into attempting cloning slices and maps? + return obj.(T) +} diff --git a/testsupport/assertions/fix.go b/testsupport/assertions/fix.go new file mode 100644 index 000000000..9071de96f --- /dev/null +++ b/testsupport/assertions/fix.go @@ -0,0 +1,60 @@ +package assertions + +import ( + "fmt" + "strings" + + "github.com/google/go-cmp/cmp" +) + +var ( + _ Assertion[bool] = (*AssertAndFixFunc[bool])(nil) + _ AssertionFixer[bool] = (*AssertAndFixFunc[bool])(nil) +) + +func Explain[T any, A WithAssertions[T]](obj T, assertions A) string { + cpy := copyObject[T](obj) + + nonFixingAssertions := []string{} + nonFixingAssertionsIndices := []int{} + for i, a := range assertions.Assertions() { + if f, ok := a.(AssertionFixer[T]); ok { + f.AdaptToMatch(cpy) + } else { + nonFixingAssertions = append(nonFixingAssertions, fmt.Sprintf("%T", a)) + nonFixingAssertionsIndices = append(nonFixingAssertionsIndices, i) + } + } + + sb := strings.Builder{} + sb.WriteString(cmp.Diff(obj, cpy)) + for i := range nonFixingAssertions { + sb.WriteRune('\n') + sb.WriteString(fmt.Sprintf("the %dth assertion was not able to modify the object to match it", nonFixingAssertionsIndices[i])) + } + + return sb.String() +} + +type AssertionFixer[T any] interface { + AdaptToMatch(object T) T +} + +type AssertAndFixFunc[T any] struct { + Assert func(t AssertT, obj T) + Fix func(obj T) T +} + +func (a *AssertAndFixFunc[T]) Test(t AssertT, obj T) { + t.Helper() + if a.Assert != nil { + a.Assert(t, obj) + } +} + +func (a *AssertAndFixFunc[T]) AdaptToMatch(object T) T { + if a.Fix != nil { + return a.Fix(object) + } + return object +} diff --git a/testsupport/assertions/object/object.go b/testsupport/assertions/object/object.go index 007840731..a7f5c7568 100644 --- a/testsupport/assertions/object/object.go +++ b/testsupport/assertions/object/object.go @@ -11,33 +11,68 @@ type Assertions[Self any, T client.Object] struct { } func (o *Assertions[Self, T]) HasLabel(label string) *Self { - o.AddAssertion(func(t assertions.AssertT, o T) { - t.Helper() - assert.Contains(t, o.GetLabels(), label, "label '%s' not found", label) + o.AddAssertion(&assertions.AssertAndFixFunc[T]{ + Assert: func(t assertions.AssertT, o T) { + t.Helper() + t.Logf("ad-hoc log message from within the HasLabel assertion :)") + assert.Contains(t, o.GetLabels(), label, "label '%s' not found", label) + }, + Fix: func(o T) T { + labels := o.GetLabels() + if labels == nil { + labels = map[string]string{} + o.SetLabels(labels) + } + labels[label] = "" + return o + }, }) return o.Self() } func (o *Assertions[Self, T]) HasLabelWithValue(label string, value string) *Self { - o.AddAssertion(func(t assertions.AssertT, o T) { - t.Helper() - assert.Equal(t, value, o.GetLabels()[label]) + o.AddAssertion(&assertions.AssertAndFixFunc[T]{ + Assert: func(t assertions.AssertT, o T) { + t.Helper() + assert.Equal(t, value, o.GetLabels()[label]) + }, + Fix: func(o T) T { + labels := o.GetLabels() + if labels == nil { + labels = map[string]string{} + o.SetLabels(labels) + } + labels[label] = value + return o + }, }) return o.Self() } func (o *Assertions[Self, T]) HasName(name string) *Self { - o.AddAssertion(func(t assertions.AssertT, o T) { - t.Helper() - assert.Equal(t, name, o.GetName()) + o.AddAssertion(&assertions.AssertAndFixFunc[T]{ + Assert: func(t assertions.AssertT, o T) { + t.Helper() + assert.Equal(t, name, o.GetName()) + }, + Fix: func(o T) T { + o.SetName(name) + return o + }, }) return o.Self() } func (o *Assertions[Self, T]) IsInNamespace(namespace string) *Self { - o.AddAssertion(func(t assertions.AssertT, o T) { - t.Helper() - assert.Equal(t, namespace, o.GetNamespace()) + o.AddAssertion(&assertions.AssertAndFixFunc[T]{ + Assert: func(t assertions.AssertT, o T) { + t.Helper() + assert.Equal(t, namespace, o.GetNamespace()) + }, + Fix: func(o T) T { + o.SetNamespace(namespace) + return o + }, }) return o.Self() } diff --git a/testsupport/assertions/spaceprovisionerconfig/spc.go b/testsupport/assertions/spaceprovisionerconfig/spc.go index 1590a577c..51cf1cb84 100644 --- a/testsupport/assertions/spaceprovisionerconfig/spc.go +++ b/testsupport/assertions/spaceprovisionerconfig/spc.go @@ -25,16 +25,22 @@ func (a *Assertions) Assertions() []assertions.Assertion[*toolchainv1aplha1.Spac func That() *Assertions { instance := &Assertions{assertions: []assertions.Assertion[*toolchainv1aplha1.SpaceProvisionerConfig]{}} - instance.EmbedInto(instance, &instance.assertions, func(spc *toolchainv1aplha1.SpaceProvisionerConfig) []toolchainv1aplha1.Condition { - return spc.Status.Conditions + instance.EmbedInto(instance, &instance.assertions, func(spc *toolchainv1aplha1.SpaceProvisionerConfig) *[]toolchainv1aplha1.Condition { + return &spc.Status.Conditions }) instance.ObjectAssertions.EmbedInto(instance, &instance.assertions) return instance } func (a *Assertions) ReferencesToolchainCluster(tc string) *Assertions { - a.assertions = append(a.assertions, func(t assertions.AssertT, spc *toolchainv1aplha1.SpaceProvisionerConfig) { - assert.Equal(t, tc, spc.Spec.ToolchainCluster) + a.assertions = append(a.assertions, &assertions.AssertAndFixFunc[*toolchainv1aplha1.SpaceProvisionerConfig]{ + Assert: func(t assertions.AssertT, spc *toolchainv1aplha1.SpaceProvisionerConfig) { + assert.Equal(t, tc, spc.Spec.ToolchainCluster) + }, + Fix: func(obj *toolchainv1aplha1.SpaceProvisionerConfig) *toolchainv1aplha1.SpaceProvisionerConfig { + obj.Spec.ToolchainCluster = tc + return obj + }, }) return a } diff --git a/testsupport/assertions/t.go b/testsupport/assertions/t.go new file mode 100644 index 000000000..b6edbd10c --- /dev/null +++ b/testsupport/assertions/t.go @@ -0,0 +1,28 @@ +package assertions + +import ( + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type AssertT interface { + assert.TestingT + Helper() + Logf(format string, args ...any) +} + +type RequireT interface { + require.TestingT + Helper() + Logf(format string, args ...any) +} + +type failureTrackingT struct { + AssertT + failed bool +} + +func (t *failureTrackingT) Errorf(format string, args ...interface{}) { + t.failed = true + t.AssertT.Errorf(format, args...) +} diff --git a/testsupport/assertions/wait.go b/testsupport/assertions/wait.go index ea23c05f8..365226370 100644 --- a/testsupport/assertions/wait.go +++ b/testsupport/assertions/wait.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "reflect" + "strings" "time" "github.com/codeready-toolchain/toolchain-e2e/testsupport/wait" @@ -13,6 +14,7 @@ import ( apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" + kwait "k8s.io/apimachinery/pkg/util/wait" "sigs.k8s.io/controller-runtime/pkg/client" ) @@ -40,8 +42,13 @@ type FinderByObjectKey[T client.Object] struct { key client.ObjectKey } -type failureTrackingT struct { - *assert.CollectT +type logger interface { + Logf(format string, args ...any) +} + +type errorCollectingT struct { + errors []error + logger failed bool } @@ -55,7 +62,7 @@ func (f *Finder[T]) WithRetryInterval(interval time.Duration) *Finder[T] { return f } -func (f *Finder[T]) ByObjectKey(namespace, name string) *FinderByObjectKey[T] { +func (f *Finder[T]) WithObjectKey(namespace, name string) *FinderByObjectKey[T] { return &FinderByObjectKey[T]{ Finder: *f, key: client.ObjectKey{Name: name, Namespace: namespace}, @@ -76,18 +83,24 @@ func (f *FinderInNamespace[T]) WithName(name string) *FinderByObjectKey[T] { } } -func (t *failureTrackingT) Errorf(format string, args ...interface{}) { +func (t *errorCollectingT) Errorf(format string, args ...interface{}) { t.failed = true - t.CollectT.Errorf(format, args...) + t.errors = append(t.errors, fmt.Errorf(format, args...)) } -func (f *failureTrackingT) Helper() { - // this is a wrapper of CollectT so helper should do nothing +func (f *errorCollectingT) Helper() { + // we cannot call any inner Helper() because that wouldn't work anyway +} + +func (f *errorCollectingT) FailNow() { + panic("assertion failed") } func (f *FinderInNamespace[T]) First(ctx context.Context, t RequireT, assertions WithAssertions[T]) T { t.Helper() + t.Logf("waiting for the first object of type %T in namespace '%s' to match criteria", newObject[T](), f.namespace) + possibleGvks, _, err := f.cl.Scheme().ObjectKinds(newObject[T]()) require.NoError(t, err) require.Len(t, possibleGvks, 1) @@ -96,68 +109,122 @@ func (f *FinderInNamespace[T]) First(ctx context.Context, t RequireT, assertions var returnedObject T - assert.EventuallyWithT(t, func(c *assert.CollectT) { + ft := &errorCollectingT{logger: t} + + err = kwait.PollUntilContextTimeout(ctx, f.tick, f.timeout, true, func(ctx context.Context) (done bool, err error) { list := &unstructured.UnstructuredList{} list.SetGroupVersionKind(gvk) - require.NoError(c, f.cl.List(ctx, list, client.InNamespace(f.namespace))) + ft.errors = nil + if err := f.cl.List(ctx, list, client.InNamespace(f.namespace)); err != nil { + return false, err + } for _, uobj := range list.Items { uobj := uobj obj, err := cast[T](f.cl.Scheme(), &uobj) - require.NoError(c, err) - - f := &failureTrackingT{CollectT: c} + if err != nil { + return false, fmt.Errorf("failed to cast object with GVK %v to object %T: %w", gvk, newObject[T](), err) + } - Test(f, obj, assertions) + testInner(ft, obj, assertions, true) - if !f.failed { + if !ft.failed { returnedObject = obj } } - }, f.timeout, f.tick) // some more thorough message should be added here as a param to Eventually + return !ft.failed, nil + }) + if err != nil { + sb := strings.Builder{} + sb.WriteString("failed to find objects (of GVK '%s') in namespace '%s' matching the criteria: %s") + args := []any{gvk, f.namespace, err.Error()} + list := &unstructured.UnstructuredList{} + list.SetGroupVersionKind(gvk) + if err := f.cl.List(context.TODO(), list, client.InNamespace(f.namespace)); err != nil { + sb.WriteString(" and also failed to retrieve the object at all with error: %s") + args = append(args, err) + } else { + sb.WriteString("\nlisting the objects found in cluster with the differences from the expected state for each:") + for _, o := range list.Items { + o := o + obj, _ := cast[T](f.cl.Scheme(), &o) + key := client.ObjectKeyFromObject(obj) + + sb.WriteRune('\n') + sb.WriteString("object ") + sb.WriteString(key.String()) + sb.WriteString(":\n") + format, oargs := doExplainAfterTestFailure(obj, assertions) + sb.WriteString(format) + args = append(args, oargs...) + } + } + t.Logf(sb.String(), args...) + } return returnedObject } -func (f *FinderByObjectKey[T]) Matching(ctx context.Context, t assert.TestingT, assertions WithAssertions[T]) T { - if t, ok := t.(interface{ Helper() }); ok { - t.Helper() - } +func (f *FinderByObjectKey[T]) Matching(ctx context.Context, t AssertT, assertions WithAssertions[T]) T { + t.Helper() + + t.Logf("waiting for %T with name '%s' in namespace '%s' to match additional criteria", newObject[T](), f.key.Name, f.key.Namespace) var returnedObject T - assert.EventuallyWithT(t, func(c *assert.CollectT) { + ft := &errorCollectingT{logger: t} + + err := kwait.PollUntilContextTimeout(ctx, f.tick, f.timeout, true, func(ctx context.Context) (done bool, err error) { + t.Helper() + ft.errors = nil obj := newObject[T]() - err := f.cl.Get(ctx, f.key, obj) + err = f.cl.Get(ctx, f.key, obj) if err != nil { - assert.NoError(c, err, "failed to find the object by key %s", f.key) - return + assert.NoError(ft, err, "failed to find the object by key %s", f.key) + return false, err } - f := &failureTrackingT{CollectT: c} + testInner(ft, obj, assertions, true) - Test(f, obj, assertions) - - if !f.failed { + if !ft.failed { returnedObject = obj } - }, f.timeout, f.tick) + + return !ft.failed, nil + }) + if err != nil { + if ft.failed { + for _, e := range ft.errors { + t.Errorf("%s", e) //nolint: testifylint + } + obj := newObject[T]() + err := f.cl.Get(ctx, f.key, obj) + if err != nil { + t.Errorf("failed to find the object while reporting the failure to match by criteria using object key %s", f.key) + return returnedObject + } + format, args := doExplainAfterTestFailure(obj, assertions) + t.Logf(format, args...) + } + t.Logf("couldn't match %T with name '%s' in namespace '%s' with additional criteria because of: %s", newObject[T](), f.key.Name, f.key.Namespace, err) + } return returnedObject } -func (f *FinderByObjectKey[T]) Deleted(ctx context.Context, t assert.TestingT) { - if t, ok := t.(interface{ Helper() }); ok { - t.Helper() - } +func (f *FinderByObjectKey[T]) Deleted(ctx context.Context, t AssertT) { + t.Helper() - assert.EventuallyWithT(t, func(c *assert.CollectT) { + err := kwait.PollUntilContextTimeout(ctx, f.tick, f.timeout, true, func(ctx context.Context) (done bool, err error) { obj := newObject[T]() - err := f.cl.Get(ctx, f.key, obj) + err = f.cl.Get(ctx, f.key, obj) if err != nil && apierrors.IsNotFound(err) { - return + return true, nil } - assert.Fail(c, "object with key %s still present or other error happened: %s", f.key, err) - }, f.timeout, f.tick) + return false, err + }) + if err != nil { + assert.Fail(t, "object with key %s still present or other error happened: %s", f.key, err) + } } func cast[T client.Object](scheme *runtime.Scheme, obj *unstructured.Unstructured) (T, error) { diff --git a/testsupport/assertions_use/assertions_test.go b/testsupport/assertions_use/assertions_test.go index 7873346b1..0c3e24aef 100644 --- a/testsupport/assertions_use/assertions_test.go +++ b/testsupport/assertions_use/assertions_test.go @@ -32,7 +32,7 @@ func Test(t *testing.T) { // this is the new WaitFor assertions.WaitFor[*toolchainv1aplha1.SpaceProvisionerConfig](cl). WithTimeout(1*time.Second). // defaults to wait.DefaultTimeout which is 2 minutes, so let's make it shorter here - ByObjectKey("default", "kachny"). + WithObjectKey("default", "kachny"). Matching(context.TODO(), t, spaceprovisionerconfig.That().HasLabel("asdf")) } From 097f9810716a68737d4a295ff311942c81c9a1f5 Mon Sep 17 00:00:00 2001 From: Lukas Krejci Date: Mon, 5 May 2025 16:10:25 +0200 Subject: [PATCH 5/7] Simplify the whole assertion setup. Provide utility functions for assertion conversion instead of the elaborate setup to compose the assertions together. --- go.mod | 5 +- testsupport/assertions/assertion.go | 57 ---- testsupport/assertions/assertions.go | 54 ++++ .../assertions/conditions/conditions.go | 24 +- testsupport/assertions/deepcopy.go | 13 - .../assertions/embeddableassertions.go | 56 ---- testsupport/assertions/fix.go | 56 ---- testsupport/assertions/lift.go | 76 +++++ testsupport/assertions/metadata/metadata.go | 89 ++++-- .../wait.go => assertions/object.go} | 168 ++++++----- testsupport/assertions/object/object.go | 26 ++ testsupport/assertions/objectundertest.go | 25 -- .../assertions/spaceprovisionerconfig/spc.go | 38 +-- testsupport/assertions/wait.go | 261 ------------------ testsupport/assertions2/assertion.go | 102 ------- .../assertions2/conditions/conditions.go | 29 -- testsupport/assertions2/deepcopy.go | 13 - .../assertions2/embeddableassertions.go | 60 ---- testsupport/assertions2/fix.go | 56 ---- testsupport/assertions2/metadata/metadata.go | 102 ------- .../assertions2/spaceprovisionerconfig/spc.go | 36 --- testsupport/assertions2/t.go | 29 -- .../assertions2_use/assertions_test.go | 43 --- testsupport/assertions_use/assertions_test.go | 23 +- 24 files changed, 354 insertions(+), 1087 deletions(-) delete mode 100644 testsupport/assertions/assertion.go create mode 100644 testsupport/assertions/assertions.go delete mode 100644 testsupport/assertions/deepcopy.go delete mode 100644 testsupport/assertions/embeddableassertions.go delete mode 100644 testsupport/assertions/fix.go create mode 100644 testsupport/assertions/lift.go rename testsupport/{assertions2/wait.go => assertions/object.go} (59%) create mode 100644 testsupport/assertions/object/object.go delete mode 100644 testsupport/assertions/objectundertest.go delete mode 100644 testsupport/assertions/wait.go delete mode 100644 testsupport/assertions2/assertion.go delete mode 100644 testsupport/assertions2/conditions/conditions.go delete mode 100644 testsupport/assertions2/deepcopy.go delete mode 100644 testsupport/assertions2/embeddableassertions.go delete mode 100644 testsupport/assertions2/fix.go delete mode 100644 testsupport/assertions2/metadata/metadata.go delete mode 100644 testsupport/assertions2/spaceprovisionerconfig/spc.go delete mode 100644 testsupport/assertions2/t.go delete mode 100644 testsupport/assertions2_use/assertions_test.go diff --git a/go.mod b/go.mod index 33307d5d9..2f57be082 100644 --- a/go.mod +++ b/go.mod @@ -31,7 +31,10 @@ require ( sigs.k8s.io/controller-runtime v0.18.4 ) -require github.com/google/uuid v1.6.0 +require ( + github.com/google/uuid v1.6.0 + gotest.tools v2.2.0+incompatible +) require ( github.com/BurntSushi/toml v1.3.2 // indirect diff --git a/testsupport/assertions/assertion.go b/testsupport/assertions/assertion.go deleted file mode 100644 index ee877bb47..000000000 --- a/testsupport/assertions/assertion.go +++ /dev/null @@ -1,57 +0,0 @@ -package assertions - -var _ Assertion[bool] = (AssertionFunc[bool])(nil) - -// Assertion is a functional interface that is used to test whether an object satisfies some condition. -type Assertion[T any] interface { - Test(t AssertT, obj T) -} - -// AssertionFunc converts a function into an assertion. -type AssertionFunc[T any] func(t AssertT, obj T) - -// WithAssertions is an interface for "things" that make available a list of assertions to use in the Test function. -type WithAssertions[T any] interface { - Assertions() []Assertion[T] -} - -func Test[T any, A WithAssertions[T]](t AssertT, obj T, assertions A) { - t.Helper() - testInner(t, obj, assertions, false) -} - -func testInner[T any, A WithAssertions[T]](t AssertT, obj T, assertions A, suppressLogAround bool) { - t.Helper() - ft := &failureTrackingT{AssertT: t} - - if !suppressLogAround { - t.Logf("About to test object %T with assertions", obj) - } - - for _, a := range assertions.Assertions() { - a.Test(ft, obj) - } - - if !suppressLogAround && ft.failed { - format, args := doExplainAfterTestFailure(obj, assertions) - t.Logf(format, args...) - } -} - -func doExplainAfterTestFailure[T any, A WithAssertions[T]](obj T, assertions A) (format string, args []any) { - diff := Explain(obj, assertions) - if diff != "" { - format = "Some of the assertions failed to match the object (see output above). The following diff shows what the object should have looked like:\n%s" - args = []any{diff} - } else { - format = "Some of the assertions failed to match the object (see output above)." - args = []any{} - } - - return -} - -func (f AssertionFunc[T]) Test(t AssertT, obj T) { - t.Helper() - f(t, obj) -} diff --git a/testsupport/assertions/assertions.go b/testsupport/assertions/assertions.go new file mode 100644 index 000000000..a3a490296 --- /dev/null +++ b/testsupport/assertions/assertions.go @@ -0,0 +1,54 @@ +package assertions + +// Assertion is a test function that is meant to test some object. +type Assertion[T any] interface { + Test(t AssertT, obj T) +} + +// Assertions is just a list of assertions provided for convenience. It is meant to be embedded into structs. +type Assertions[T any] []Assertion[T] + +// AssertionFunc converts a function into an assertion. +type AssertionFunc[T any] func(t AssertT, obj T) + +// Append is just an alias for Go's built-in append. +func Append[Type any](assertionList Assertions[Type], assertions ...Assertion[Type]) Assertions[Type] { + assertionList = append(assertionList, assertions...) + return assertionList +} + +// AppendGeneric is a variant of append that can also cast assertions on some super-type into assertions +// on some type. This can be useful when one has some assertions that work on an super-type of some type and +// you want to append it to a list of assertions on the type itself. +func AppendGeneric[SuperType any, Type any](assertionList Assertions[Type], assertions ...Assertion[SuperType]) Assertions[Type] { + for _, a := range assertions { + assertionList = append(assertionList, CastAssertion[SuperType, Type](a)) + } + return assertionList +} + +// AppendLifted is a convenience function to first lift all the assertions to the "To" type and then append them to the provided list. +func AppendLifted[From any, To any](conversion func(To) (From, bool), assertionList Assertions[To], assertions ...Assertion[From]) Assertions[To] { + return Append(assertionList, LiftAll(conversion, assertions...)...) +} + +// AppendFunc is a convenience function that is able to take in the assertions as simple functions. +func AppendFunc[T any](assertionList Assertions[T], fn ...AssertionFunc[T]) Assertions[T] { + for _, f := range fn { + assertionList = append(assertionList, f) + } + return assertionList +} + +// Test runs the test by all assertions in the list. +func (as Assertions[T]) Test(t AssertT, obj T) { + t.Helper() + for _, a := range as { + a.Test(t, obj) + } +} + +func (f AssertionFunc[T]) Test(t AssertT, obj T) { + t.Helper() + f(t, obj) +} diff --git a/testsupport/assertions/conditions/conditions.go b/testsupport/assertions/conditions/conditions.go index 8d4ad4812..974ca92b6 100644 --- a/testsupport/assertions/conditions/conditions.go +++ b/testsupport/assertions/conditions/conditions.go @@ -1,29 +1,25 @@ package conditions import ( - toolchainv1aplha1 "github.com/codeready-toolchain/api/api/v1alpha1" + toolchainv1alpha1 "github.com/codeready-toolchain/api/api/v1alpha1" "github.com/codeready-toolchain/toolchain-common/pkg/condition" "github.com/codeready-toolchain/toolchain-e2e/testsupport/assertions" "github.com/stretchr/testify/assert" ) -type Assertions[Self any, T any] struct { - assertions.EmbeddableAssertions[Self, T] - - accessor func(T) *[]toolchainv1aplha1.Condition +type ConditionAssertions struct { + assertions.Assertions[[]toolchainv1alpha1.Condition] } -func (a *Assertions[Self, T]) WireUp(self *Self, assertions *[]assertions.Assertion[T], accessor func(T) *[]toolchainv1aplha1.Condition) { - a.EmbeddableAssertions.WireUp(self, assertions) - a.accessor = accessor +func With() *ConditionAssertions { + return &ConditionAssertions{} } -func (a *Assertions[Self, T]) HasConditionWithType(typ toolchainv1aplha1.ConditionType) *Self { - a.AddAssertionFunc(func(t assertions.AssertT, obj T) { +func (cas *ConditionAssertions) Type(typ toolchainv1alpha1.ConditionType) *ConditionAssertions { + cas.Assertions = assertions.AppendFunc(cas.Assertions, func(t assertions.AssertT, conds []toolchainv1alpha1.Condition) { t.Helper() - conds := a.accessor(obj) - _, found := condition.FindConditionByType(*conds, typ) - assert.True(t, found, "condition with the type %s not found", typ) + _, found := condition.FindConditionByType(conds, typ) + assert.True(t, found, "didn't find a condition with the type '%v'", typ) }) - return a.Self() + return cas } diff --git a/testsupport/assertions/deepcopy.go b/testsupport/assertions/deepcopy.go deleted file mode 100644 index 75071c823..000000000 --- a/testsupport/assertions/deepcopy.go +++ /dev/null @@ -1,13 +0,0 @@ -package assertions - -type deepCopy[T any] interface { - DeepCopy() T -} - -func copyObject[T any](obj any) T { - if dc, ok := obj.(deepCopy[T]); ok { - return dc.DeepCopy() - } - // TODO: should we go into attempting cloning slices and maps? - return obj.(T) -} diff --git a/testsupport/assertions/embeddableassertions.go b/testsupport/assertions/embeddableassertions.go deleted file mode 100644 index afd405c25..000000000 --- a/testsupport/assertions/embeddableassertions.go +++ /dev/null @@ -1,56 +0,0 @@ -package assertions - -import "sigs.k8s.io/controller-runtime/pkg/client" - -var _ WithAssertions[int] = (*EmbeddableAssertions[int, int])(nil) - -// EmbeddableAssertions is meant to be embedded into other structs as a means for storing the assertions. -// Initialize it using the EmbedInto method. -type EmbeddableAssertions[Self any, T any] struct { - assertions *[]Assertion[T] - self *Self -} - -// Self can be used in structs that embed embeddable assertions and are themselves meant to be embedded to return the correct -// type of the struct that embeds them from their fluent methods. -func (a *EmbeddableAssertions[Self, T]) Self() *Self { - return a.self -} - -// WireUp initializes the embeddable assertions struct using a pointer to the assertions array that should be used -// as the storage for the assertions and also a pointer to "self". This is meant to enable returning a correctly typed object -// from the Self method such that all structs that are embedded into some "end user" struct can define fluent methods -// returning the correct type of the "end user". -func (a *EmbeddableAssertions[Self, T]) WireUp(self *Self, assertions *[]Assertion[T]) { - a.self = self - a.assertions = assertions -} - -// AddAssertion adds the provided assertion to the list of assertions. -func (ea *EmbeddableAssertions[Self, T]) AddAssertion(a Assertion[T]) { - *ea.assertions = append(*ea.assertions, a) -} - -// AddAssertionFunc is a convenience function for the common case of implementing the assertions -// using a simple function. -func (ea *EmbeddableAssertions[Self, T]) AddAssertionFunc(assertion func(AssertT, T)) { - ea.AddAssertion(AssertionFunc[T](assertion)) -} - -func (ea *EmbeddableAssertions[Self, T]) Assertions() []Assertion[T] { - return *ea.assertions -} - -// Test is a fluent variant to test the assertions on an object. -func (ea *EmbeddableAssertions[Self, T]) Test(t AssertT, obj T) { - t.Helper() - Test(t, obj, ea) -} - -type EmbeddableObjectAssertions[Self any, T client.Object] struct { - EmbeddableAssertions[Self, T] -} - -func (ea *EmbeddableObjectAssertions[Self, T]) WaitFor(cl client.Client) *Finder[T] { - return WaitFor[T](cl) -} diff --git a/testsupport/assertions/fix.go b/testsupport/assertions/fix.go deleted file mode 100644 index 9300ed97b..000000000 --- a/testsupport/assertions/fix.go +++ /dev/null @@ -1,56 +0,0 @@ -package assertions - -import ( - "strings" - - "github.com/google/go-cmp/cmp" -) - -var ( - _ Assertion[bool] = (*AssertAndFixFunc[bool])(nil) - _ AssertionFixer[bool] = (*AssertAndFixFunc[bool])(nil) -) - -func Explain[T any, A WithAssertions[T]](obj T, assertions A) string { - cpy := copyObject[T](obj) - - modified := false - for _, a := range assertions.Assertions() { - if f, ok := a.(AssertionFixer[T]); ok { - f.AdaptToMatch(cpy) - modified = true - } - } - - if modified { - sb := strings.Builder{} - sb.WriteString(cmp.Diff(obj, cpy)) - - return sb.String() - } - - return "" -} - -type AssertionFixer[T any] interface { - AdaptToMatch(object T) T -} - -type AssertAndFixFunc[T any] struct { - Assert func(t AssertT, obj T) - Fix func(obj T) T -} - -func (a *AssertAndFixFunc[T]) Test(t AssertT, obj T) { - t.Helper() - if a.Assert != nil { - a.Assert(t, obj) - } -} - -func (a *AssertAndFixFunc[T]) AdaptToMatch(object T) T { - if a.Fix != nil { - return a.Fix(object) - } - return object -} diff --git a/testsupport/assertions/lift.go b/testsupport/assertions/lift.go new file mode 100644 index 000000000..9839d0607 --- /dev/null +++ b/testsupport/assertions/lift.go @@ -0,0 +1,76 @@ +package assertions + +// CastAssertion can be used to convert a generic assertion on, say, client.Object, into +// an assertion on a concrete subtype. Note that the conversion is not guaranteed to +// pass by the type system and can fail at runtime. +func CastAssertion[SuperType any, Type any](a Assertion[SuperType]) Assertion[Type] { + // we cannot pass "cast[SuperType]" as a function pointer, so we need this aid + conversion := func(o Type) (SuperType, bool) { + return cast[SuperType](o) + } + + return Lift(conversion, a) +} + +// Lift converts from one assertion type to another by converting the tested value. +// It respectes the ObjectNameAssertion and ObjectNamespaceAssertion so that assertions +// can still be used to identify the object after lifting. +// The provided accessor can be fallible, returning false on the failure to convert the object. +func Lift[From any, To any](accessor func(To) (From, bool), assertion Assertion[From]) Assertion[To] { + if _, ok := assertion.(ObjectNameAssertion); ok { + return &liftedObjectName[From, To]{liftedAssertion: liftedAssertion[From, To]{accessor: accessor, assertion: assertion}} + } else if _, ok := assertion.(ObjectNamespaceAssertion); ok { + return &liftedObjectNamespace[From, To]{liftedAssertion: liftedAssertion[From, To]{accessor: accessor, assertion: assertion}} + } else { + return &liftedAssertion[From, To]{accessor: accessor, assertion: assertion} + } +} + +// LiftAll performs Lift on all the provided assertions. +func LiftAll[From any, To any](accessor func(To) (From, bool), assertions ...Assertion[From]) Assertions[To] { + tos := make(Assertions[To], len(assertions)) + for i, a := range assertions { + tos[i] = Lift(accessor, a) + } + return tos +} + +// cast casts the obj into T. This is strangely required in cases where you want to cast +// object that is typed using a type parameter into a type specified by another type parameter. +// The compiler rejects such casts but doesn't complain if the cast is done using +// an indirection using this function. +func cast[T any](obj any) (T, bool) { + ret, ok := obj.(T) + return ret, ok +} + +type liftedAssertion[From any, To any] struct { + assertion Assertion[From] + accessor func(To) (From, bool) +} + +func (lon *liftedAssertion[From, To]) Test(t AssertT, obj To) { + t.Helper() + o, ok := lon.accessor(obj) + if !ok { + t.Errorf("invalid conversion") + return + } + lon.assertion.Test(t, o) +} + +type liftedObjectName[From any, To any] struct { + liftedAssertion[From, To] +} + +func (lon *liftedObjectName[From, To]) Name() string { + return lon.assertion.(ObjectNameAssertion).Name() +} + +type liftedObjectNamespace[From any, To any] struct { + liftedAssertion[From, To] +} + +func (lon *liftedObjectNamespace[From, To]) Namespace() string { + return lon.assertion.(ObjectNamespaceAssertion).Namespace() +} diff --git a/testsupport/assertions/metadata/metadata.go b/testsupport/assertions/metadata/metadata.go index d4b399f2a..8d590f6cd 100644 --- a/testsupport/assertions/metadata/metadata.go +++ b/testsupport/assertions/metadata/metadata.go @@ -6,43 +6,78 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" ) -type Assertions[Self any, T client.Object] struct { - assertions.EmbeddableAssertions[Self, T] +// MetadataAssertions is a set of assertions on the metadata of any client.Object. +type MetadataAssertions struct { + assertions.Assertions[client.Object] } -func (o *Assertions[Self, T]) HasLabel(label string) *Self { - o.AddAssertionFunc(func(t assertions.AssertT, o T) { - t.Helper() - assert.Contains(t, o.GetLabels(), label, "label '%s' not found", label) - }) - return o.Self() +// With is a "readable" constructor of MetadataAssertions. It is meant to be used +// to construct the MetadataAssertions instance so that the call reads like an English +// sentence: "metadata.With().Name().Namespace()..." +func With() *MetadataAssertions { + return &MetadataAssertions{} } -func (o *Assertions[Self, T]) HasLabelWithValue(label string, value string) *Self { - o.AddAssertionFunc(func(t assertions.AssertT, o T) { - t.Helper() - assert.Equal(t, value, o.GetLabels()[label]) - }) - return o.Self() +// objectName is a special impl of an assertion on object name that also implements +// the assertions.ObjectNameAssertion so that it can be used in await methods to +// identify the object. +type objectName struct { + name string } -func (o *Assertions[Self, T]) HasName(name string) *Self { - o.AddAssertionFunc(func(t assertions.AssertT, o T) { - t.Helper() - assert.Equal(t, name, o.GetName()) - }) - return o.Self() +// objectName is a special impl of an assertion on object name that also implements +// the assertions.ObjectNamespaceAssertion so that it can be used in await methods to +// identify the object. +type objectNamespace struct { + namespace string +} + +// Name adds an assertion on the objects name being equal to the provided value. +// The assertion also implements the assertions.ObjectNameAssertion so that it can be +// transparently used to identify the object during the assertions.Await calls. +func (ma *MetadataAssertions) Name(name string) *MetadataAssertions { + ma.Assertions = assertions.Append(ma.Assertions, &objectName{name: name}) + return ma +} + +// Name adds an assertion on the objects namespace being equal to the provided value. +// The assertion also implements the assertions.ObjectNamespaceAssertion so that it can be +// transparently used to identify the object during the assertions.Await calls. +func (ma *MetadataAssertions) Namespace(ns string) *MetadataAssertions { + ma.Assertions = assertions.Append(ma.Assertions, &objectNamespace{namespace: ns}) + return ma } -func (o *Assertions[Self, T]) IsInNamespace(namespace string) *Self { - o.AddAssertionFunc(func(t assertions.AssertT, o T) { +// Label adds an assertion for the presence of the label on the object. +func (ma *MetadataAssertions) Label(name string) *MetadataAssertions { + ma.Assertions = assertions.AppendFunc(ma.Assertions, func(t assertions.AssertT, obj client.Object) { t.Helper() - assert.Equal(t, namespace, o.GetNamespace()) + assert.Contains(t, obj.GetLabels(), name, "no label called '%s' found on the object", name) }) - return o.Self() + return ma +} + +func (a *objectName) Test(t assertions.AssertT, obj client.Object) { + t.Helper() + assert.Equal(t, a.name, obj.GetName(), "object name doesn't match") +} + +func (a *objectName) Name() string { + return a.name } -func (o *Assertions[Self, T]) WithNameAndNamespace(name, ns string) *Self { - o.HasName(name) - return o.IsInNamespace(ns) +func (a *objectNamespace) Test(t assertions.AssertT, obj client.Object) { + t.Helper() + assert.Equal(t, a.namespace, obj.GetNamespace(), "object namespace doesn't match") } + +func (a *objectNamespace) Namespace() string { + return a.namespace +} + +var ( + _ assertions.Assertion[client.Object] = (*objectName)(nil) + _ assertions.Assertion[client.Object] = (*objectNamespace)(nil) + _ assertions.ObjectNameAssertion = (*objectName)(nil) + _ assertions.ObjectNamespaceAssertion = (*objectNamespace)(nil) +) diff --git a/testsupport/assertions2/wait.go b/testsupport/assertions/object.go similarity index 59% rename from testsupport/assertions2/wait.go rename to testsupport/assertions/object.go index 7790af554..ffe9ab1a4 100644 --- a/testsupport/assertions2/wait.go +++ b/testsupport/assertions/object.go @@ -1,4 +1,4 @@ -package assertions2 +package assertions import ( "context" @@ -18,28 +18,23 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" ) -func WaitFor[T client.Object](cl client.Client) *Finder[T] { - return &Finder[T]{ - cl: cl, - timeout: wait.DefaultTimeout, - tick: wait.DefaultRetryInterval, - } +type AddressableObjectAssertions[T client.Object] struct { + Assertions[T] } -type Finder[T client.Object] struct { - cl client.Client - timeout time.Duration - tick time.Duration +type ObjectNameAssertion interface { + Name() string } -type FinderInNamespace[T client.Object] struct { - Finder[T] - namespace string +type ObjectNamespaceAssertion interface { + Namespace() string } -type FinderByObjectKey[T client.Object] struct { - Finder[T] - key client.ObjectKey +type Await[T client.Object] struct { + cl client.Client + timeout time.Duration + tick time.Duration + assertions Assertions[T] } type logger interface { @@ -52,54 +47,32 @@ type errorCollectingT struct { failed bool } -func (f *Finder[T]) WithTimeout(timeout time.Duration) *Finder[T] { +func (oa *AddressableObjectAssertions[T]) Await(cl client.Client) *Await[T] { + return &Await[T]{ + cl: cl, + timeout: wait.DefaultTimeout, + tick: wait.DefaultRetryInterval, + assertions: oa.Assertions, + } +} + +func (f *Await[T]) WithTimeout(timeout time.Duration) *Await[T] { f.timeout = timeout return f } -func (f *Finder[T]) WithRetryInterval(interval time.Duration) *Finder[T] { +func (f *Await[T]) WithRetryInterval(interval time.Duration) *Await[T] { f.tick = interval return f } -func (f *Finder[T]) WithObjectKey(namespace, name string) *FinderByObjectKey[T] { - return &FinderByObjectKey[T]{ - Finder: *f, - key: client.ObjectKey{Name: name, Namespace: namespace}, - } -} - -func (f *Finder[T]) InNamespace(ns string) *FinderInNamespace[T] { - return &FinderInNamespace[T]{ - Finder: *f, - namespace: ns, - } -} - -func (f *FinderInNamespace[T]) WithName(name string) *FinderByObjectKey[T] { - return &FinderByObjectKey[T]{ - Finder: f.Finder, - key: client.ObjectKey{Name: name, Namespace: f.namespace}, - } -} - -func (t *errorCollectingT) Errorf(format string, args ...interface{}) { - t.failed = true - t.errors = append(t.errors, fmt.Errorf(format, args...)) -} - -func (f *errorCollectingT) Helper() { - // we cannot call any inner Helper() because that wouldn't work anyway -} - -func (f *errorCollectingT) FailNow() { - panic("assertion failed") -} - -func (f *FinderInNamespace[T]) First(ctx context.Context, t RequireT, assertions WithAssertions[T]) T { +func (f *Await[T]) First(ctx context.Context, t RequireT) T { t.Helper() - t.Logf("waiting for the first object of type %T in namespace '%s' to match criteria", newObject[T](), f.namespace) + namespace, found := findNamespaceFromAssertions(f.assertions) + require.True(t, found, "no ObjectNamespaceAssertion found in the assertions but one required") + + t.Logf("waiting for the first object of type %T in namespace '%s' to match criteria", newObject[T](), namespace) possibleGvks, _, err := f.cl.Scheme().ObjectKinds(newObject[T]()) require.NoError(t, err) @@ -115,7 +88,7 @@ func (f *FinderInNamespace[T]) First(ctx context.Context, t RequireT, assertions list := &unstructured.UnstructuredList{} list.SetGroupVersionKind(gvk) ft.errors = nil - if err := f.cl.List(ctx, list, client.InNamespace(f.namespace)); err != nil { + if err := f.cl.List(ctx, list, client.InNamespace(namespace)); err != nil { return false, err } for _, uobj := range list.Items { @@ -125,7 +98,7 @@ func (f *FinderInNamespace[T]) First(ctx context.Context, t RequireT, assertions return false, fmt.Errorf("failed to cast object with GVK %v to object %T: %w", gvk, newObject[T](), err) } - testInner(ft, obj, assertions, true) + f.assertions.Test(ft, obj) if !ft.failed { returnedObject = obj @@ -136,10 +109,10 @@ func (f *FinderInNamespace[T]) First(ctx context.Context, t RequireT, assertions if err != nil { sb := strings.Builder{} sb.WriteString("failed to find objects (of GVK '%s') in namespace '%s' matching the criteria: %s") - args := []any{gvk, f.namespace, err.Error()} + args := []any{gvk, namespace, err.Error()} list := &unstructured.UnstructuredList{} list.SetGroupVersionKind(gvk) - if err := f.cl.List(context.TODO(), list, client.InNamespace(f.namespace)); err != nil { + if err := f.cl.List(context.TODO(), list, client.InNamespace(namespace)); err != nil { sb.WriteString(" and also failed to retrieve the object at all with error: %s") args = append(args, err) } else { @@ -152,10 +125,7 @@ func (f *FinderInNamespace[T]) First(ctx context.Context, t RequireT, assertions sb.WriteRune('\n') sb.WriteString("object ") sb.WriteString(key.String()) - sb.WriteString(":\n") - format, oargs := doExplainAfterTestFailure(obj, assertions) - sb.WriteString(format) - args = append(args, oargs...) + sb.WriteString(":\nSome of the assertions failed to match the object (see output above).") } } t.Logf(sb.String(), args...) @@ -164,10 +134,18 @@ func (f *FinderInNamespace[T]) First(ctx context.Context, t RequireT, assertions return returnedObject } -func (f *FinderByObjectKey[T]) Matching(ctx context.Context, t AssertT, assertions WithAssertions[T]) T { +func (f *Await[T]) Matching(ctx context.Context, t RequireT) T { t.Helper() - t.Logf("waiting for %T with name '%s' in namespace '%s' to match additional criteria", newObject[T](), f.key.Name, f.key.Namespace) + name, found := findNameFromAssertions(f.assertions) + require.True(t, found, "ObjectNameAssertion not found in the list of assertions but one is required") + + namespace, found := findNamespaceFromAssertions(f.assertions) + require.True(t, found, "ObjectNamespaceAssertion not found in the list of assertions but one is required") + + key := client.ObjectKey{Name: name, Namespace: namespace} + + t.Logf("waiting for %T with name '%s' in namespace '%s' to match additional criteria", newObject[T](), key.Name, key.Namespace) var returnedObject T @@ -177,13 +155,13 @@ func (f *FinderByObjectKey[T]) Matching(ctx context.Context, t AssertT, assertio t.Helper() ft.errors = nil obj := newObject[T]() - err = f.cl.Get(ctx, f.key, obj) + err = f.cl.Get(ctx, key, obj) if err != nil { - assert.NoError(ft, err, "failed to find the object by key %s", f.key) + assert.NoError(ft, err, "failed to find the object by key %s", key) return false, err } - testInner(ft, obj, assertions, true) + f.assertions.Test(ft, obj) if !ft.failed { returnedObject = obj @@ -197,36 +175,56 @@ func (f *FinderByObjectKey[T]) Matching(ctx context.Context, t AssertT, assertio t.Errorf("%s", e) //nolint: testifylint } obj := newObject[T]() - err := f.cl.Get(ctx, f.key, obj) + err := f.cl.Get(ctx, key, obj) if err != nil { - t.Errorf("failed to find the object while reporting the failure to match by criteria using object key %s", f.key) + t.Errorf("failed to find the object while reporting the failure to match by criteria using object key %s", key) return returnedObject } - format, args := doExplainAfterTestFailure(obj, assertions) - t.Logf(format, args...) + t.Logf("Some of the assertions failed to match the object (see output above).") } - t.Logf("couldn't match %T with name '%s' in namespace '%s' with additional criteria because of: %s", newObject[T](), f.key.Name, f.key.Namespace, err) + t.Logf("couldn't match %T with name '%s' in namespace '%s' with additional criteria because of: %s", newObject[T](), key.Name, key.Namespace, err) } return returnedObject } -func (f *FinderByObjectKey[T]) Deleted(ctx context.Context, t AssertT) { +func (f *Await[T]) Deleted(ctx context.Context, t RequireT) { t.Helper() + name, found := findNameFromAssertions(f.assertions) + require.True(t, found, "ObjectNameAssertion not found in the list of assertions but one is required") + + namespace, found := findNamespaceFromAssertions(f.assertions) + require.True(t, found, "ObjectNamespaceAssertion not found in the list of assertions but one is required") + + key := client.ObjectKey{Name: name, Namespace: namespace} + err := kwait.PollUntilContextTimeout(ctx, f.tick, f.timeout, true, func(ctx context.Context) (done bool, err error) { obj := newObject[T]() - err = f.cl.Get(ctx, f.key, obj) + err = f.cl.Get(ctx, key, obj) if err != nil && apierrors.IsNotFound(err) { return true, nil } return false, err }) if err != nil { - assert.Fail(t, "object with key %s still present or other error happened: %s", f.key, err) + assert.Fail(t, "object with key %s still present or other error happened: %s", key, err) } } +func (t *errorCollectingT) Errorf(format string, args ...any) { + t.failed = true + t.errors = append(t.errors, fmt.Errorf(format, args...)) +} + +func (f *errorCollectingT) Helper() { + // we cannot call any inner Helper() because that wouldn't work anyway +} + +func (f *errorCollectingT) FailNow() { + panic("assertion failed") +} + func remarshal[T client.Object](scheme *runtime.Scheme, obj *unstructured.Unstructured) (T, error) { var empty T raw, err := obj.MarshalJSON() @@ -259,3 +257,21 @@ func newObject[T client.Object]() T { return zero.(T) } + +func findNameFromAssertions[T any](as []Assertion[T]) (string, bool) { + for _, a := range as { + if oa, ok := a.(ObjectNameAssertion); ok { + return oa.Name(), true + } + } + return "", false +} + +func findNamespaceFromAssertions[T any](as []Assertion[T]) (string, bool) { + for _, a := range as { + if oa, ok := a.(ObjectNamespaceAssertion); ok { + return oa.Namespace(), true + } + } + return "", false +} diff --git a/testsupport/assertions/object/object.go b/testsupport/assertions/object/object.go new file mode 100644 index 000000000..ab52e88f7 --- /dev/null +++ b/testsupport/assertions/object/object.go @@ -0,0 +1,26 @@ +package object + +import ( + "github.com/codeready-toolchain/toolchain-e2e/testsupport/assertions" + "github.com/codeready-toolchain/toolchain-e2e/testsupport/assertions/metadata" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// ObjectAssertions is a common base for assertions on client.Object subtypes. +// It provides assertions on the object metadata. +type ObjectAssertions[Self any, T client.Object] struct { + assertions.AddressableObjectAssertions[T] + self *Self +} + +// SetFluentSelf sets the "Self" that should be returned from the fluent methods like +// ObjectMeta(). +func (oa *ObjectAssertions[Self, T]) SetFluentSelf(self *Self) { + oa.self = self +} + +// ObjectMeta sets the assertions on the metadata of the object. +func (oa *ObjectAssertions[Self, T]) ObjectMeta(mas *metadata.MetadataAssertions) *Self { + oa.Assertions = assertions.AppendGeneric(oa.Assertions, mas.Assertions...) + return oa.self +} diff --git a/testsupport/assertions/objectundertest.go b/testsupport/assertions/objectundertest.go deleted file mode 100644 index b0dd83223..000000000 --- a/testsupport/assertions/objectundertest.go +++ /dev/null @@ -1,25 +0,0 @@ -package assertions - -// Not used at the moment - just an experiment how to play with custom testingT instances -// and influence the output -func ObjectUnderTest(t AssertT, obj any) AssertT { - return &objectT{ - AssertT: t, - Object: obj, - } -} - -type objectT struct { - AssertT - Object any - objectReported bool -} - -func (t *objectT) Errorf(format string, args ...any) { - t.Helper() - if !t.objectReported { - t.Errorf("Object failed one or more assertions\n%+v", t.Object) //nolint: testifylint - t.objectReported = true - } - t.AssertT.Errorf(format, args...) -} diff --git a/testsupport/assertions/spaceprovisionerconfig/spc.go b/testsupport/assertions/spaceprovisionerconfig/spc.go index 791cca863..50b8f3a49 100644 --- a/testsupport/assertions/spaceprovisionerconfig/spc.go +++ b/testsupport/assertions/spaceprovisionerconfig/spc.go @@ -4,33 +4,33 @@ import ( toolchainv1aplha1 "github.com/codeready-toolchain/api/api/v1alpha1" "github.com/codeready-toolchain/toolchain-e2e/testsupport/assertions" "github.com/codeready-toolchain/toolchain-e2e/testsupport/assertions/conditions" - "github.com/codeready-toolchain/toolchain-e2e/testsupport/assertions/metadata" + "github.com/codeready-toolchain/toolchain-e2e/testsupport/assertions/object" "github.com/stretchr/testify/assert" ) -type Assertions struct { - Metadata metadata.Assertions[Assertions, *toolchainv1aplha1.SpaceProvisionerConfig] - Conditions conditions.Assertions[Assertions, *toolchainv1aplha1.SpaceProvisionerConfig] - assertions.EmbeddableObjectAssertions[Assertions, *toolchainv1aplha1.SpaceProvisionerConfig] +type SpaceProvisionerConfigAssertions struct { + object.ObjectAssertions[SpaceProvisionerConfigAssertions, *toolchainv1aplha1.SpaceProvisionerConfig] } -func Asserting() *Assertions { - assertions := []assertions.Assertion[*toolchainv1aplha1.SpaceProvisionerConfig]{} - instance := &Assertions{} - instance.Conditions.WireUp(instance, &assertions, func(spc *toolchainv1aplha1.SpaceProvisionerConfig) *[]toolchainv1aplha1.Condition { - return &spc.Status.Conditions - }) - instance.Metadata.WireUp(instance, &assertions) - instance.WireUp(instance, &assertions) +func Asserting() *SpaceProvisionerConfigAssertions { + spc := &SpaceProvisionerConfigAssertions{} + spc.SetFluentSelf(spc) + return spc +} - return instance +func (spc *SpaceProvisionerConfigAssertions) Conditions(cas *conditions.ConditionAssertions) *SpaceProvisionerConfigAssertions { + spc.Assertions = assertions.AppendLifted(getConditions, spc.Assertions, cas.Assertions...) + return spc } -func (a *Assertions) ReferencesToolchainCluster(tc string) *Assertions { - a.AddAssertionFunc(func(t assertions.AssertT, spc *toolchainv1aplha1.SpaceProvisionerConfig) { - assert.Equal(t, tc, spc.Spec.ToolchainCluster) +func (spc *SpaceProvisionerConfigAssertions) ToolchainClusterName(name string) *SpaceProvisionerConfigAssertions { + spc.Assertions = assertions.AppendFunc(spc.Assertions, func(t assertions.AssertT, obj *toolchainv1aplha1.SpaceProvisionerConfig) { + t.Helper() + assert.Equal(t, name, obj.Spec.ToolchainCluster, "unexpected toolchainCluster") }) - return a + return spc } -var _ assertions.WithAssertions[*toolchainv1aplha1.SpaceProvisionerConfig] = (*Assertions)(nil) +func getConditions(spc *toolchainv1aplha1.SpaceProvisionerConfig) ([]toolchainv1aplha1.Condition, bool) { + return spc.Status.Conditions, true +} diff --git a/testsupport/assertions/wait.go b/testsupport/assertions/wait.go deleted file mode 100644 index 365226370..000000000 --- a/testsupport/assertions/wait.go +++ /dev/null @@ -1,261 +0,0 @@ -package assertions - -import ( - "context" - "encoding/json" - "fmt" - "reflect" - "strings" - "time" - - "github.com/codeready-toolchain/toolchain-e2e/testsupport/wait" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - apierrors "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/runtime" - kwait "k8s.io/apimachinery/pkg/util/wait" - "sigs.k8s.io/controller-runtime/pkg/client" -) - -func WaitFor[T client.Object](cl client.Client) *Finder[T] { - return &Finder[T]{ - cl: cl, - timeout: wait.DefaultTimeout, - tick: wait.DefaultRetryInterval, - } -} - -type Finder[T client.Object] struct { - cl client.Client - timeout time.Duration - tick time.Duration -} - -type FinderInNamespace[T client.Object] struct { - Finder[T] - namespace string -} - -type FinderByObjectKey[T client.Object] struct { - Finder[T] - key client.ObjectKey -} - -type logger interface { - Logf(format string, args ...any) -} - -type errorCollectingT struct { - errors []error - logger - failed bool -} - -func (f *Finder[T]) WithTimeout(timeout time.Duration) *Finder[T] { - f.timeout = timeout - return f -} - -func (f *Finder[T]) WithRetryInterval(interval time.Duration) *Finder[T] { - f.tick = interval - return f -} - -func (f *Finder[T]) WithObjectKey(namespace, name string) *FinderByObjectKey[T] { - return &FinderByObjectKey[T]{ - Finder: *f, - key: client.ObjectKey{Name: name, Namespace: namespace}, - } -} - -func (f *Finder[T]) InNamespace(ns string) *FinderInNamespace[T] { - return &FinderInNamespace[T]{ - Finder: *f, - namespace: ns, - } -} - -func (f *FinderInNamespace[T]) WithName(name string) *FinderByObjectKey[T] { - return &FinderByObjectKey[T]{ - Finder: f.Finder, - key: client.ObjectKey{Name: name, Namespace: f.namespace}, - } -} - -func (t *errorCollectingT) Errorf(format string, args ...interface{}) { - t.failed = true - t.errors = append(t.errors, fmt.Errorf(format, args...)) -} - -func (f *errorCollectingT) Helper() { - // we cannot call any inner Helper() because that wouldn't work anyway -} - -func (f *errorCollectingT) FailNow() { - panic("assertion failed") -} - -func (f *FinderInNamespace[T]) First(ctx context.Context, t RequireT, assertions WithAssertions[T]) T { - t.Helper() - - t.Logf("waiting for the first object of type %T in namespace '%s' to match criteria", newObject[T](), f.namespace) - - possibleGvks, _, err := f.cl.Scheme().ObjectKinds(newObject[T]()) - require.NoError(t, err) - require.Len(t, possibleGvks, 1) - - gvk := possibleGvks[0] - - var returnedObject T - - ft := &errorCollectingT{logger: t} - - err = kwait.PollUntilContextTimeout(ctx, f.tick, f.timeout, true, func(ctx context.Context) (done bool, err error) { - list := &unstructured.UnstructuredList{} - list.SetGroupVersionKind(gvk) - ft.errors = nil - if err := f.cl.List(ctx, list, client.InNamespace(f.namespace)); err != nil { - return false, err - } - for _, uobj := range list.Items { - uobj := uobj - obj, err := cast[T](f.cl.Scheme(), &uobj) - if err != nil { - return false, fmt.Errorf("failed to cast object with GVK %v to object %T: %w", gvk, newObject[T](), err) - } - - testInner(ft, obj, assertions, true) - - if !ft.failed { - returnedObject = obj - } - } - return !ft.failed, nil - }) - if err != nil { - sb := strings.Builder{} - sb.WriteString("failed to find objects (of GVK '%s') in namespace '%s' matching the criteria: %s") - args := []any{gvk, f.namespace, err.Error()} - list := &unstructured.UnstructuredList{} - list.SetGroupVersionKind(gvk) - if err := f.cl.List(context.TODO(), list, client.InNamespace(f.namespace)); err != nil { - sb.WriteString(" and also failed to retrieve the object at all with error: %s") - args = append(args, err) - } else { - sb.WriteString("\nlisting the objects found in cluster with the differences from the expected state for each:") - for _, o := range list.Items { - o := o - obj, _ := cast[T](f.cl.Scheme(), &o) - key := client.ObjectKeyFromObject(obj) - - sb.WriteRune('\n') - sb.WriteString("object ") - sb.WriteString(key.String()) - sb.WriteString(":\n") - format, oargs := doExplainAfterTestFailure(obj, assertions) - sb.WriteString(format) - args = append(args, oargs...) - } - } - t.Logf(sb.String(), args...) - } - - return returnedObject -} - -func (f *FinderByObjectKey[T]) Matching(ctx context.Context, t AssertT, assertions WithAssertions[T]) T { - t.Helper() - - t.Logf("waiting for %T with name '%s' in namespace '%s' to match additional criteria", newObject[T](), f.key.Name, f.key.Namespace) - - var returnedObject T - - ft := &errorCollectingT{logger: t} - - err := kwait.PollUntilContextTimeout(ctx, f.tick, f.timeout, true, func(ctx context.Context) (done bool, err error) { - t.Helper() - ft.errors = nil - obj := newObject[T]() - err = f.cl.Get(ctx, f.key, obj) - if err != nil { - assert.NoError(ft, err, "failed to find the object by key %s", f.key) - return false, err - } - - testInner(ft, obj, assertions, true) - - if !ft.failed { - returnedObject = obj - } - - return !ft.failed, nil - }) - if err != nil { - if ft.failed { - for _, e := range ft.errors { - t.Errorf("%s", e) //nolint: testifylint - } - obj := newObject[T]() - err := f.cl.Get(ctx, f.key, obj) - if err != nil { - t.Errorf("failed to find the object while reporting the failure to match by criteria using object key %s", f.key) - return returnedObject - } - format, args := doExplainAfterTestFailure(obj, assertions) - t.Logf(format, args...) - } - t.Logf("couldn't match %T with name '%s' in namespace '%s' with additional criteria because of: %s", newObject[T](), f.key.Name, f.key.Namespace, err) - } - - return returnedObject -} - -func (f *FinderByObjectKey[T]) Deleted(ctx context.Context, t AssertT) { - t.Helper() - - err := kwait.PollUntilContextTimeout(ctx, f.tick, f.timeout, true, func(ctx context.Context) (done bool, err error) { - obj := newObject[T]() - err = f.cl.Get(ctx, f.key, obj) - if err != nil && apierrors.IsNotFound(err) { - return true, nil - } - return false, err - }) - if err != nil { - assert.Fail(t, "object with key %s still present or other error happened: %s", f.key, err) - } -} - -func cast[T client.Object](scheme *runtime.Scheme, obj *unstructured.Unstructured) (T, error) { - var empty T - raw, err := obj.MarshalJSON() - if err != nil { - return empty, fmt.Errorf("failed to obtain the raw JSON of the object: %w", err) - } - - typed, err := scheme.New(obj.GroupVersionKind()) - if err != nil { - return empty, fmt.Errorf("failed to create a new empty object from the scheme: %w", err) - } - - err = json.Unmarshal(raw, typed) - if err != nil { - return empty, fmt.Errorf("failed to unmarshal the raw JSON to the go structure: %w", err) - } - - return typed.(T), nil -} - -func newObject[T client.Object]() T { - // all client.Object implementations are pointers, so this declaration gives us just a nil pointer - var v T - - ptrT := reflect.TypeOf(v) - valT := ptrT.Elem() - ptrToZeroV := reflect.New(valT) - - zero := ptrToZeroV.Interface() - - return zero.(T) -} diff --git a/testsupport/assertions2/assertion.go b/testsupport/assertions2/assertion.go deleted file mode 100644 index a0006cc88..000000000 --- a/testsupport/assertions2/assertion.go +++ /dev/null @@ -1,102 +0,0 @@ -package assertions2 - -var _ Assertion[bool] = (AssertionFunc[bool])(nil) - -// Assertion is a functional interface that is used to test whether an object satisfies some condition. -type Assertion[T any] interface { - Test(t AssertT, obj T) -} - -// AssertionFunc converts a function into an assertion. -type AssertionFunc[T any] func(t AssertT, obj T) - -// WithAssertions is an interface for "things" that make available a list of assertions to use in the Test function. -type WithAssertions[T any] interface { - Assertions() []Assertion[T] -} - -func Test[T any, A WithAssertions[T]](t AssertT, obj T, assertions A) { - t.Helper() - testInner(t, obj, assertions, false) -} - -func CastAssertion[Type any, SubType any](a Assertion[Type]) Assertion[SubType] { - if af, ok := a.(AssertionFixer[Type]); ok { - // convert the assertion fixer, too - return &AssertAndFixFunc[SubType]{ - Assert: func(t AssertT, obj SubType) { - t.Helper() - tobj, ok := cast[Type](obj) - if !ok { - t.Errorf("invalid cast") - } - - a.Test(t, tobj) - }, - Fix: func(obj SubType) SubType { - tobj, ok := cast[Type](obj) - if ok { - tobj = af.AdaptToMatch(tobj) - obj, _ = cast[SubType](tobj) - } - return obj - }, - } - } else { - // simple - return AssertionFunc[SubType](func(t AssertT, obj SubType) { - t.Helper() - sobj, ok := cast[Type](obj) - if !ok { - t.Errorf("invalid cast") - } - - a.Test(t, sobj) - }) - } -} - -// cast casts the obj into T. This is strangely required in cases where you want to cast -// object that is typed using a type parameter into a type specified by another type parameter. -// The compiler rejects such casts but doesn't complain if the cast is done using -// an indirection using this function. -func cast[T any](obj any) (T, bool) { - ret, ok := obj.(T) - return ret, ok -} - -func testInner[T any, A WithAssertions[T]](t AssertT, obj T, assertions A, suppressLogAround bool) { - t.Helper() - ft := &failureTrackingT{AssertT: t} - - if !suppressLogAround { - t.Logf("About to test object %T with assertions", obj) - } - - for _, a := range assertions.Assertions() { - a.Test(ft, obj) - } - - if !suppressLogAround && ft.failed { - format, args := doExplainAfterTestFailure(obj, assertions) - t.Logf(format, args...) - } -} - -func doExplainAfterTestFailure[T any, A WithAssertions[T]](obj T, assertions A) (format string, args []any) { - diff := Explain(obj, assertions) - if diff != "" { - format = "Some of the assertions failed to match the object (see output above). The following diff shows what the object should have looked like:\n%s" - args = []any{diff} - } else { - format = "Some of the assertions failed to match the object (see output above)." - args = []any{} - } - - return -} - -func (f AssertionFunc[T]) Test(t AssertT, obj T) { - t.Helper() - f(t, obj) -} diff --git a/testsupport/assertions2/conditions/conditions.go b/testsupport/assertions2/conditions/conditions.go deleted file mode 100644 index 82420d98c..000000000 --- a/testsupport/assertions2/conditions/conditions.go +++ /dev/null @@ -1,29 +0,0 @@ -package conditions - -import ( - toolchainv1aplha1 "github.com/codeready-toolchain/api/api/v1alpha1" - "github.com/codeready-toolchain/toolchain-common/pkg/condition" - assertions "github.com/codeready-toolchain/toolchain-e2e/testsupport/assertions2" - "github.com/stretchr/testify/assert" -) - -type Assertions[Self any, T any] struct { - assertions.EmbeddableAssertions[Self, T] - - accessor func(T) *[]toolchainv1aplha1.Condition -} - -func (a *Assertions[Self, T]) WireUp(self *Self, assertions *[]assertions.Assertion[T], accessor func(T) *[]toolchainv1aplha1.Condition) { - a.EmbeddableAssertions.WireUp(self, assertions) - a.accessor = accessor -} - -func (a *Assertions[Self, T]) HasConditionWithType(typ toolchainv1aplha1.ConditionType) *Self { - a.AddAssertionFunc(func(t assertions.AssertT, obj T) { - t.Helper() - conds := a.accessor(obj) - _, found := condition.FindConditionByType(*conds, typ) - assert.True(t, found, "condition with the type %s not found", typ) - }) - return a.Self() -} diff --git a/testsupport/assertions2/deepcopy.go b/testsupport/assertions2/deepcopy.go deleted file mode 100644 index 989a8dced..000000000 --- a/testsupport/assertions2/deepcopy.go +++ /dev/null @@ -1,13 +0,0 @@ -package assertions2 - -type deepCopy[T any] interface { - DeepCopy() T -} - -func copyObject[T any](obj any) T { - if dc, ok := obj.(deepCopy[T]); ok { - return dc.DeepCopy() - } - // TODO: should we go into attempting cloning slices and maps? - return obj.(T) -} diff --git a/testsupport/assertions2/embeddableassertions.go b/testsupport/assertions2/embeddableassertions.go deleted file mode 100644 index db34fb79a..000000000 --- a/testsupport/assertions2/embeddableassertions.go +++ /dev/null @@ -1,60 +0,0 @@ -package assertions2 - -import "sigs.k8s.io/controller-runtime/pkg/client" - -var _ WithAssertions[int] = (*EmbeddableAssertions[int, int])(nil) - -// EmbeddableAssertions is meant to be embedded into other structs as a means for storing the assertions. -// Initialize it using the EmbedInto method. -type EmbeddableAssertions[Self any, T any] struct { - assertions *[]Assertion[T] - self *Self -} - -// Self can be used in structs that embed embeddable assertions and are themselves meant to be embedded to return the correct -// type of the struct that embeds them from their fluent methods. -func (a *EmbeddableAssertions[Self, T]) Self() *Self { - return a.self -} - -// WireUp initializes the embeddable assertions struct using a pointer to the assertions array that should be used -// as the storage for the assertions and also a pointer to "self". This is meant to enable returning a correctly typed object -// from the Self method such that all structs that are embedded into some "end user" struct can define fluent methods -// returning the correct type of the "end user". -func (a *EmbeddableAssertions[Self, T]) WireUp(self *Self, assertions *[]Assertion[T]) { - a.self = self - a.assertions = assertions -} - -// AddAssertion adds the provided assertion to the list of assertions. -func (ea *EmbeddableAssertions[Self, T]) AddAssertion(a Assertion[T]) { - *ea.assertions = append(*ea.assertions, a) -} - -// AddAssertionFunc is a convenience function for the common case of implementing the assertions -// using a simple function. -func (ea *EmbeddableAssertions[Self, T]) AddAssertionFunc(assertion func(AssertT, T)) { - ea.AddAssertion(AssertionFunc[T](assertion)) -} - -func (ea *EmbeddableAssertions[Self, T]) Assertions() []Assertion[T] { - return *ea.assertions -} - -func (ea *EmbeddableAssertions[Self, T]) WireableAssertions() *[]Assertion[T] { - return ea.assertions -} - -// Test is a fluent variant to test the assertions on an object. -func (ea *EmbeddableAssertions[Self, T]) Test(t AssertT, obj T) { - t.Helper() - Test(t, obj, ea) -} - -type EmbeddableObjectAssertions[Self any, T client.Object] struct { - EmbeddableAssertions[Self, T] -} - -func (ea *EmbeddableObjectAssertions[Self, T]) WaitFor(cl client.Client) *Finder[T] { - return WaitFor[T](cl) -} diff --git a/testsupport/assertions2/fix.go b/testsupport/assertions2/fix.go deleted file mode 100644 index 28b6afb66..000000000 --- a/testsupport/assertions2/fix.go +++ /dev/null @@ -1,56 +0,0 @@ -package assertions2 - -import ( - "strings" - - "github.com/google/go-cmp/cmp" -) - -var ( - _ Assertion[bool] = (*AssertAndFixFunc[bool])(nil) - _ AssertionFixer[bool] = (*AssertAndFixFunc[bool])(nil) -) - -func Explain[T any, A WithAssertions[T]](obj T, assertions A) string { - cpy := copyObject[T](obj) - - modified := false - for _, a := range assertions.Assertions() { - if f, ok := a.(AssertionFixer[T]); ok { - f.AdaptToMatch(cpy) - modified = true - } - } - - if modified { - sb := strings.Builder{} - sb.WriteString(cmp.Diff(obj, cpy)) - - return sb.String() - } - - return "" -} - -type AssertionFixer[T any] interface { - AdaptToMatch(object T) T -} - -type AssertAndFixFunc[T any] struct { - Assert func(t AssertT, obj T) - Fix func(obj T) T -} - -func (a *AssertAndFixFunc[T]) Test(t AssertT, obj T) { - t.Helper() - if a.Assert != nil { - a.Assert(t, obj) - } -} - -func (a *AssertAndFixFunc[T]) AdaptToMatch(object T) T { - if a.Fix != nil { - return a.Fix(object) - } - return object -} diff --git a/testsupport/assertions2/metadata/metadata.go b/testsupport/assertions2/metadata/metadata.go deleted file mode 100644 index f4fc524d2..000000000 --- a/testsupport/assertions2/metadata/metadata.go +++ /dev/null @@ -1,102 +0,0 @@ -package metadata - -import ( - assertions "github.com/codeready-toolchain/toolchain-e2e/testsupport/assertions2" - "github.com/stretchr/testify/assert" - "sigs.k8s.io/controller-runtime/pkg/client" -) - -type Assertions[Self any, T client.Object] struct { - assertions.EmbeddableAssertions[Self, T] -} - -func (o *Assertions[Self, T]) Like(ma *ChainingMetadataAssert) *Self { - for _, a := range ma.Assertions() { - o.AddAssertion(assertions.CastAssertion[client.Object, T](a)) - } - return o.Self() -} - -func (o *Assertions[Self, T]) With() *NestedMetadataAssert[Self, T] { - nested := &NestedMetadataAssert[Self, T]{parentSelf: o.Self()} - assertions := o.WireableAssertions() - nested.WireUp(nested, assertions) - return nested -} - -type NestedMetadataAssert[P any, T client.Object] struct { - parentSelf *P - assertions.EmbeddableAssertions[NestedMetadataAssert[P, T], T] -} - -func (nma *NestedMetadataAssert[P, T]) Done() *P { - return nma.parentSelf -} - -func (nma *NestedMetadataAssert[P, T]) Label(name string) *NestedMetadataAssert[P, T] { - nma.AddAssertionFunc(func(t assertions.AssertT, o T) { - t.Helper() - assert.Contains(t, o.GetLabels(), name, "label '%s' not found", name) - }) - return nma -} - -func (nma *NestedMetadataAssert[P, T]) Name(name string) *NestedMetadataAssert[P, T] { - nma.AddAssertionFunc(func(t assertions.AssertT, o T) { - t.Helper() - assert.Equal(t, o.GetName(), name) - }) - return nma -} - -func (nma *NestedMetadataAssert[P, T]) Namespace(ns string) *NestedMetadataAssert[P, T] { - nma.AddAssertionFunc(func(t assertions.AssertT, o T) { - t.Helper() - assert.Equal(t, o.GetNamespace(), ns) - }) - return nma -} - -func (nma *NestedMetadataAssert[P, T]) NameAndNamespace(name, ns string) *NestedMetadataAssert[P, T] { - nma.Name(name) - return nma.Namespace(ns) -} - -type ChainingMetadataAssert struct { - assertions.EmbeddableAssertions[ChainingMetadataAssert, client.Object] -} - -func With() *ChainingMetadataAssert { - ma := &ChainingMetadataAssert{} - ma.WireUp(ma, &[]assertions.Assertion[client.Object]{}) - return ma -} - -func (ma *ChainingMetadataAssert) Label(name string) *ChainingMetadataAssert { - ma.AddAssertionFunc(func(t assertions.AssertT, o client.Object) { - t.Helper() - assert.Contains(t, o.GetLabels(), name, "label '%s' not found", name) - }) - return ma -} - -func (ma *ChainingMetadataAssert) Name(name string) *ChainingMetadataAssert { - ma.AddAssertionFunc(func(t assertions.AssertT, o client.Object) { - t.Helper() - assert.Equal(t, o.GetName(), name) - }) - return ma -} - -func (ma *ChainingMetadataAssert) Namespace(ns string) *ChainingMetadataAssert { - ma.AddAssertionFunc(func(t assertions.AssertT, o client.Object) { - t.Helper() - assert.Equal(t, o.GetNamespace(), ns) - }) - return ma -} - -func (ma *ChainingMetadataAssert) NameAndNamespace(name, ns string) *ChainingMetadataAssert { - ma.Name(name) - return ma.Namespace(ns) -} diff --git a/testsupport/assertions2/spaceprovisionerconfig/spc.go b/testsupport/assertions2/spaceprovisionerconfig/spc.go deleted file mode 100644 index 953c4fc64..000000000 --- a/testsupport/assertions2/spaceprovisionerconfig/spc.go +++ /dev/null @@ -1,36 +0,0 @@ -package spaceprovisionerconfig - -import ( - toolchainv1aplha1 "github.com/codeready-toolchain/api/api/v1alpha1" - assertions "github.com/codeready-toolchain/toolchain-e2e/testsupport/assertions2" - "github.com/codeready-toolchain/toolchain-e2e/testsupport/assertions2/conditions" - "github.com/codeready-toolchain/toolchain-e2e/testsupport/assertions2/metadata" - "github.com/stretchr/testify/assert" -) - -type Assertions struct { - Metadata metadata.Assertions[Assertions, *toolchainv1aplha1.SpaceProvisionerConfig] - Conditions conditions.Assertions[Assertions, *toolchainv1aplha1.SpaceProvisionerConfig] - assertions.EmbeddableObjectAssertions[Assertions, *toolchainv1aplha1.SpaceProvisionerConfig] -} - -func Asserting() *Assertions { - assertions := []assertions.Assertion[*toolchainv1aplha1.SpaceProvisionerConfig]{} - instance := &Assertions{} - instance.Conditions.WireUp(instance, &assertions, func(spc *toolchainv1aplha1.SpaceProvisionerConfig) *[]toolchainv1aplha1.Condition { - return &spc.Status.Conditions - }) - instance.Metadata.WireUp(instance, &assertions) - instance.WireUp(instance, &assertions) - - return instance -} - -func (a *Assertions) ReferencesToolchainCluster(tc string) *Assertions { - a.AddAssertionFunc(func(t assertions.AssertT, spc *toolchainv1aplha1.SpaceProvisionerConfig) { - assert.Equal(t, tc, spc.Spec.ToolchainCluster) - }) - return a -} - -var _ assertions.WithAssertions[*toolchainv1aplha1.SpaceProvisionerConfig] = (*Assertions)(nil) diff --git a/testsupport/assertions2/t.go b/testsupport/assertions2/t.go deleted file mode 100644 index 6ef4ec5b0..000000000 --- a/testsupport/assertions2/t.go +++ /dev/null @@ -1,29 +0,0 @@ -package assertions2 - -import ( - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -type AssertT interface { - assert.TestingT - Helper() - Logf(format string, args ...any) -} - -type RequireT interface { - require.TestingT - Helper() - Logf(format string, args ...any) -} - -type failureTrackingT struct { - AssertT - failed bool -} - -func (t *failureTrackingT) Errorf(format string, args ...any) { - t.Helper() - t.failed = true - t.AssertT.Errorf(format, args...) -} diff --git a/testsupport/assertions2_use/assertions_test.go b/testsupport/assertions2_use/assertions_test.go deleted file mode 100644 index 15d68640b..000000000 --- a/testsupport/assertions2_use/assertions_test.go +++ /dev/null @@ -1,43 +0,0 @@ -package assertions2_use - -import ( - "context" - "testing" - "time" - - toolchainv1aplha1 "github.com/codeready-toolchain/api/api/v1alpha1" - assertions "github.com/codeready-toolchain/toolchain-e2e/testsupport/assertions2" - "github.com/codeready-toolchain/toolchain-e2e/testsupport/assertions2/metadata" - "github.com/codeready-toolchain/toolchain-e2e/testsupport/assertions2/spaceprovisionerconfig" - "github.com/stretchr/testify/require" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "sigs.k8s.io/controller-runtime/pkg/client/fake" -) - -func Test(t *testing.T) { - spcUnderTest := &toolchainv1aplha1.SpaceProvisionerConfig{ - ObjectMeta: metav1.ObjectMeta{ - Name: "kachny", - Namespace: "default", - }, - } - - scheme := runtime.NewScheme() - require.NoError(t, toolchainv1aplha1.AddToScheme(scheme)) - cl := fake.NewClientBuilder().WithScheme(scheme).WithRuntimeObjects(spcUnderTest).Build() - - // use the assertions in a simple immediate call - spaceprovisionerconfig.Asserting(). - Metadata.Like(metadata.With().Label("asdf").Name("kachny").Namespace("default")). - Metadata.With().Label("asdf").Name("kachny").Namespace("default").Done(). - Conditions.HasConditionWithType(toolchainv1aplha1.ConditionReady). - Test(t, spcUnderTest) - - // this is the new WaitFor - assertions.WaitFor[*toolchainv1aplha1.SpaceProvisionerConfig](cl). - WithTimeout(1*time.Second). // defaults to wait.DefaultTimeout which is 2 minutes, so let's make it shorter here - WithObjectKey("default", "kachny"). - Matching(context.TODO(), t, - spaceprovisionerconfig.Asserting().Metadata.Like(metadata.With().Label("asdf"))) -} diff --git a/testsupport/assertions_use/assertions_test.go b/testsupport/assertions_use/assertions_test.go index 227d3cc2d..cc9522b39 100644 --- a/testsupport/assertions_use/assertions_test.go +++ b/testsupport/assertions_use/assertions_test.go @@ -3,10 +3,10 @@ package assertions_use import ( "context" "testing" - "time" toolchainv1aplha1 "github.com/codeready-toolchain/api/api/v1alpha1" - "github.com/codeready-toolchain/toolchain-e2e/testsupport/assertions" + "github.com/codeready-toolchain/toolchain-e2e/testsupport/assertions/conditions" + "github.com/codeready-toolchain/toolchain-e2e/testsupport/assertions/metadata" "github.com/codeready-toolchain/toolchain-e2e/testsupport/assertions/spaceprovisionerconfig" "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -24,20 +24,19 @@ func Test(t *testing.T) { scheme := runtime.NewScheme() require.NoError(t, toolchainv1aplha1.AddToScheme(scheme)) - cl := fake.NewClientBuilder().WithScheme(scheme).WithRuntimeObjects(spcUnderTest).Build() // use the assertions in a simple immediate call spaceprovisionerconfig.Asserting(). - Metadata.HasLabel("asdf"). - Metadata.HasName("kachny"). - Metadata.IsInNamespace("default"). - Conditions.HasConditionWithType(toolchainv1aplha1.ConditionReady). + ObjectMeta(metadata.With().Name("asdf").Label("kachny")). + Conditions(conditions.With().Type(toolchainv1aplha1.ConditionReady)). Test(t, spcUnderTest) + cl := fake.NewClientBuilder().WithScheme(scheme).WithRuntimeObjects(spcUnderTest).Build() + // this is the new WaitFor - assertions.WaitFor[*toolchainv1aplha1.SpaceProvisionerConfig](cl). - WithTimeout(1*time.Second). // defaults to wait.DefaultTimeout which is 2 minutes, so let's make it shorter here - WithObjectKey("default", "kachny"). - Matching(context.TODO(), t, - spaceprovisionerconfig.Asserting().Metadata.HasLabel("asdf")) + spaceprovisionerconfig.Asserting(). + ObjectMeta(metadata.With().Name("asdf").Namespace("default").Label("kachny")). + Conditions(conditions.With().Type(toolchainv1aplha1.ConditionReady)). + Await(cl). + Matching(context.TODO(), t) } From 55109b4681bbe949846e9434f286add0848332b2 Mon Sep 17 00:00:00 2001 From: Lukas Krejci Date: Mon, 12 May 2025 23:11:10 +0200 Subject: [PATCH 6/7] remove the gotest.tools from go.mod. Added by mistake. --- go.mod | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/go.mod b/go.mod index 2f57be082..33307d5d9 100644 --- a/go.mod +++ b/go.mod @@ -31,10 +31,7 @@ require ( sigs.k8s.io/controller-runtime v0.18.4 ) -require ( - github.com/google/uuid v1.6.0 - gotest.tools v2.2.0+incompatible -) +require github.com/google/uuid v1.6.0 require ( github.com/BurntSushi/toml v1.3.2 // indirect From 50e68f230f7e0091d30ebd586a34175876c4ff55 Mon Sep 17 00:00:00 2001 From: Lukas Krejci Date: Mon, 12 May 2025 23:11:10 +0200 Subject: [PATCH 7/7] small additions, improved naming, fixed typos --- testsupport/assertions/assertions.go | 6 +-- .../assertions/conditions/conditions.go | 22 ++++++++++ .../assertions/{lift.go => convert.go} | 44 +++++++++---------- testsupport/assertions/metadata/metadata.go | 8 ++++ testsupport/assertions/object.go | 10 +++-- testsupport/assertions/object/object.go | 8 +++- .../assertions/spaceprovisionerconfig/spc.go | 10 ++--- testsupport/assertions_use/assertions_test.go | 14 +++--- 8 files changed, 82 insertions(+), 40 deletions(-) rename testsupport/assertions/{lift.go => convert.go} (50%) diff --git a/testsupport/assertions/assertions.go b/testsupport/assertions/assertions.go index a3a490296..3538037d4 100644 --- a/testsupport/assertions/assertions.go +++ b/testsupport/assertions/assertions.go @@ -27,9 +27,9 @@ func AppendGeneric[SuperType any, Type any](assertionList Assertions[Type], asse return assertionList } -// AppendLifted is a convenience function to first lift all the assertions to the "To" type and then append them to the provided list. -func AppendLifted[From any, To any](conversion func(To) (From, bool), assertionList Assertions[To], assertions ...Assertion[From]) Assertions[To] { - return Append(assertionList, LiftAll(conversion, assertions...)...) +// AppendConverted is a convenience function to first lift all the assertions to the "To" type and then append them to the provided list. +func AppendConverted[From any, To any](conversion func(To) (From, bool), assertionList Assertions[To], assertions ...Assertion[From]) Assertions[To] { + return Append(assertionList, ConvertAll(conversion, assertions...)...) } // AppendFunc is a convenience function that is able to take in the assertions as simple functions. diff --git a/testsupport/assertions/conditions/conditions.go b/testsupport/assertions/conditions/conditions.go index 974ca92b6..104670e90 100644 --- a/testsupport/assertions/conditions/conditions.go +++ b/testsupport/assertions/conditions/conditions.go @@ -5,6 +5,7 @@ import ( "github.com/codeready-toolchain/toolchain-common/pkg/condition" "github.com/codeready-toolchain/toolchain-e2e/testsupport/assertions" "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" ) type ConditionAssertions struct { @@ -23,3 +24,24 @@ func (cas *ConditionAssertions) Type(typ toolchainv1alpha1.ConditionType) *Condi }) return cas } + +func (cas *ConditionAssertions) Status(typ toolchainv1alpha1.ConditionType, status corev1.ConditionStatus) *ConditionAssertions { + cas.Assertions = assertions.AppendFunc(cas.Assertions, func(t assertions.AssertT, conds []toolchainv1alpha1.Condition) { + t.Helper() + cond, found := condition.FindConditionByType(conds, typ) + assert.True(t, found, "didn't find a condition with the type '%v'", typ) + assert.Equal(t, status, cond.Status, "condition of type '%v' doesn't have the expected status", typ) + }) + return cas +} + +func (cas *ConditionAssertions) StatusAndReason(typ toolchainv1alpha1.ConditionType, status corev1.ConditionStatus, reason string) *ConditionAssertions { + cas.Assertions = assertions.AppendFunc(cas.Assertions, func(t assertions.AssertT, conds []toolchainv1alpha1.Condition) { + t.Helper() + cond, found := condition.FindConditionByType(conds, typ) + assert.True(t, found, "didn't find a condition with the type '%v'", typ) + assert.Equal(t, status, cond.Status, "condition of type '%v' doesn't have the expected status", typ) + assert.Equal(t, reason, cond.Reason, "condition of type '%v' doesn't have the expected reason", typ) + }) + return cas +} diff --git a/testsupport/assertions/lift.go b/testsupport/assertions/convert.go similarity index 50% rename from testsupport/assertions/lift.go rename to testsupport/assertions/convert.go index 9839d0607..6f134ed24 100644 --- a/testsupport/assertions/lift.go +++ b/testsupport/assertions/convert.go @@ -9,28 +9,28 @@ func CastAssertion[SuperType any, Type any](a Assertion[SuperType]) Assertion[Ty return cast[SuperType](o) } - return Lift(conversion, a) + return Convert(conversion, a) } -// Lift converts from one assertion type to another by converting the tested value. +// Convert converts from one assertion type to another by converting the tested value. // It respectes the ObjectNameAssertion and ObjectNamespaceAssertion so that assertions -// can still be used to identify the object after lifting. +// can still be used to identify the object after conversion. // The provided accessor can be fallible, returning false on the failure to convert the object. -func Lift[From any, To any](accessor func(To) (From, bool), assertion Assertion[From]) Assertion[To] { +func Convert[From any, To any](accessor func(To) (From, bool), assertion Assertion[From]) Assertion[To] { if _, ok := assertion.(ObjectNameAssertion); ok { - return &liftedObjectName[From, To]{liftedAssertion: liftedAssertion[From, To]{accessor: accessor, assertion: assertion}} + return &convertedObjectName[From, To]{convertedAssertion: convertedAssertion[From, To]{accessor: accessor, assertion: assertion}} } else if _, ok := assertion.(ObjectNamespaceAssertion); ok { - return &liftedObjectNamespace[From, To]{liftedAssertion: liftedAssertion[From, To]{accessor: accessor, assertion: assertion}} + return &convertedObjectNamespace[From, To]{convertedAssertion: convertedAssertion[From, To]{accessor: accessor, assertion: assertion}} } else { - return &liftedAssertion[From, To]{accessor: accessor, assertion: assertion} + return &convertedAssertion[From, To]{accessor: accessor, assertion: assertion} } } -// LiftAll performs Lift on all the provided assertions. -func LiftAll[From any, To any](accessor func(To) (From, bool), assertions ...Assertion[From]) Assertions[To] { +// ConvertAll performs Convert on all the provided assertions. +func ConvertAll[From any, To any](accessor func(To) (From, bool), assertions ...Assertion[From]) Assertions[To] { tos := make(Assertions[To], len(assertions)) for i, a := range assertions { - tos[i] = Lift(accessor, a) + tos[i] = Convert(accessor, a) } return tos } @@ -44,33 +44,33 @@ func cast[T any](obj any) (T, bool) { return ret, ok } -type liftedAssertion[From any, To any] struct { +type convertedAssertion[From any, To any] struct { assertion Assertion[From] accessor func(To) (From, bool) } -func (lon *liftedAssertion[From, To]) Test(t AssertT, obj To) { +func (ca *convertedAssertion[From, To]) Test(t AssertT, obj To) { t.Helper() - o, ok := lon.accessor(obj) + o, ok := ca.accessor(obj) if !ok { t.Errorf("invalid conversion") return } - lon.assertion.Test(t, o) + ca.assertion.Test(t, o) } -type liftedObjectName[From any, To any] struct { - liftedAssertion[From, To] +type convertedObjectName[From any, To any] struct { + convertedAssertion[From, To] } -func (lon *liftedObjectName[From, To]) Name() string { - return lon.assertion.(ObjectNameAssertion).Name() +func (con *convertedObjectName[From, To]) Name() string { + return con.assertion.(ObjectNameAssertion).Name() } -type liftedObjectNamespace[From any, To any] struct { - liftedAssertion[From, To] +type convertedObjectNamespace[From any, To any] struct { + convertedAssertion[From, To] } -func (lon *liftedObjectNamespace[From, To]) Namespace() string { - return lon.assertion.(ObjectNamespaceAssertion).Namespace() +func (con *convertedObjectNamespace[From, To]) Namespace() string { + return con.assertion.(ObjectNamespaceAssertion).Namespace() } diff --git a/testsupport/assertions/metadata/metadata.go b/testsupport/assertions/metadata/metadata.go index 8d590f6cd..a9fe1903b 100644 --- a/testsupport/assertions/metadata/metadata.go +++ b/testsupport/assertions/metadata/metadata.go @@ -57,6 +57,14 @@ func (ma *MetadataAssertions) Label(name string) *MetadataAssertions { return ma } +func (ma *MetadataAssertions) NoLabel(name string) *MetadataAssertions { + ma.Assertions = assertions.AppendFunc(ma.Assertions, func(t assertions.AssertT, obj client.Object) { + t.Helper() + assert.NotContains(t, obj.GetLabels(), name, "a label called '%s' found on the object but none expected", name) + }) + return ma +} + func (a *objectName) Test(t assertions.AssertT, obj client.Object) { t.Helper() assert.Equal(t, a.name, obj.GetName(), "object name doesn't match") diff --git a/testsupport/assertions/object.go b/testsupport/assertions/object.go index ffe9ab1a4..3cc777dfd 100644 --- a/testsupport/assertions/object.go +++ b/testsupport/assertions/object.go @@ -8,7 +8,6 @@ import ( "strings" "time" - "github.com/codeready-toolchain/toolchain-e2e/testsupport/wait" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" apierrors "k8s.io/apimachinery/pkg/api/errors" @@ -18,6 +17,11 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" ) +const ( + DefaultRetryInterval = time.Millisecond * 100 // make it short because a "retry interval" is waited before the first test + DefaultTimeout = time.Second * 120 +) + type AddressableObjectAssertions[T client.Object] struct { Assertions[T] } @@ -50,8 +54,8 @@ type errorCollectingT struct { func (oa *AddressableObjectAssertions[T]) Await(cl client.Client) *Await[T] { return &Await[T]{ cl: cl, - timeout: wait.DefaultTimeout, - tick: wait.DefaultRetryInterval, + timeout: DefaultTimeout, + tick: DefaultRetryInterval, assertions: oa.Assertions, } } diff --git a/testsupport/assertions/object/object.go b/testsupport/assertions/object/object.go index ab52e88f7..9aee1a838 100644 --- a/testsupport/assertions/object/object.go +++ b/testsupport/assertions/object/object.go @@ -6,8 +6,14 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" ) -// ObjectAssertions is a common base for assertions on client.Object subtypes. +// ObjectAssertions is a common base for assertions on client.Object subtypes +// and is meant to be embedded in other structs. +// // It provides assertions on the object metadata. +// +// It is necessary to initialize this using the SetFluentSelf method so that +// the methods defined on this struct can participate in the fluent call-chains +// defined on the struct that embeds it. type ObjectAssertions[Self any, T client.Object] struct { assertions.AddressableObjectAssertions[T] self *Self diff --git a/testsupport/assertions/spaceprovisionerconfig/spc.go b/testsupport/assertions/spaceprovisionerconfig/spc.go index 50b8f3a49..d81ab2f07 100644 --- a/testsupport/assertions/spaceprovisionerconfig/spc.go +++ b/testsupport/assertions/spaceprovisionerconfig/spc.go @@ -1,7 +1,7 @@ package spaceprovisionerconfig import ( - toolchainv1aplha1 "github.com/codeready-toolchain/api/api/v1alpha1" + toolchainv1alpha1 "github.com/codeready-toolchain/api/api/v1alpha1" "github.com/codeready-toolchain/toolchain-e2e/testsupport/assertions" "github.com/codeready-toolchain/toolchain-e2e/testsupport/assertions/conditions" "github.com/codeready-toolchain/toolchain-e2e/testsupport/assertions/object" @@ -9,7 +9,7 @@ import ( ) type SpaceProvisionerConfigAssertions struct { - object.ObjectAssertions[SpaceProvisionerConfigAssertions, *toolchainv1aplha1.SpaceProvisionerConfig] + object.ObjectAssertions[SpaceProvisionerConfigAssertions, *toolchainv1alpha1.SpaceProvisionerConfig] } func Asserting() *SpaceProvisionerConfigAssertions { @@ -19,18 +19,18 @@ func Asserting() *SpaceProvisionerConfigAssertions { } func (spc *SpaceProvisionerConfigAssertions) Conditions(cas *conditions.ConditionAssertions) *SpaceProvisionerConfigAssertions { - spc.Assertions = assertions.AppendLifted(getConditions, spc.Assertions, cas.Assertions...) + spc.Assertions = assertions.AppendConverted(getConditions, spc.Assertions, cas.Assertions...) return spc } func (spc *SpaceProvisionerConfigAssertions) ToolchainClusterName(name string) *SpaceProvisionerConfigAssertions { - spc.Assertions = assertions.AppendFunc(spc.Assertions, func(t assertions.AssertT, obj *toolchainv1aplha1.SpaceProvisionerConfig) { + spc.Assertions = assertions.AppendFunc(spc.Assertions, func(t assertions.AssertT, obj *toolchainv1alpha1.SpaceProvisionerConfig) { t.Helper() assert.Equal(t, name, obj.Spec.ToolchainCluster, "unexpected toolchainCluster") }) return spc } -func getConditions(spc *toolchainv1aplha1.SpaceProvisionerConfig) ([]toolchainv1aplha1.Condition, bool) { +func getConditions(spc *toolchainv1alpha1.SpaceProvisionerConfig) ([]toolchainv1alpha1.Condition, bool) { return spc.Status.Conditions, true } diff --git a/testsupport/assertions_use/assertions_test.go b/testsupport/assertions_use/assertions_test.go index cc9522b39..19383c7f7 100644 --- a/testsupport/assertions_use/assertions_test.go +++ b/testsupport/assertions_use/assertions_test.go @@ -3,6 +3,7 @@ package assertions_use import ( "context" "testing" + "time" toolchainv1aplha1 "github.com/codeready-toolchain/api/api/v1alpha1" "github.com/codeready-toolchain/toolchain-e2e/testsupport/assertions/conditions" @@ -17,7 +18,7 @@ import ( func Test(t *testing.T) { spcUnderTest := &toolchainv1aplha1.SpaceProvisionerConfig{ ObjectMeta: metav1.ObjectMeta{ - Name: "kachny", + Name: "myobj", Namespace: "default", }, } @@ -26,17 +27,18 @@ func Test(t *testing.T) { require.NoError(t, toolchainv1aplha1.AddToScheme(scheme)) // use the assertions in a simple immediate call - spaceprovisionerconfig.Asserting(). - ObjectMeta(metadata.With().Name("asdf").Label("kachny")). - Conditions(conditions.With().Type(toolchainv1aplha1.ConditionReady)). - Test(t, spcUnderTest) + // spaceprovisionerconfig.Asserting(). + // ObjectMeta(metadata.With().Name("asdf").Label("kachny")). + // Conditions(conditions.With().Type(toolchainv1aplha1.ConditionReady)). + // Test(t, spcUnderTest) cl := fake.NewClientBuilder().WithScheme(scheme).WithRuntimeObjects(spcUnderTest).Build() // this is the new WaitFor spaceprovisionerconfig.Asserting(). - ObjectMeta(metadata.With().Name("asdf").Namespace("default").Label("kachny")). + ObjectMeta(metadata.With().Name("myobj").Namespace("default").Label("requiredLabel")). Conditions(conditions.With().Type(toolchainv1aplha1.ConditionReady)). Await(cl). + WithTimeout(1*time.Second). Matching(context.TODO(), t) }