/* 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 topologycache import ( "reflect" "strings" "testing" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" v1 "k8s.io/api/core/v1" discovery "k8s.io/api/discovery/v1" "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/utils/pointer" ) func TestAddHints(t *testing.T) { testCases := []struct { name string cpuRatiosByZone map[string]float64 sliceInfo *SliceInfo expectedEndpointsByAddrType map[discovery.AddressType]EndpointZoneInfo expectedSlicesToCreate []*discovery.EndpointSlice expectedSlicesToUpdate []*discovery.EndpointSlice expectedEvents []*EventBuilder }{{ name: "empty", cpuRatiosByZone: nil, sliceInfo: &SliceInfo{ ServiceKey: "ns/svc", AddressType: discovery.AddressTypeIPv4, }, expectedEndpointsByAddrType: nil, expectedSlicesToCreate: []*discovery.EndpointSlice{}, expectedSlicesToUpdate: []*discovery.EndpointSlice{}, expectedEvents: []*EventBuilder{ { EventType: v1.EventTypeWarning, Reason: "TopologyAwareHintsDisabled", Message: InsufficientNodeInfo, }, }, }, { name: "slice to create, no zone ratios", cpuRatiosByZone: nil, sliceInfo: &SliceInfo{ ServiceKey: "ns/svc", AddressType: discovery.AddressTypeIPv4, ToCreate: []*discovery.EndpointSlice{{ Endpoints: []discovery.Endpoint{{ Addresses: []string{"10.1.2.3"}, Zone: pointer.String("zone-a"), Conditions: discovery.EndpointConditions{Ready: pointer.Bool(true)}, }}, }}, }, expectedEndpointsByAddrType: nil, expectedSlicesToCreate: []*discovery.EndpointSlice{{ Endpoints: []discovery.Endpoint{{ Addresses: []string{"10.1.2.3"}, Zone: pointer.String("zone-a"), Conditions: discovery.EndpointConditions{Ready: pointer.Bool(true)}, }}, }}, expectedSlicesToUpdate: []*discovery.EndpointSlice{}, expectedEvents: []*EventBuilder{ { EventType: v1.EventTypeWarning, Reason: "TopologyAwareHintsDisabled", Message: InsufficientNodeInfo, }, }, }, { name: "slice to create with 2 endpoints, zone ratios require 3", cpuRatiosByZone: map[string]float64{ "zone-a": 0.3, "zone-b": 0.4, "zone-c": 0.3, }, sliceInfo: &SliceInfo{ ServiceKey: "ns/svc", AddressType: discovery.AddressTypeIPv4, ToCreate: []*discovery.EndpointSlice{{ Endpoints: []discovery.Endpoint{{ Addresses: []string{"10.1.2.3"}, Zone: pointer.String("zone-a"), Conditions: discovery.EndpointConditions{Ready: pointer.Bool(true)}, }, { Addresses: []string{"10.1.2.4"}, Zone: pointer.String("zone-b"), Conditions: discovery.EndpointConditions{Ready: pointer.Bool(true)}, }}, }}, }, expectedEndpointsByAddrType: nil, expectedSlicesToCreate: []*discovery.EndpointSlice{{ Endpoints: []discovery.Endpoint{{ Addresses: []string{"10.1.2.3"}, Zone: pointer.String("zone-a"), Conditions: discovery.EndpointConditions{Ready: pointer.Bool(true)}, }, { Addresses: []string{"10.1.2.4"}, Zone: pointer.String("zone-b"), Conditions: discovery.EndpointConditions{Ready: pointer.Bool(true)}, }}, }}, expectedSlicesToUpdate: []*discovery.EndpointSlice{}, expectedEvents: []*EventBuilder{ { EventType: v1.EventTypeWarning, Reason: "TopologyAwareHintsDisabled", Message: InsufficientNumberOfEndpoints, }, }, }, { name: "slice to create with 2 endpoints, zone ratios only require 2", cpuRatiosByZone: map[string]float64{ "zone-a": 0.45, "zone-b": 0.55, }, sliceInfo: &SliceInfo{ ServiceKey: "ns/svc", AddressType: discovery.AddressTypeIPv4, ToCreate: []*discovery.EndpointSlice{{ Endpoints: []discovery.Endpoint{{ Addresses: []string{"10.1.2.3"}, Zone: pointer.String("zone-a"), Conditions: discovery.EndpointConditions{Ready: pointer.Bool(true)}, }, { Addresses: []string{"10.1.2.4"}, Zone: pointer.String("zone-b"), Conditions: discovery.EndpointConditions{Ready: pointer.Bool(true)}, }}, }}, }, expectedEndpointsByAddrType: map[discovery.AddressType]EndpointZoneInfo{ discovery.AddressTypeIPv4: { "zone-a": 1, "zone-b": 1, }, }, expectedSlicesToCreate: []*discovery.EndpointSlice{{ Endpoints: []discovery.Endpoint{{ Addresses: []string{"10.1.2.3"}, Zone: pointer.String("zone-a"), Hints: &discovery.EndpointHints{ForZones: []discovery.ForZone{{Name: "zone-a"}}}, Conditions: discovery.EndpointConditions{Ready: pointer.Bool(true)}, }, { Addresses: []string{"10.1.2.4"}, Zone: pointer.String("zone-b"), Hints: &discovery.EndpointHints{ForZones: []discovery.ForZone{{Name: "zone-b"}}}, Conditions: discovery.EndpointConditions{Ready: pointer.Bool(true)}, }}, }}, expectedSlicesToUpdate: []*discovery.EndpointSlice{}, expectedEvents: []*EventBuilder{ { EventType: v1.EventTypeNormal, Reason: "TopologyAwareHintsEnabled", Message: TopologyAwareHintsEnabled, }, }, }, { name: "slice to create with 2 ready, 1 unready, 1 unknown endpoints, zone ratios only require 2", cpuRatiosByZone: map[string]float64{ "zone-a": 0.45, "zone-b": 0.55, }, sliceInfo: &SliceInfo{ ServiceKey: "ns/svc", AddressType: discovery.AddressTypeIPv4, ToCreate: []*discovery.EndpointSlice{{ Endpoints: []discovery.Endpoint{{ Addresses: []string{"10.1.2.3"}, Zone: pointer.String("zone-a"), Conditions: discovery.EndpointConditions{Ready: pointer.Bool(true)}, }, { Addresses: []string{"10.1.2.4"}, Zone: pointer.String("zone-b"), Conditions: discovery.EndpointConditions{Ready: pointer.Bool(true)}, }, { Addresses: []string{"10.1.2.5"}, Zone: pointer.String("zone-b"), Conditions: discovery.EndpointConditions{Ready: pointer.Bool(false)}, }, { Addresses: []string{"10.1.2.6"}, Zone: pointer.String("zone-b"), }}, }}, }, expectedEndpointsByAddrType: map[discovery.AddressType]EndpointZoneInfo{ discovery.AddressTypeIPv4: { "zone-a": 1, "zone-b": 1, }, }, expectedSlicesToCreate: []*discovery.EndpointSlice{{ Endpoints: []discovery.Endpoint{{ Addresses: []string{"10.1.2.3"}, Zone: pointer.String("zone-a"), Hints: &discovery.EndpointHints{ForZones: []discovery.ForZone{{Name: "zone-a"}}}, Conditions: discovery.EndpointConditions{Ready: pointer.Bool(true)}, }, { Addresses: []string{"10.1.2.4"}, Zone: pointer.String("zone-b"), Hints: &discovery.EndpointHints{ForZones: []discovery.ForZone{{Name: "zone-b"}}}, Conditions: discovery.EndpointConditions{Ready: pointer.Bool(true)}, }, { Addresses: []string{"10.1.2.5"}, Zone: pointer.String("zone-b"), Hints: nil, Conditions: discovery.EndpointConditions{Ready: pointer.Bool(false)}, }, { Addresses: []string{"10.1.2.6"}, Zone: pointer.String("zone-b"), Hints: nil, }}, }}, expectedSlicesToUpdate: []*discovery.EndpointSlice{}, expectedEvents: []*EventBuilder{ { EventType: v1.EventTypeNormal, Reason: "TopologyAwareHintsEnabled", Message: TopologyAwareHintsEnabled, }, }, }, { name: "slices to create and update within 3 zone threshold", cpuRatiosByZone: map[string]float64{ "zone-a": 0.35, "zone-b": 0.35, "zone-c": 0.30, }, sliceInfo: &SliceInfo{ ServiceKey: "ns/svc", AddressType: discovery.AddressTypeIPv4, ToCreate: []*discovery.EndpointSlice{{ Endpoints: []discovery.Endpoint{{ Addresses: []string{"10.1.2.3"}, Zone: pointer.String("zone-a"), Conditions: discovery.EndpointConditions{Ready: pointer.Bool(true)}, }, { Addresses: []string{"10.1.2.4"}, Zone: pointer.String("zone-b"), Conditions: discovery.EndpointConditions{Ready: pointer.Bool(true)}, }}, }, { Endpoints: []discovery.Endpoint{{ Addresses: []string{"10.1.3.3"}, Zone: pointer.String("zone-c"), Conditions: discovery.EndpointConditions{Ready: pointer.Bool(true)}, }, { Addresses: []string{"10.1.3.4"}, Zone: pointer.String("zone-c"), Conditions: discovery.EndpointConditions{Ready: pointer.Bool(true)}, }, { Addresses: []string{"10.1.3.4"}, Zone: pointer.String("zone-a"), Conditions: discovery.EndpointConditions{Ready: pointer.Bool(true)}, }}, }}, ToUpdate: []*discovery.EndpointSlice{{ Endpoints: []discovery.Endpoint{{ Addresses: []string{"10.2.2.3"}, Zone: pointer.String("zone-a"), Conditions: discovery.EndpointConditions{Ready: pointer.Bool(true)}, }, { Addresses: []string{"10.2.2.4"}, Zone: pointer.String("zone-a"), Conditions: discovery.EndpointConditions{Ready: pointer.Bool(true)}, }}, }, { Endpoints: []discovery.Endpoint{{ Addresses: []string{"10.2.3.3"}, Zone: pointer.String("zone-b"), Conditions: discovery.EndpointConditions{Ready: pointer.Bool(true)}, }, { Addresses: []string{"10.2.3.4"}, Zone: pointer.String("zone-c"), Conditions: discovery.EndpointConditions{Ready: pointer.Bool(true)}, }, { Addresses: []string{"10.2.3.4"}, Zone: pointer.String("zone-a"), Conditions: discovery.EndpointConditions{Ready: pointer.Bool(true)}, }}, }}, }, expectedEndpointsByAddrType: map[discovery.AddressType]EndpointZoneInfo{ discovery.AddressTypeIPv4: { "zone-a": 4, "zone-b": 3, "zone-c": 3, }, }, expectedSlicesToCreate: []*discovery.EndpointSlice{{ Endpoints: []discovery.Endpoint{{ Addresses: []string{"10.1.2.3"}, Zone: pointer.String("zone-a"), Hints: &discovery.EndpointHints{ForZones: []discovery.ForZone{{Name: "zone-b"}}}, Conditions: discovery.EndpointConditions{Ready: pointer.Bool(true)}, }, { Addresses: []string{"10.1.2.4"}, Zone: pointer.String("zone-b"), Hints: &discovery.EndpointHints{ForZones: []discovery.ForZone{{Name: "zone-b"}}}, Conditions: discovery.EndpointConditions{Ready: pointer.Bool(true)}, }}, }, { Endpoints: []discovery.Endpoint{{ Addresses: []string{"10.1.3.3"}, Zone: pointer.String("zone-c"), Hints: &discovery.EndpointHints{ForZones: []discovery.ForZone{{Name: "zone-c"}}}, Conditions: discovery.EndpointConditions{Ready: pointer.Bool(true)}, }, { Addresses: []string{"10.1.3.4"}, Zone: pointer.String("zone-c"), Hints: &discovery.EndpointHints{ForZones: []discovery.ForZone{{Name: "zone-c"}}}, Conditions: discovery.EndpointConditions{Ready: pointer.Bool(true)}, }, { Addresses: []string{"10.1.3.4"}, Zone: pointer.String("zone-a"), Hints: &discovery.EndpointHints{ForZones: []discovery.ForZone{{Name: "zone-a"}}}, Conditions: discovery.EndpointConditions{Ready: pointer.Bool(true)}, }}, }}, expectedSlicesToUpdate: []*discovery.EndpointSlice{{ Endpoints: []discovery.Endpoint{{ Addresses: []string{"10.2.2.3"}, Zone: pointer.String("zone-a"), Hints: &discovery.EndpointHints{ForZones: []discovery.ForZone{{Name: "zone-a"}}}, Conditions: discovery.EndpointConditions{Ready: pointer.Bool(true)}, }, { Addresses: []string{"10.2.2.4"}, Zone: pointer.String("zone-a"), Hints: &discovery.EndpointHints{ForZones: []discovery.ForZone{{Name: "zone-a"}}}, Conditions: discovery.EndpointConditions{Ready: pointer.Bool(true)}, }}, }, { Endpoints: []discovery.Endpoint{{ Addresses: []string{"10.2.3.3"}, Zone: pointer.String("zone-b"), Hints: &discovery.EndpointHints{ForZones: []discovery.ForZone{{Name: "zone-b"}}}, Conditions: discovery.EndpointConditions{Ready: pointer.Bool(true)}, }, { Addresses: []string{"10.2.3.4"}, Zone: pointer.String("zone-c"), Hints: &discovery.EndpointHints{ForZones: []discovery.ForZone{{Name: "zone-c"}}}, Conditions: discovery.EndpointConditions{Ready: pointer.Bool(true)}, }, { Addresses: []string{"10.2.3.4"}, Zone: pointer.String("zone-a"), Hints: &discovery.EndpointHints{ForZones: []discovery.ForZone{{Name: "zone-a"}}}, Conditions: discovery.EndpointConditions{Ready: pointer.Bool(true)}, }}, }}, expectedEvents: []*EventBuilder{ { EventType: v1.EventTypeNormal, Reason: "TopologyAwareHintsEnabled", Message: TopologyAwareHintsEnabled, }, }, }} for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { cache := NewTopologyCache() cache.cpuRatiosByZone = tc.cpuRatiosByZone slicesToCreate, slicesToUpdate, events := cache.AddHints(tc.sliceInfo) expectEquivalentSlices(t, slicesToCreate, tc.expectedSlicesToCreate) expectEquivalentSlices(t, slicesToUpdate, tc.expectedSlicesToUpdate) compareExpectedEvents(t, tc.expectedEvents, events) endpointsByAddrType, ok := cache.endpointsByService[tc.sliceInfo.ServiceKey] if tc.expectedEndpointsByAddrType == nil { if ok { t.Errorf("Expected no endpoints for Service %s, got %+v", tc.sliceInfo.ServiceKey, endpointsByAddrType) } } else { if len(tc.expectedEndpointsByAddrType) != len(endpointsByAddrType) { t.Fatalf("Expected endpoints for %d address types, got %d", len(tc.expectedEndpointsByAddrType), len(endpointsByAddrType)) } for addrType, expectedEndpointZoneInfo := range tc.expectedEndpointsByAddrType { endpointZoneInfo, ok := endpointsByAddrType[addrType] if !ok { t.Fatalf("Expected endpoints for %s address type, got none", addrType) } if len(expectedEndpointZoneInfo) != len(endpointZoneInfo) { t.Fatalf("Expected endpoints for %d zones, got %d", len(expectedEndpointZoneInfo), len(endpointZoneInfo)) } for zone, expectedNum := range expectedEndpointZoneInfo { num, ok := endpointZoneInfo[zone] if !ok { t.Fatalf("Expected endpoints for %s zone, got none", zone) } if num != expectedNum { t.Errorf("Expected %d endpoints for %s zone, got %d", expectedNum, zone, num) } } } } }) } } func TestSetNodes(t *testing.T) { type nodeInfo struct { zone string cpu resource.Quantity ready v1.ConditionStatus labels map[string]string } testCases := []struct { name string nodes []nodeInfo expectSufficientNodeInfo bool expectedCPUByZone map[string]*resource.Quantity expectedRatios map[string]float64 }{{ name: "empty", nodes: []nodeInfo{}, expectSufficientNodeInfo: false, expectedCPUByZone: nil, expectedRatios: nil, }, { name: "single node", nodes: []nodeInfo{ {zone: "zone-a", cpu: resource.MustParse("1000m"), ready: v1.ConditionTrue}, }, expectSufficientNodeInfo: false, expectedCPUByZone: nil, expectedRatios: nil, }, { name: "single zone", nodes: []nodeInfo{ {zone: "zone-a", cpu: resource.MustParse("1000m"), ready: v1.ConditionTrue}, {zone: "zone-a", cpu: resource.MustParse("1000m"), ready: v1.ConditionTrue}, }, expectSufficientNodeInfo: false, expectedCPUByZone: nil, expectedRatios: nil, }, { name: "2 zones", nodes: []nodeInfo{ {zone: "zone-a", cpu: resource.MustParse("1000m"), ready: v1.ConditionTrue}, {zone: "zone-b", cpu: resource.MustParse("1000m"), ready: v1.ConditionTrue}, }, expectSufficientNodeInfo: true, expectedCPUByZone: map[string]*resource.Quantity{ "zone-a": resource.NewQuantity(1, resource.BinarySI), "zone-b": resource.NewQuantity(1, resource.BinarySI), }, expectedRatios: map[string]float64{ "zone-a": 0.5, "zone-b": 0.5, }, }, { name: "2 zones, unready node in 1, ready node in 1", nodes: []nodeInfo{ {zone: "zone-a", cpu: resource.MustParse("1000m"), ready: v1.ConditionFalse}, {zone: "zone-b", cpu: resource.MustParse("1000m"), ready: v1.ConditionTrue}, }, expectSufficientNodeInfo: false, expectedCPUByZone: nil, expectedRatios: nil, }, { name: "2 zones, control plane node in 1, ready node in 1", nodes: []nodeInfo{ {zone: "zone-a", cpu: resource.MustParse("1000m"), ready: v1.ConditionTrue}, {zone: "zone-b", cpu: resource.MustParse("1000m"), ready: v1.ConditionTrue, labels: map[string]string{"node-role.kubernetes.io/control-plane": ""}}, }, expectSufficientNodeInfo: false, expectedCPUByZone: nil, expectedRatios: nil, }, { name: "2 zones, unready node in 1, ready node in 2", nodes: []nodeInfo{ {zone: "zone-a", cpu: resource.MustParse("1000m"), ready: v1.ConditionTrue}, {zone: "zone-b", cpu: resource.MustParse("1000m"), ready: v1.ConditionTrue}, {zone: "zone-b", cpu: resource.MustParse("1000m"), ready: v1.ConditionFalse}, }, expectSufficientNodeInfo: true, expectedCPUByZone: map[string]*resource.Quantity{ "zone-a": resource.NewQuantity(1, resource.BinarySI), "zone-b": resource.NewQuantity(1, resource.BinarySI), }, expectedRatios: map[string]float64{ "zone-a": 0.5, "zone-b": 0.5, }, }, { name: "2 zones, control plane node in 1, ready node in 2", nodes: []nodeInfo{ {zone: "zone-a", cpu: resource.MustParse("1000m"), ready: v1.ConditionTrue}, {zone: "zone-b", cpu: resource.MustParse("1000m"), ready: v1.ConditionTrue}, {zone: "zone-b", cpu: resource.MustParse("1000m"), ready: v1.ConditionTrue, labels: map[string]string{"node-role.kubernetes.io/control-plane": ""}}, }, expectSufficientNodeInfo: true, expectedCPUByZone: map[string]*resource.Quantity{ "zone-a": resource.NewQuantity(1, resource.BinarySI), "zone-b": resource.NewQuantity(1, resource.BinarySI), }, expectedRatios: map[string]float64{ "zone-a": 0.5, "zone-b": 0.5, }, }, { name: "3 zones, 4 nodes in 1, 2 nodes in 1, 1 node in 1", nodes: []nodeInfo{ {zone: "zone-a", cpu: resource.MustParse("1000m"), ready: v1.ConditionTrue}, {zone: "zone-a", cpu: resource.MustParse("1000m"), ready: v1.ConditionTrue}, {zone: "zone-a", cpu: resource.MustParse("1000m"), ready: v1.ConditionTrue}, {zone: "zone-a", cpu: resource.MustParse("2000m"), ready: v1.ConditionTrue}, {zone: "zone-b", cpu: resource.MustParse("3000m"), ready: v1.ConditionTrue}, {zone: "zone-b", cpu: resource.MustParse("1500m"), ready: v1.ConditionTrue}, {zone: "zone-c", cpu: resource.MustParse("500m"), ready: v1.ConditionTrue}, }, expectSufficientNodeInfo: true, expectedCPUByZone: map[string]*resource.Quantity{ "zone-a": resource.NewMilliQuantity(5000, resource.BinarySI), "zone-b": resource.NewMilliQuantity(4500, resource.BinarySI), "zone-c": resource.NewMilliQuantity(500, resource.BinarySI), }, expectedRatios: map[string]float64{ "zone-a": 0.5, "zone-b": 0.45, "zone-c": 0.05, }, }} for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { cache := NewTopologyCache() nodes := make([]*v1.Node, 0, len(tc.nodes)) for _, node := range tc.nodes { labels := node.labels if labels == nil { labels = map[string]string{} } if node.zone != "" { labels[v1.LabelTopologyZone] = node.zone } conditions := []v1.NodeCondition{{ Type: v1.NodeReady, Status: node.ready, }} allocatable := v1.ResourceList{ v1.ResourceCPU: node.cpu, } nodes = append(nodes, &v1.Node{ ObjectMeta: metav1.ObjectMeta{ Labels: labels, }, Status: v1.NodeStatus{ Allocatable: allocatable, Conditions: conditions, }, }) } cache.SetNodes(nodes) if cache.sufficientNodeInfo != tc.expectSufficientNodeInfo { t.Errorf("Expected sufficientNodeInfo to be %t, got %t", tc.expectSufficientNodeInfo, cache.sufficientNodeInfo) } if cache.cpuRatiosByZone == nil || tc.expectedRatios == nil { if (cache.cpuRatiosByZone == nil) != (tc.expectedRatios == nil) { t.Errorf("Expected %+v, got %+v", tc.expectedRatios, cache.cpuRatiosByZone) } } else { if len(cache.cpuRatiosByZone) != len(tc.expectedRatios) { t.Errorf("Expected ratios with %d zones, got %d", len(tc.expectedRatios), len(cache.cpuRatiosByZone)) } for zone, expectedRatio := range tc.expectedRatios { actualRatio, ok := cache.cpuRatiosByZone[zone] if !ok { t.Errorf("Expected ratio for %s zone, got none", zone) } else if actualRatio != expectedRatio { t.Errorf("Expected ratio to be %f, got %f", expectedRatio, actualRatio) } } } if cache.cpuByZone == nil || tc.expectedCPUByZone == nil { if (cache.cpuByZone == nil) != (tc.expectedCPUByZone == nil) { t.Errorf("Expected %+v, got %+v", tc.expectedCPUByZone, cache.cpuByZone) } } else { if len(cache.cpuByZone) != len(tc.expectedCPUByZone) { t.Errorf("Expected CPU with %d zones, got %d", len(tc.expectedCPUByZone), len(cache.cpuByZone)) } for zone, expectedCPU := range tc.expectedCPUByZone { actualCPU, ok := cache.cpuByZone[zone] if !ok { t.Errorf("Expected CPU for %s zone, got none", zone) } else if !actualCPU.Equal(*expectedCPU) { t.Errorf("Expected CPU to be %d, got %d", expectedCPU.MilliValue(), actualCPU.MilliValue()) } } } }) } } // Test Helpers func expectEquivalentSlices(t *testing.T, actualSlices, expectedSlices []*discovery.EndpointSlice) { t.Helper() if len(actualSlices) != len(expectedSlices) { t.Fatalf("Expected %d slices, got %d", len(expectedSlices), len(actualSlices)) } for i, expectedSlice := range expectedSlices { actualSlice := actualSlices[i] if len(expectedSlice.Endpoints) != len(actualSlice.Endpoints) { t.Errorf("Expected %d endpoints, got %d", len(expectedSlice.Endpoints), len(actualSlice.Endpoints)) continue } for j, expectedEndpoint := range expectedSlice.Endpoints { actualEndpoint := actualSlice.Endpoints[j] if !reflect.DeepEqual(actualEndpoint, expectedEndpoint) { t.Errorf("Endpoints didn't match\nExpected: %+v\nGot: %+v", expectedEndpoint, actualEndpoint) } } } } func compareExpectedEvents(t *testing.T, expectedEvents, events []*EventBuilder) { if len(expectedEvents) != len(events) { t.Errorf("Expected %d event, got %d", len(expectedEvents), len(events)) } for i, event := range events { if diff := cmp.Diff(event, expectedEvents[i], cmpopts.IgnoreFields(EventBuilder{}, "Message")); diff != "" { t.Errorf("Unexpected event (-want,+got):\n%s", diff) } if got, want := event.Message, expectedEvents[i].Message; !strings.HasPrefix(got, want) || want == "" { t.Errorf("Unexpected event message:\ngot %q want a message with %q prefix", got, want) } } }