Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ WIP: Add Kubernetes Gomega extension with to make testing controllers easier #1364

Closed
wants to merge 2 commits into from
Closed
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
58 changes: 58 additions & 0 deletions pkg/envtest/komega/interfaces.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/*
Copyright 2021 The Kubernetes Authors.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package komega

import (
"context"
"time"

"github.com/onsi/gomega"
"k8s.io/apimachinery/pkg/runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
)

// Komega is the root interface that the Matcher implements.
type Komega interface {
KomegaAsync
KomegaSync
WithContext(context.Context) Komega
}

// KomegaSync is the interface for any sync assertions that
// the matcher implements.
type KomegaSync interface {
Create(client.Object, ...client.CreateOption) gomega.GomegaAssertion
Delete(client.Object, ...client.DeleteOption) gomega.GomegaAssertion
WithExtras(...interface{}) KomegaSync
}

// KomegaAsync is the interface for any async assertions that
// the matcher implements.
type KomegaAsync interface {
Consistently(runtime.Object, ...client.ListOption) gomega.AsyncAssertion
Eventually(runtime.Object, ...client.ListOption) gomega.AsyncAssertion
Get(client.Object) gomega.AsyncAssertion
List(client.ObjectList, ...client.ListOption) gomega.AsyncAssertion
Update(client.Object, UpdateFunc, ...client.UpdateOption) gomega.AsyncAssertion
UpdateStatus(client.Object, UpdateFunc, ...client.UpdateOption) gomega.AsyncAssertion
WithTimeout(time.Duration) KomegaAsync
WithPollInterval(time.Duration) KomegaAsync
}

// UpdateFunc modifies the object fetched from the API server before sending
// the update
type UpdateFunc func(client.Object) client.Object
236 changes: 236 additions & 0 deletions pkg/envtest/komega/matcher.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
/*
Copyright 2021 The Kubernetes Authors.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package komega

import (
"context"
"time"

"github.com/onsi/gomega"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client"
)

// Matcher has Gomega Matchers that use the controller-runtime client.
type Matcher struct {
Copy link
Member

Choose a reason for hiding this comment

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

I think Matcher should be unexported here, and the initialized struct should be returned as a non-pointer value from a builder method. With a repetitive and parallel nature of tests, it will be good to always know you work with unique komega interface using exact client, extras or timeout.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hmm that's a good point, at the moment we are using all pointer receivers, which means we will affect the parent. Perhaps we need to stop doing that and then whenever you call one of the builder style methods, it would not affect the parent it's being called upon, means you could compose different settings for each call if need be

I expect people will create a new matcher for each test anyway (either in a BeforeEach in Ginkgo or at the start of the test in normal test suites), a NewMatcher method might make this cleaner though, good suggestion

Client client.Client
ctx context.Context
extras []interface{}
timeout time.Duration
pollInterval time.Duration
}

// WithContext sets the context to be used for the underlying client
// during assertions.
func (m *Matcher) WithContext(ctx context.Context) Komega {
m.ctx = ctx
return m
}

// context returns the matcher context if one has been set.
// Else it returns the context.TODO().
func (m *Matcher) context() context.Context {
if m.ctx == nil {
return context.TODO()
}
return m.ctx
}

// WithExtras sets extra arguments for sync assertions.
// Any extras passed will be expected to be nil during assertion.
func (m *Matcher) WithExtras(extras ...interface{}) KomegaSync {
m.extras = extras
return m
}

// WithTimeout sets the timeout for any async assertions.
func (m *Matcher) WithTimeout(timeout time.Duration) KomegaAsync {
m.timeout = timeout
return m
}

// WithPollInterval sets the poll interval for any async assertions.
// Note: This will only work if an explicit timeout has been set with WithTimeout.
func (m *Matcher) WithPollInterval(pollInterval time.Duration) KomegaAsync {
m.pollInterval = pollInterval
return m
}

// intervals constructs the intervals for async assertions.
// If no timeout is set, the list will be empty.
func (m *Matcher) intervals() []interface{} {
if m.timeout == 0 {
return []interface{}{}
}
out := []interface{}{m.timeout}
if m.pollInterval != 0 {
out = append(out, m.pollInterval)
}
return out
}

// Create creates the object on the API server.
func (m *Matcher) Create(obj client.Object, opts ...client.CreateOption) gomega.GomegaAssertion {
err := m.Client.Create(m.context(), obj, opts...)
return gomega.Expect(err, m.extras...)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Currently, these will only work with the default package level gomega assertions, should make the option of passing in a custom gomega.WithT as well to this can be used in normal go testing tests

}

// Delete deletes the object from the API server.
func (m *Matcher) Delete(obj client.Object, opts ...client.DeleteOption) gomega.GomegaAssertion {
err := m.Client.Delete(m.context(), obj, opts...)
return gomega.Expect(err, m.extras...)
}

// Update udpates the object on the API server by fetching the object
// and applying a mutating UpdateFunc before sending the update.
func (m *Matcher) Update(obj client.Object, fn UpdateFunc, opts ...client.UpdateOption) gomega.GomegaAsyncAssertion {
key := types.NamespacedName{
Name: obj.GetName(),
Namespace: obj.GetNamespace(),
}
update := func() error {
err := m.Client.Get(m.context(), key, obj)
if err != nil {
return err
}
return m.Client.Update(m.context(), fn(obj), opts...)
}
return gomega.Eventually(update, m.intervals()...)
}

// UpdateStatus udpates the object's status subresource on the API server by
// fetching the object and applying a mutating UpdateFunc before sending the
// update.
func (m *Matcher) UpdateStatus(obj client.Object, fn UpdateFunc, opts ...client.UpdateOption) gomega.GomegaAsyncAssertion {
key := types.NamespacedName{
Name: obj.GetName(),
Namespace: obj.GetNamespace(),
}
update := func() error {
err := m.Client.Get(m.context(), key, obj)
if err != nil {
return err
}
return m.Client.Status().Update(m.context(), fn(obj), opts...)
}
return gomega.Eventually(update, m.intervals()...)
}

// Get gets the object from the API server.
func (m *Matcher) Get(obj client.Object) gomega.GomegaAsyncAssertion {
key := types.NamespacedName{
Name: obj.GetName(),
Namespace: obj.GetNamespace(),
}
get := func() error {
return m.Client.Get(m.context(), key, obj)
}
return gomega.Eventually(get, m.intervals()...)
}

// List gets the list object from the API server.
func (m *Matcher) List(obj client.ObjectList, opts ...client.ListOption) gomega.GomegaAsyncAssertion {
list := func() error {
return m.Client.List(m.context(), obj, opts...)
}
return gomega.Eventually(list, m.intervals()...)
}

// Consistently continually gets the object from the API for comparison.
// It can be used to check for either List types or regular Objects.
func (m *Matcher) Consistently(obj runtime.Object, opts ...client.ListOption) gomega.GomegaAsyncAssertion {
// If the object is a list, return a list
if o, ok := obj.(client.ObjectList); ok {
return m.consistentlyList(o, opts...)
}
if o, ok := obj.(client.Object); ok {
return m.consistentlyObject(o)
}
//Should not get here
panic("Unknown object.")
}

// consistentlyclient.Object gets an individual object from the API server.
func (m *Matcher) consistentlyObject(obj client.Object) gomega.GomegaAsyncAssertion {
key := types.NamespacedName{
Name: obj.GetName(),
Namespace: obj.GetNamespace(),
}
get := func() client.Object {
err := m.Client.Get(m.context(), key, obj)
if err != nil {
panic(err)
}
return obj
}
return gomega.Consistently(get, m.intervals()...)
}

// consistentlyList gets an list of objects from the API server.
func (m *Matcher) consistentlyList(obj client.ObjectList, opts ...client.ListOption) gomega.GomegaAsyncAssertion {
list := func() client.ObjectList {
err := m.Client.List(m.context(), obj, opts...)
if err != nil {
panic(err)
}
return obj
}
return gomega.Consistently(list, m.intervals()...)
}

// Eventually continually gets the object from the API for comparison.
// It can be used to check for either List types or regular Objects.
func (m *Matcher) Eventually(obj runtime.Object, opts ...client.ListOption) gomega.GomegaAsyncAssertion {
// If the object is a list, return a list
if o, ok := obj.(client.ObjectList); ok {
return m.eventuallyList(o, opts...)
}
if o, ok := obj.(client.Object); ok {
return m.eventuallyObject(o)
}
//Should not get here
panic("Unknown object.")
}

// eventuallyObject gets an individual object from the API server.
func (m *Matcher) eventuallyObject(obj client.Object) gomega.GomegaAsyncAssertion {
key := types.NamespacedName{
Name: obj.GetName(),
Namespace: obj.GetNamespace(),
}
get := func() client.Object {
err := m.Client.Get(m.context(), key, obj)
if err != nil {
panic(err)
}
return obj
}
return gomega.Eventually(get, m.intervals()...)
}

// eventuallyList gets a list type from the API server.
func (m *Matcher) eventuallyList(obj client.ObjectList, opts ...client.ListOption) gomega.GomegaAsyncAssertion {
list := func() client.ObjectList {
err := m.Client.List(m.context(), obj, opts...)
if err != nil {
panic(err)
}
return obj
}
return gomega.Eventually(list, m.intervals()...)
}
52 changes: 52 additions & 0 deletions pkg/envtest/komega/transforms.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/*
Copyright 2021 The Kubernetes Authors.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package komega

import (
"fmt"
"reflect"
"strings"

"github.com/onsi/gomega"
gtypes "github.com/onsi/gomega/types"
)

// WithField gets the value of the named field from the object.
// This is intended to be used in assertions with the Matcher make it easy
// to check the value of a particular field in a resource.
// To access nested fields uses a `.` separator.
// Eg.
// m.Eventually(deployment).Should(WithField("spec.replicas", BeZero()))
// To access nested lists, use one of the Gomega list matchers in conjunction with this.
// Eg.
// m.Eventually(deploymentList).Should(WithField("items", ConsistOf(...)))
func WithField(field string, matcher gtypes.GomegaMatcher) gtypes.GomegaMatcher {
// Addressing Field by <struct>.<field> can be recursed
fields := strings.SplitN(field, ".", 2)
if len(fields) == 2 {
matcher = WithField(fields[1], matcher)
}

return gomega.WithTransform(func(obj interface{}) interface{} {
r := reflect.ValueOf(obj)
f := reflect.Indirect(r).FieldByName(fields[0])
if !f.IsValid() {
panic(fmt.Sprintf("Object '%s' does not have a field '%s'", reflect.TypeOf(obj), fields[0]))
}
return f.Interface()
}, matcher)
}