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 }