Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
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
49 changes: 34 additions & 15 deletions testsupport/assertions/assertion.go
Original file line number Diff line number Diff line change
@@ -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]
Expand All @@ -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
}
Expand All @@ -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)
}
27 changes: 20 additions & 7 deletions testsupport/assertions/conditions/conditions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
13 changes: 13 additions & 0 deletions testsupport/assertions/deepcopy.go
Original file line number Diff line number Diff line change
@@ -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)
}
60 changes: 60 additions & 0 deletions testsupport/assertions/fix.go
Original file line number Diff line number Diff line change
@@ -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
}
59 changes: 47 additions & 12 deletions testsupport/assertions/object/object.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Comment on lines +28 to +32

Copy link
Copy Markdown
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 comment, the MetadataAssertions should be common for any kind/resource we execute the logic for. I believe that including it into the fluent-api chain of methods would be better for an easy use and reference:

Suggested change
// 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
}
func (oa *ObjectAssertions[Self, T]) Label(name string) *Self {
oa.Assertions = assertions.AppendFunc(oa.Assertions, func(t assertions.AssertT, obj T) {
t.Helper()
assert.Contains(t, obj.GetLabels(), name, "no label called '%s' found on the object", name)
})
return oa.self
}
func (oa *ObjectAssertions[Self, T]) NoLabel(name string) *Self {
oa.Assertions = assertions.AppendFunc(oa.Assertions, func(t assertions.AssertT, obj T) {
t.Helper()
assert.NotContains(t, obj.GetLabels(), name, "a label called '%s' found on the object but none expected", name)
})
return oa.self
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

See my comment above - I'm personally like the "down and right" approach better because it can visually separate different parts of the object definition similarly to how the object definition looks like when you initialize a struct.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I understand your "down and right" point, but it's all just a criteria for the resource, it doesn't matter if it's part of metadata, spec, status, data or any other sub-section of any of the before-mentioned ones.
It's also pretty clear what they represent from the name, there is no confusion

14 changes: 10 additions & 4 deletions testsupport/assertions/spaceprovisionerconfig/spc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
},
Comment thread
metlos marked this conversation as resolved.
Outdated
})
return a
}
Expand Down
28 changes: 28 additions & 0 deletions testsupport/assertions/t.go
Original file line number Diff line number Diff line change
@@ -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...)
}
Loading