Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions testsupport/assertions/assertions.go
Original file line number Diff line number Diff line change
@@ -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] {
Comment on lines +4 to +15
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

using any looks to complicate the things a bit more (there is the need to cast the assertions).
I think that we can rely on the fact that this API should be used only for client.Object type - this assumption could simplify the code a bit.
Let's keep in mind that we want to improve the wait functions for k8s resources, we don't want to implement a new assertion library for any object type.

Suggested change
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] {
type Assertion[T client.Object] 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 client.Object] []Assertion[T]
// AssertionFunc converts a function into an assertion.
type AssertionFunc[T client.Object] func(t AssertT, obj T)
// Append is just an alias for Go's built-in append.
func Append[Type client.Object](assertionList Assertions[Type], assertions ...Assertion[Type]) Assertions[Type] {

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the restriction to client.Object here is completely arbitrary - the API as it is doesn't need the T to be a client.Object so we shouldn't restrict it.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wouldn't say that it's arbitrary. The API is gonna be used for resources from OpenShift clusters and all of them should implement client.Object interface. Restricting the API to this type makes the logic simpler.

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
}

// 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.
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)
}
47 changes: 47 additions & 0 deletions testsupport/assertions/conditions/conditions.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package conditions

import (
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"
corev1 "k8s.io/api/core/v1"
)

type ConditionAssertions struct {
assertions.Assertions[[]toolchainv1alpha1.Condition]
}
Comment on lines +11 to +13
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok, now I understand why you used the any - it's to support the conditions. Well, we really don't need to store that in the same list of assertions, correct? Each custom-assertion implementation needs to provide the Conditions method anyway, so it's only enabling these implementations calling the API when adding the type-specific assertion function.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Having the Assertion[T any] instead of Assertion[client.Object] also enables a generic implementation of AppendConverted[From any, To any](conversionFn func(To) (From, bool), assertions[To], assertions[From]) Assertion[To].

This function then enables a nice "traversal" in the spaceprovisionerconfig assertions:

func (spc *SpaceProvisionerConfigAssertions) Conditions(cas *conditions.ConditionAssertions) *SpaceProvisionerConfigAssertions {
	spc.Assertions = assertions.AppendConverted(getConditions, spc.Assertions, cas.Assertions...)
	return spc
}

func getConditions(spc *toolchainv1alpha1.SpaceProvisionerConfig) ([]toolchainv1alpha1.Condition, bool) {
	return spc.Status.Conditions, true
}

Of course, we could implement something similar if we had specialized assertions for conditions that wouldn't share anything with the Assertion[T client.Object] but it would be a bit arbitrary.

I like the cohesion that Assertion[T any] enables here.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As I mentioned above - we already talked about it in one of our sync calls and we agreed on focusing only on the wait logic in toolchain-e2e repo.
We don't want to create a new testing framework, we don't want to solve all the problems of the universe with the new API, we only want to reduce the code-duplication in the wait package and simplify it.
The SPC assertions were one of the first POC proposals to provide a generic API for everything, but we agreed on not expanding that as it brings additional complication and rather focus only on the wait logic instead.


func With() *ConditionAssertions {
return &ConditionAssertions{}
}

func (cas *ConditionAssertions) Type(typ toolchainv1alpha1.ConditionType) *ConditionAssertions {
cas.Assertions = assertions.AppendFunc(cas.Assertions, func(t assertions.AssertT, conds []toolchainv1alpha1.Condition) {
t.Helper()
_, found := condition.FindConditionByType(conds, typ)
assert.True(t, found, "didn't find a condition with the type '%v'", typ)
})
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
}
76 changes: 76 additions & 0 deletions testsupport/assertions/convert.go
Original file line number Diff line number Diff line change
@@ -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] {
Comment on lines +3 to +6
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Related to the other comments, by replacing any with client.Object we could technically drop all this conversion logic.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the my comment from above also applies here. The simplistic generics of Go force a lot of this "cruft", too.

This function is only used in AppendGeneric() which incidentally converts the generic assertions on the client.Object.ObjectMeta to T in ObjectAssertions.ObjectMeta(). So something like this would be needed even if we restricted T to client.Object. We could also forgo type safety (as we do with the current incarnation of the predicates used in WaitFor but that lack of type safety was actually one of the drivers for me to have another stab at the assertions.

// 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 Convert(conversion, a)
}

// Convert converts from one assertion type to another by converting the tested value.
// It respectes the ObjectNameAssertion and ObjectNamespaceAssertion so that assertions

Check failure on line 16 in testsupport/assertions/convert.go

View workflow job for this annotation

GitHub Actions / GolangCI Lint

`respectes` is a misspelling of `respects` (misspell)
// 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 Convert[From any, To any](accessor func(To) (From, bool), assertion Assertion[From]) Assertion[To] {
if _, ok := assertion.(ObjectNameAssertion); ok {
return &convertedObjectName[From, To]{convertedAssertion: convertedAssertion[From, To]{accessor: accessor, assertion: assertion}}
} else if _, ok := assertion.(ObjectNamespaceAssertion); ok {
return &convertedObjectNamespace[From, To]{convertedAssertion: convertedAssertion[From, To]{accessor: accessor, assertion: assertion}}
} else {
return &convertedAssertion[From, To]{accessor: accessor, assertion: assertion}
}
}

// 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] = Convert(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 convertedAssertion[From any, To any] struct {
assertion Assertion[From]
accessor func(To) (From, bool)
}

func (ca *convertedAssertion[From, To]) Test(t AssertT, obj To) {
t.Helper()
o, ok := ca.accessor(obj)
if !ok {
t.Errorf("invalid conversion")
return
}
ca.assertion.Test(t, o)
}

type convertedObjectName[From any, To any] struct {
convertedAssertion[From, To]
}

func (con *convertedObjectName[From, To]) Name() string {
return con.assertion.(ObjectNameAssertion).Name()
}

type convertedObjectNamespace[From any, To any] struct {
convertedAssertion[From, To]
}

func (con *convertedObjectNamespace[From, To]) Namespace() string {
return con.assertion.(ObjectNamespaceAssertion).Namespace()
}
91 changes: 91 additions & 0 deletions testsupport/assertions/metadata/metadata.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package metadata

import (
"github.com/codeready-toolchain/toolchain-e2e/testsupport/assertions"
"github.com/stretchr/testify/assert"
"sigs.k8s.io/controller-runtime/pkg/client"
)

// MetadataAssertions is a set of assertions on the metadata of any client.Object.
type MetadataAssertions struct {
assertions.Assertions[client.Object]
}

// 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{}
}

// 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
}

// 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
}

// 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.Contains(t, obj.GetLabels(), name, "no label called '%s' found on the object", name)
})
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")
}

func (a *objectName) Name() string {
return a.name
}

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)
)
Loading
Loading