knative.dev/pkg@v0.0.0-20260602142205-ac97e43f6622/reconciler/testing/table.go (about)

     1  /*
     2  Copyright 2019 The Knative Authors.
     3  
     4  Licensed under the Apache License, Version 2.0 (the "License");
     5  you may not use this file except in compliance with the License.
     6  You may obtain a copy of the License at
     7  
     8      http://www.apache.org/licenses/LICENSE-2.0
     9  
    10  Unless required by applicable law or agreed to in writing, software
    11  distributed under the License is distributed on an "AS IS" BASIS,
    12  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    13  See the License for the specific language governing permissions and
    14  limitations under the License.
    15  */
    16  
    17  package testing
    18  
    19  import (
    20  	"context"
    21  	"path"
    22  	"reflect"
    23  	"slices"
    24  	"strings"
    25  	"testing"
    26  
    27  	"github.com/google/go-cmp/cmp"
    28  	"github.com/google/go-cmp/cmp/cmpopts"
    29  	"go.uber.org/zap"
    30  
    31  	"k8s.io/apimachinery/pkg/api/resource"
    32  	"k8s.io/apimachinery/pkg/runtime"
    33  	"k8s.io/apimachinery/pkg/util/sets"
    34  	clientgotesting "k8s.io/client-go/testing"
    35  	"k8s.io/client-go/tools/cache"
    36  
    37  	"knative.dev/pkg/controller"
    38  	"knative.dev/pkg/kmeta"
    39  	"knative.dev/pkg/logging"
    40  	"knative.dev/pkg/logging/logkey"
    41  	_ "knative.dev/pkg/system/testing" // Setup system.Namespace()
    42  )
    43  
    44  // TableRow holds a single row of our table test.
    45  type TableRow struct {
    46  	// Name is a descriptive name for this test suitable as a first argument to t.Run()
    47  	Name string
    48  
    49  	// Ctx is the context to pass to Reconcile. Defaults to context.Background()
    50  	Ctx context.Context
    51  
    52  	// Objects holds the state of the world at the onset of reconciliation.
    53  	Objects []runtime.Object
    54  
    55  	// Key is the parameter to reconciliation.
    56  	// This has the form "namespace/name".
    57  	Key string
    58  
    59  	// WantErr holds whether we should expect the reconciliation to result in an error.
    60  	WantErr bool
    61  
    62  	// WantCreates holds the ordered list of Create calls we expect during reconciliation.
    63  	WantCreates []runtime.Object
    64  
    65  	// WantUpdates holds the ordered list of Update calls we expect during reconciliation.
    66  	WantUpdates []clientgotesting.UpdateActionImpl
    67  
    68  	// WantStatusUpdates holds the ordered list of Update calls, with `status` subresource set,
    69  	// that we expect during reconciliation.
    70  	WantStatusUpdates []clientgotesting.UpdateActionImpl
    71  
    72  	// WantDeletes holds the ordered list of Delete calls we expect during reconciliation.
    73  	WantDeletes []clientgotesting.DeleteActionImpl
    74  
    75  	// WantDeleteCollections holds the ordered list of DeleteCollection calls we expect during reconciliation.
    76  	WantDeleteCollections []clientgotesting.DeleteCollectionActionImpl
    77  
    78  	// WantPatches holds the ordered list of Patch calls we expect during reconciliation.
    79  	WantPatches []clientgotesting.PatchActionImpl
    80  
    81  	// WantEvents holds the ordered list of events we expect during reconciliation.
    82  	WantEvents []string
    83  
    84  	// WithReactors is a set of functions that are installed as Reactors for the execution
    85  	// of this row of the table-driven-test.
    86  	WithReactors []clientgotesting.ReactionFunc
    87  
    88  	// For cluster-scoped resources like ClusterIngress, it does not have to be
    89  	// in the same namespace with its child resources.
    90  	SkipNamespaceValidation bool
    91  
    92  	// PostConditions allows custom assertions to be made after reconciliation
    93  	PostConditions []func(*testing.T, *TableRow)
    94  
    95  	// Reconciler holds the controller.Reconciler that was used to evaluate this row.
    96  	// It is populated here to make it accessible to PostConditions.
    97  	Reconciler controller.Reconciler
    98  
    99  	// OtherTestData is arbitrary data needed for the test. It is not used directly by the table
   100  	// testing framework. Instead it is used in the test method. E.g. setting up the responses for a
   101  	// mock client can go in here.
   102  	OtherTestData map[string]interface{}
   103  
   104  	CmpOpts []cmp.Option
   105  }
   106  
   107  var (
   108  	ignoreLastTransitionTime = cmp.FilterPath(func(p cmp.Path) bool {
   109  		return strings.HasSuffix(p.String(), "LastTransitionTime.Inner.Time")
   110  	}, cmp.Ignore())
   111  
   112  	ignoreQuantity = cmpopts.IgnoreUnexported(resource.Quantity{})
   113  	defaultCmpOpts = []cmp.Option{ignoreLastTransitionTime, ignoreQuantity, cmpopts.EquateEmpty()}
   114  )
   115  
   116  func objKey(o runtime.Object) string {
   117  	on := o.(kmeta.Accessor)
   118  
   119  	var typeOf string
   120  	if gvk := on.GroupVersionKind(); gvk.Group != "" {
   121  		// This must be populated if we're dealing with unstructured.Unstructured.
   122  		typeOf = gvk.String()
   123  	} else if or, ok := on.(kmeta.OwnerRefable); ok {
   124  		// This is typically implemented by Knative resources.
   125  		typeOf = or.GetGroupVersionKind().String()
   126  	} else {
   127  		// Worst case, fallback on a non-GVK string.
   128  		typeOf = reflect.TypeOf(o).String()
   129  	}
   130  
   131  	// namespace + name is not unique, and the tests don't populate k8s kind
   132  	// information, so use GoLang's type name as part of the key.
   133  	return path.Join(typeOf, on.GetNamespace(), on.GetName())
   134  }
   135  
   136  // Factory returns a Reconciler.Interface to perform reconciliation in table test, and
   137  // ActionRecorderList/EventList to capture k8s actions/events produced during reconciliation.
   138  type Factory func(*testing.T, *TableRow) (controller.Reconciler, ActionRecorderList, EventList)
   139  
   140  // Test executes the single table test.
   141  func (r *TableRow) Test(t *testing.T, factory Factory) {
   142  	t.Helper()
   143  	c, recorderList, eventList := factory(t, r)
   144  
   145  	// Set the Reconciler for PostConditions to access it post-Reconcile()
   146  	r.Reconciler = c
   147  
   148  	// Set context to not be nil.
   149  	ctx := r.Ctx
   150  	if ctx == nil {
   151  		ctx = context.Background()
   152  	} else {
   153  		// If we have logger setup on the context, decorate it with the key, so that the logs
   154  		// look like in prod.
   155  		l := logging.FromContext(ctx)
   156  		l = l.With(zap.String(logkey.Key, r.Key))
   157  		ctx = logging.WithLogger(ctx, l)
   158  	}
   159  
   160  	// Run the Reconcile we're testing.
   161  	if err := c.Reconcile(ctx, r.Key); (err != nil) != r.WantErr {
   162  		t.Errorf("Reconcile() error = %v, WantErr %v", err, r.WantErr)
   163  	}
   164  
   165  	expectedNamespace, _, _ := cache.SplitMetaNamespaceKey(r.Key)
   166  
   167  	actions, err := recorderList.ActionsByVerb()
   168  	if err != nil {
   169  		t.Errorf("Error capturing actions by verb: %q", err)
   170  	}
   171  
   172  	effectiveOpts := slices.Concat(r.CmpOpts, defaultCmpOpts)
   173  
   174  	// Previous state is used to diff resource expected state for update requests that were missed.
   175  	objPrevState := make(map[string]runtime.Object, len(r.Objects))
   176  	for _, o := range r.Objects {
   177  		objPrevState[objKey(o)] = o
   178  	}
   179  
   180  	for i, want := range r.WantCreates {
   181  		if i >= len(actions.Creates) {
   182  			t.Errorf("Missing create: %#v", want)
   183  			continue
   184  		}
   185  		got := actions.Creates[i]
   186  		obj := got.GetObject()
   187  		objPrevState[objKey(obj)] = obj
   188  
   189  		if !r.SkipNamespaceValidation && got.GetNamespace() != expectedNamespace {
   190  			t.Errorf("Unexpected action[%d]: %#v", i, got)
   191  		}
   192  
   193  		if !cmp.Equal(want, obj, effectiveOpts...) {
   194  			t.Errorf("Unexpected create (-want, +got):\n%s",
   195  				cmp.Diff(want, obj, effectiveOpts...))
   196  		}
   197  	}
   198  	if got, want := len(actions.Creates), len(r.WantCreates); got > want {
   199  		for _, extra := range actions.Creates[want:] {
   200  			t.Errorf("Extra create: %#v", extra.GetObject())
   201  		}
   202  	}
   203  
   204  	updates := filterUpdatesWithSubresource("", actions.Updates)
   205  	for i, want := range r.WantUpdates {
   206  		if i >= len(updates) {
   207  			wo := want.GetObject()
   208  			key := objKey(wo)
   209  			oldObj, ok := objPrevState[key]
   210  			if !ok {
   211  				t.Errorf("Object %s was never created: want: %#v", key, wo)
   212  				continue
   213  			}
   214  			t.Errorf("Missing update for %s (-want, +prevState):\n%s", key,
   215  				cmp.Diff(wo, oldObj, effectiveOpts...))
   216  			continue
   217  		}
   218  
   219  		if want.GetSubresource() != "" {
   220  			t.Errorf("Expectation was invalid - it should not include a subresource: %#v", want)
   221  		}
   222  
   223  		got := updates[i].GetObject()
   224  
   225  		// Update the object state.
   226  		objPrevState[objKey(got)] = got
   227  
   228  		if !cmp.Equal(want.GetObject(), got, effectiveOpts...) {
   229  			t.Errorf("Unexpected update (-want, +got):\n%s",
   230  				cmp.Diff(want.GetObject(), got, effectiveOpts...))
   231  		}
   232  	}
   233  	if got, want := len(updates), len(r.WantUpdates); got > want {
   234  		for _, extra := range updates[want:] {
   235  			t.Errorf("Extra update: %#v", extra.GetObject())
   236  		}
   237  	}
   238  
   239  	// TODO(#2843): refactor.
   240  	statusUpdates := filterUpdatesWithSubresource("status", actions.Updates)
   241  	for i, want := range r.WantStatusUpdates {
   242  		if i >= len(statusUpdates) {
   243  			wo := want.GetObject()
   244  			key := objKey(wo)
   245  			oldObj, ok := objPrevState[key]
   246  			if !ok {
   247  				t.Errorf("Object %s was never created: want: %#v", key, wo)
   248  				continue
   249  			}
   250  			t.Errorf("Missing status update for %s (-want, +prevState):\n%s", key,
   251  				cmp.Diff(wo, oldObj, effectiveOpts...))
   252  			continue
   253  		}
   254  
   255  		got := statusUpdates[i].GetObject()
   256  
   257  		// Update the object state.
   258  		objPrevState[objKey(got)] = got
   259  
   260  		if !cmp.Equal(want.GetObject(), got, effectiveOpts...) {
   261  			t.Errorf("Unexpected status update (-want, +got):\n%s\nFull: %v",
   262  				cmp.Diff(want.GetObject(), got, effectiveOpts...), got)
   263  		}
   264  	}
   265  	if got, want := len(statusUpdates), len(r.WantStatusUpdates); got > want {
   266  		for _, extra := range statusUpdates[want:] {
   267  			wo := extra.GetObject()
   268  			key := objKey(wo)
   269  			oldObj, ok := objPrevState[key]
   270  			if !ok {
   271  				t.Errorf("Object %s was never created: want: %#v", key, wo)
   272  				continue
   273  			}
   274  			t.Errorf("Extra status update for %s (-extra, +prevState):\n%s", key,
   275  				cmp.Diff(wo, oldObj, effectiveOpts...))
   276  		}
   277  	}
   278  
   279  	if len(statusUpdates)+len(updates) != len(actions.Updates) {
   280  		var unexpected []runtime.Object
   281  
   282  		for _, update := range actions.Updates {
   283  			if update.GetSubresource() != "status" && update.GetSubresource() != "" {
   284  				unexpected = append(unexpected, update.GetObject())
   285  			}
   286  		}
   287  
   288  		t.Errorf("Unexpected subresource updates occurred %#v", unexpected)
   289  	}
   290  
   291  	// Build a set of unique strings that represent type-name{-namespace}.
   292  	// Adding type will help catch the bugs where several similarly named
   293  	// resources are deleted (and some should or should not).
   294  	gotDeletes := make(sets.Set[string], len(actions.Deletes))
   295  	for _, w := range actions.Deletes {
   296  		n := w.GetResource().Resource + "~~" + w.GetName()
   297  		if !r.SkipNamespaceValidation {
   298  			n += "~~" + w.GetNamespace()
   299  		}
   300  		gotDeletes.Insert(n)
   301  	}
   302  	wantDeletes := make(sets.Set[string], len(actions.Deletes))
   303  	for _, w := range r.WantDeletes {
   304  		n := w.GetResource().Resource + "~~" + w.GetName()
   305  		if !r.SkipNamespaceValidation {
   306  			n += "~~" + w.GetNamespace()
   307  		}
   308  		wantDeletes.Insert(n)
   309  	}
   310  	if !gotDeletes.Equal(wantDeletes) {
   311  		if extra := gotDeletes.Difference(wantDeletes); len(extra) > 0 {
   312  			t.Error("Extra or unexpected deletes:", extra.UnsortedList())
   313  		}
   314  		if missing := wantDeletes.Difference(gotDeletes); len(missing) > 0 {
   315  			t.Error("Missing deletes:", missing.UnsortedList())
   316  		}
   317  	}
   318  
   319  	for i, want := range r.WantPatches {
   320  		if i >= len(actions.Patches) {
   321  			t.Errorf("Missing patch: %#v; raw: %s", want, string(want.GetPatch()))
   322  			continue
   323  		}
   324  
   325  		got := actions.Patches[i]
   326  		if got.GetName() != want.GetName() {
   327  			t.Errorf("Unexpected patch[%d]: %#v", i, got)
   328  		}
   329  		if (!r.SkipNamespaceValidation && got.GetNamespace() != expectedNamespace) &&
   330  			(!r.SkipNamespaceValidation && got.GetResource().GroupResource().Resource != "namespaces" &&
   331  				got.GetName() != expectedNamespace) {
   332  			t.Errorf("Unexpected patch[%d]: %#v", i, got)
   333  		}
   334  		if got, want := string(got.GetPatch()), string(want.GetPatch()); got != want {
   335  			t.Errorf("Unexpected patch(-want, +got):\n%s", cmp.Diff(want, got))
   336  		}
   337  	}
   338  	if got, want := len(actions.Patches), len(r.WantPatches); got > want {
   339  		for _, extra := range actions.Patches[want:] {
   340  			t.Errorf("Extra patch: %#v; raw: %s", extra, string(extra.GetPatch()))
   341  		}
   342  	}
   343  
   344  	gotEvents := eventList.Events()
   345  	for i, want := range r.WantEvents {
   346  		if i >= len(gotEvents) {
   347  			t.Error("Missing event:", want)
   348  			continue
   349  		}
   350  
   351  		if !cmp.Equal(want, gotEvents[i]) {
   352  			t.Errorf("Unexpected event(-want, +got):\n%s", cmp.Diff(want, gotEvents[i]))
   353  		}
   354  	}
   355  	if got, want := len(gotEvents), len(r.WantEvents); got > want {
   356  		for _, extra := range gotEvents[want:] {
   357  			t.Error("Extra event:", extra)
   358  		}
   359  	}
   360  
   361  	for _, verify := range r.PostConditions {
   362  		verify(t, r)
   363  	}
   364  }
   365  
   366  func filterUpdatesWithSubresource(
   367  	subresource string,
   368  	actions []clientgotesting.UpdateAction,
   369  ) (result []clientgotesting.UpdateAction) {
   370  	for _, action := range actions {
   371  		if action.GetSubresource() == subresource {
   372  			result = append(result, action)
   373  		}
   374  	}
   375  	return result
   376  }
   377  
   378  // TableTest represents a list of TableRow tests instances.
   379  type TableTest []TableRow
   380  
   381  // Test executes the whole suite of the table tests.
   382  func (tt TableTest) Test(t *testing.T, factory Factory) {
   383  	t.Helper()
   384  	for _, test := range tt {
   385  		// Record the original objects in table.
   386  		originObjects := make([]runtime.Object, len(test.Objects))
   387  		for i, obj := range test.Objects {
   388  			originObjects[i] = obj.DeepCopyObject()
   389  		}
   390  		t.Run(test.Name, func(t *testing.T) {
   391  			t.Helper()
   392  			test.Test(t, factory)
   393  			opts := make([]cmp.Option, 0, len(defaultCmpOpts)+len(test.CmpOpts))
   394  			opts = append(opts, defaultCmpOpts...)
   395  			opts = append(opts, test.CmpOpts...)
   396  			// Validate cached objects do not get soiled after controller loops.
   397  			if !cmp.Equal(originObjects, test.Objects, opts...) {
   398  				t.Errorf("Unexpected objects (-want, +got):\n%s",
   399  					cmp.Diff(originObjects, test.Objects, opts...))
   400  			}
   401  		})
   402  	}
   403  }