Chapter 04

Kubernetesオブジェクトの扱い方

zoetro
zoetro
2022.08.22に更新

Kubernetesオブジェクト

DeploymentやStatefulSetなど、Kubernetesオブジェクトの構造体は非常に大きくて複雑です。
そのため、Assertionやオブジェクトの生成処理も複雑になりがちです。

ではここで、よいテストコードとはどのようなものか考えてみましょう。

  • テストが失敗したときに原因や該当箇所が分かりやすいこと
  • テストコードには、テストを理解するために必要なことがすべて書かれていて、かつ不要な情報はなるべく含まれていないこと
  • テストが書きやすいこと(テストを書くために多くの知識を必要としない、型によるチェックがおこなわれる、など)

しかし、大きくて複雑なKubernetesオブジェクトを扱う場合は、これらを実現することが難しくなります。
例えば、Kubernetesオブジェクトを単純にEqual Matcherで比較すると、差分がどこにあるのか分かりにくくなります。
また、Kubernetesオブジェクトを生成する処理が長くなると、何のためのテストを書いているのか分かりにくくなったりもします。

本章では、テストコード内でKubernetesオブジェクトを生成したり、Assertionする際のテクニックを紹介したいと思います。

Kubernetesオブジェクトの作成

通常の作成方法

まずは、Kubernetesオブジェクトの構造体を普通に初期化する方法です。

package controllers

import (
	appsv1 "k8s.io/api/apps/v1"
	corev1 "k8s.io/api/core/v1"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/utils/pointer"
)

func createDeployment() *appsv1.Deployment {
	return &appsv1.Deployment{
		ObjectMeta: metav1.ObjectMeta{
			Namespace: "default",
			Name:      "nginx-deployment",
			Labels: map[string]string{
				"app": "nginx",
			},
		},
		Spec: appsv1.DeploymentSpec{
			Replicas: pointer.Int32(3),
			Selector: &metav1.LabelSelector{
				MatchLabels: map[string]string{
					"app": "nginx",
				},
			},
			Template: corev1.PodTemplateSpec{
				ObjectMeta: metav1.ObjectMeta{
					Labels: map[string]string{
						"app": "nginx",
					},
				},
				Spec: corev1.PodSpec{
					Containers: []corev1.Container{
						{
							Name:  "nginx",
							Image: "nginx:latest",
							Ports: []corev1.ContainerPort{
								{
									ContainerPort: 80,
								},
							},
						},
					},
				},
			},
		},
	}
}

Deploymentの初期化には最低でも上記のような設定が必要になります。
他のフィールドも設定すると、上記の何倍もの長さになるでしょう。

用意するオブジェクトの数が少なければ問題ないのですが、テストを書く際には内容がほとんど同じだけど少しだけ違うオブジェクトをたくさん用意しなければなりません。
そのような記述がたくさんあるとテストコードの見通しが悪くなってしまいます。

ビルダーパターン

似たようなオブジェクトをたくさん作るためには、ビルダーパターンと呼ばれるデザインパターンが役立ちます。

Deploymentオブジェクトを生成するためのビルダーを以下のように作成してみましょう。

package controllers

import (
	appsv1 "k8s.io/api/apps/v1"
	corev1 "k8s.io/api/core/v1"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

type deploymentBuilder struct {
	object *appsv1.Deployment
}

func newDeployment(namespace, name string) *deploymentBuilder {
	return &deploymentBuilder{
		object: &appsv1.Deployment{
			ObjectMeta: metav1.ObjectMeta{
				Namespace: namespace,
				Name:      name,
			},
		},
	}
}

func (b *deploymentBuilder) withLabels(labels map[string]string) *deploymentBuilder {
	if b.object.Labels == nil {
		b.object.Labels = map[string]string{}
	}

	for key, value := range labels {
		b.object.Labels[key] = value
	}
	return b
}

func (b *deploymentBuilder) withReplicas(replicas int32) *deploymentBuilder {
	b.object.Spec.Replicas = &replicas
	return b
}

func (b *deploymentBuilder) withNginxContainer(image string) *deploymentBuilder {
	b.object.Spec.Selector = &metav1.LabelSelector{
		MatchLabels: map[string]string{
			"app": "nginx",
		},
	}
	b.object.Spec.Template.Labels = map[string]string{
		"app": "nginx",
	}
	b.object.Spec.Template.Spec.Containers =
		append(b.object.Spec.Template.Spec.Containers,
			corev1.Container{
				Name:  "nginx",
				Image: image,
				Ports: []corev1.ContainerPort{
					{
						ContainerPort: 80,
					},
				},
			})
	return b
}

func (b *deploymentBuilder) withSidecarContainer(image string) *deploymentBuilder {
	b.object.Spec.Template.Spec.Containers =
		append(b.object.Spec.Template.Spec.Containers,
			corev1.Container{
				Name:  "sidecar",
				Image: image,
			})
	return b
}

func (b *deploymentBuilder) build() *appsv1.Deployment {
	return b.object
}

テストコードでは、ビルダーを利用して以下のようにDeploymentオブジェクトを生成します。

package controllers

import (
	. "github.com/onsi/ginkgo/v2"
	. "github.com/onsi/gomega"
)

var _ = Describe("Builder", func() {
	It("should equal", func() {
		dep1 := createDeployment("nginx:latest")

		dep2 := newDeployment("default", "nginx-deployment").
			withLabels(map[string]string{"app": "nginx"}).
			withReplicas(3).
			withNginxContainer("nginx:latest").
			build()

		Expect(dep2).Should(Equal(dep1))

		dep3 := newDeployment("default", "nginx-deployment").
			withLabels(map[string]string{"app": "nginx"}).
			withReplicas(5).
			withNginxContainer("nginx:1.14.2").
			withSidecarContainer("ubuntu:22.04").
			build()

		Expect(dep3).ShouldNot(Equal(dep1))
	})
})

オブジェクト生成の処理がすっきりしていてますし、どんな属性を持ったオブジェクトを生成しているのか分かりやすくなっています。

KubernetesオブジェクトのAssertion

つぎに、KubernetesオブジェクトのAssertionについて考えてみます。

Equal Matcherの問題点

Gomegaでは、オブジェクトの比較をおこなうためにEqual Matcherが用意されています。
しかし、Kubernetesオブジェクトに対してEqual Matcherを利用すると、以下のような問題があります。

差分が分かりにくい

Kubernetesオブジェクトのような大きくて複雑なオブジェクトに対してEqual Matcherを利用すると、違いがあったときのエラーメッセージが非常に分かりにくくなります。

例えば、.Spec.Template.Containers[0].Imageだけが異なるDeploymentオブジェクトをEqual Matcherで比較すると以下のようなメッセージが表示されます。

Deploymentの比較で差分があった場合のメッセージ
  Expected
      <*v1.Deployment | 0xc000167200>: {
          TypeMeta: {Kind: "", APIVersion: ""},
          ObjectMeta: {
              Name: "nginx-deployment",
              GenerateName: "",
              Namespace: "default",
              SelfLink: "",
              UID: "",
              ResourceVersion: "",
              Generation: 0,
              CreationTimestamp: {
                  Time: 0001-01-01T00:00:00Z,
              },
              DeletionTimestamp: nil,
              DeletionGracePeriodSeconds: nil,
              Labels: {"app": "nginx"},
              Annotations: nil,
              OwnerReferences: nil,
              Finalizers: nil,
              ZZZ_DeprecatedClusterName: "",
              ManagedFields: nil,
          },
          Spec: {
              Replicas: 3,
              Selector: {
                  MatchLabels: {"app": "nginx"},
                  MatchExpressions: nil,
              },
              Template: {
                  ObjectMeta: {
                      Name: "",
                      GenerateName: "",
                      Namespace: "",
                      SelfLink: "",
                      UID: "",
                      ResourceVersion: "",
                      Generation: 0,
                      CreationTimestamp: {
                          Time: 0001-01-01T00:00:00Z,
                      },
                      DeletionTimestamp: nil,
                      DeletionGracePeriodSeconds: nil,
                      Labels: {"app": "nginx"},
                      Annotations: nil,
                      OwnerReferences: nil,
                      Finalizers: nil,
                      ZZZ_DeprecatedClusterName: "",
                      ManagedFields: nil,
                  },
                  Spec: {
                      Volumes: nil,
                      InitContainers: nil,
                      Containers: [
                          {
                              Name: "nginx",
                              Image: "nginx:latest",
                              Command: nil,
                              Args: nil,
                              WorkingDir: "",
                              Ports: [
                                  {Name: "", HostPort: 0, ContainerPort: 80, Protocol: "", HostIP: ""},
                              ],
                              EnvFrom: nil,
                              Env: nil,
                              Resources: {Limits: nil, Requests: nil},
                              VolumeMounts: nil,
                              VolumeDevices: nil,
                              LivenessProbe: nil,
                              ReadinessProbe: nil,
                              StartupProbe: nil,
                              Lifecycle: nil,
                              TerminationMessagePath: "",
                              TerminationMessagePolicy: "",
                              ImagePullPolicy: "",
                              SecurityContext: nil,
                              Stdin: false,
                              StdinOnce: false,
                              TTY: false,
                          },
                      ],
                      EphemeralContainers: nil,
                      RestartPolicy: "",
                      TerminationGracePeriodSeconds: nil,
                      ActiveDeadlineSeconds: nil,
                      DNSPolicy: "",
                      NodeSelector: nil,
                      ServiceAccountName: "",
                      DeprecatedServiceAccount: "",
                      AutomountServiceAccountToken: nil,
                      NodeName: "",
                      HostNetwork: false,
                      HostPID: false,
                      HostIPC: false,
                      ShareProcessNamespace: nil,
                      SecurityContext: nil,
                      ImagePullSecrets: nil,
                      Hostname: "",
                      Subdomain: "",
                      Affinity: nil,
                      SchedulerName: "",
                      Tolerations: nil,
                      HostAliases: nil,
                      PriorityClassName: "",
                      Priority: nil,
                      DNSC...

  Gomega truncated this representation as it exceeds 'format.MaxLength'.
  Consider having the object provide a custom 'GomegaStringer' representation
  or adjust the parameters in Gomega's 'format' package.

  Learn more here: https://onsi.github.io/gomega/#adjusting-output

  to equal
      <*v1.Deployment | 0xc000167680>: {
          TypeMeta: {Kind: "", APIVersion: ""},
          ObjectMeta: {
              Name: "nginx-deployment",
              GenerateName: "",
              Namespace: "default",
              SelfLink: "",
              UID: "",
              ResourceVersion: "",
              Generation: 0,
              CreationTimestamp: {
                  Time: 0001-01-01T00:00:00Z,
              },
              DeletionTimestamp: nil,
              DeletionGracePeriodSeconds: nil,
              Labels: {"app": "nginx"},
              Annotations: nil,
              OwnerReferences: nil,
              Finalizers: nil,
              ZZZ_DeprecatedClusterName: "",
              ManagedFields: nil,
          },
          Spec: {
              Replicas: 3,
              Selector: {
                  MatchLabels: {"app": "nginx"},
                  MatchExpressions: nil,
              },
              Template: {
                  ObjectMeta: {
                      Name: "",
                      GenerateName: "",
                      Namespace: "",
                      SelfLink: "",
                      UID: "",
                      ResourceVersion: "",
                      Generation: 0,
                      CreationTimestamp: {
                          Time: 0001-01-01T00:00:00Z,
                      },
                      DeletionTimestamp: nil,
                      DeletionGracePeriodSeconds: nil,
                      Labels: {"app": "nginx"},
                      Annotations: nil,
                      OwnerReferences: nil,
                      Finalizers: nil,
                      ZZZ_DeprecatedClusterName: "",
                      ManagedFields: nil,
                  },
                  Spec: {
                      Volumes: nil,
                      InitContainers: nil,
                      Containers: [
                          {
                              Name: "nginx",
                              Image: "nginx:1.14.2",
                              Command: nil,
                              Args: nil,
                              WorkingDir: "",
                              Ports: [
                                  {Name: "", HostPort: 0, ContainerPort: 80, Protocol: "", HostIP: ""},
                              ],
                              EnvFrom: nil,
                              Env: nil,
                              Resources: {Limits: nil, Requests: nil},
                              VolumeMounts: nil,
                              VolumeDevices: nil,
                              LivenessProbe: nil,
                              ReadinessProbe: nil,
                              StartupProbe: nil,
                              Lifecycle: nil,
                              TerminationMessagePath: "",
                              TerminationMessagePolicy: "",
                              ImagePullPolicy: "",
                              SecurityContext: nil,
                              Stdin: false,
                              StdinOnce: false,
                              TTY: false,
                          },
                      ],
                      EphemeralContainers: nil,
                      RestartPolicy: "",
                      TerminationGracePeriodSeconds: nil,
                      ActiveDeadlineSeconds: nil,
                      DNSPolicy: "",
                      NodeSelector: nil,
                      ServiceAccountName: "",
                      DeprecatedServiceAccount: "",
                      AutomountServiceAccountToken: nil,
                      NodeName: "",
                      HostNetwork: false,
                      HostPID: false,
                      HostIPC: false,
                      ShareProcessNamespace: nil,
                      SecurityContext: nil,
                      ImagePullSecrets: nil,
                      Hostname: "",
                      Subdomain: "",
                      Affinity: nil,
                      SchedulerName: "",
                      Tolerations: nil,
                      HostAliases: nil,
                      PriorityClassName: "",
                      Priority: nil,
                      DNSC...

  Gomega truncated this representation as it exceeds 'format.MaxLength'.
  Consider having the object provide a custom 'GomegaStringer' representation
  or adjust the parameters in Gomega's 'format' package.

  Learn more here: https://onsi.github.io/gomega/#adjusting-output

あまりに長いので折りたたんでありますが、どこに差分があるのか見つけるのに一苦労です。

正しく比較できないフィールドがある

Equal Matcherは内部でreflect.DeepEqalを利用しているのですが、Kubernetesオブジェクトが持っているいくつかのフィールドは正しく比較できない場合があります。

例えば、Podに割り当てるCPU Requestに11000mを指定した場合、意味としては同じですが、Equal Matcherで比較すると異なる値として判断されます。

package controllers

import (
	. "github.com/onsi/ginkgo/v2"
	. "github.com/onsi/gomega"
	corev1 "k8s.io/api/core/v1"
	"k8s.io/apimachinery/pkg/api/resource"
)

var _ = Describe("SemanticEqual", func() {
	It("should be equal", func() {
		dep1 := createDeployment("nginx:latest")
		dep2 := createDeployment("nginx:latest")
		dep1.Spec.Template.Spec.Containers[0].Resources = corev1.ResourceRequirements{
			Requests: corev1.ResourceList{
				corev1.ResourceCPU: resource.MustParse("1"),
			},
		}
		dep2.Spec.Template.Spec.Containers[0].Resources = corev1.ResourceRequirements{
			Requests: corev1.ResourceList{
				corev1.ResourceCPU: resource.MustParse("1000m"),
			},
		}

		// 一致しない
		Expect(dep1).ShouldNot(Equal(dep2))
	})
})

kube-apiserver登録時に設定されるフィールド

Kubernetesオブジェクトをkube-apiserverに登録すると、自動的に様々なフィールドが設定されます。

例えば、Deploymentオブジェクトを登録したときには以下のようなフィールドが設定されます。

Deploymentをkube-apiserverに登録した際の変更箇所
  diff:
    &v1.Pod{
        TypeMeta: {},
        ObjectMeta: v1.ObjectMeta{
                ... // 2 identical fields
                Namespace:                  "default",
                SelfLink:                   "",
  -             UID:                        "",
  +             UID:                        "5ca0c4de-5c65-4452-9eac-6cc7d272773a",
  -             ResourceVersion:            "",
  +             ResourceVersion:            "205",
                Generation:                 0,
  -             CreationTimestamp:          s"0001-01-01 00:00:00 +0000 UTC",
  +             CreationTimestamp:          s"2022-08-15 23:03:24 +0900 JST",
                DeletionTimestamp:          nil,
                DeletionGracePeriodSeconds: nil,
                ... // 3 identical fields
                Finalizers:                nil,
                ZZZ_DeprecatedClusterName: "",
  -             ManagedFields:             nil,
  +             ManagedFields: []v1.ManagedFieldsEntry{
  +                     {
  +                             Manager:    "controllers.test",
  +                             Operation:  "Update",
  +                             APIVersion: "v1",
  +                             Time:       s"2022-08-15 23:03:24 +0900 JST",
  +                             FieldsType: "FieldsV1",
  +                             FieldsV1:   s`{"f:spec":{"f:containers":{"k:{\"name\":\"ubuntu\"}":{".":{},"f:`...,
  +                     },
  +             },
        },
        Spec: v1.PodSpec{
                Volumes:        nil,
                InitContainers: nil,
                Containers: []v1.Container{
                        {
                                ... // 13 identical fields
                                StartupProbe:             nil,
                                Lifecycle:                nil,
  -                             TerminationMessagePath:   "",
  +                             TerminationMessagePath:   "/dev/termination-log",
  -                             TerminationMessagePolicy: "",
  +                             TerminationMessagePolicy: "File",
  -                             ImagePullPolicy:          "",
  +                             ImagePullPolicy:          "IfNotPresent",
                                SecurityContext:          nil,
                                Stdin:                    false,
                                ... // 2 identical fields
                        },
                },
                EphemeralContainers:           nil,
  -             RestartPolicy:                 "",
  +             RestartPolicy:                 "Always",
  -             TerminationGracePeriodSeconds: nil,
  +             TerminationGracePeriodSeconds: &30,
                ActiveDeadlineSeconds:         nil,
  -             DNSPolicy:                     "",
  +             DNSPolicy:                     "ClusterFirst",
                NodeSelector:                  nil,
                ServiceAccountName:            "",
                ... // 5 identical fields
                HostIPC:               false,
                ShareProcessNamespace: nil,
  -             SecurityContext:       nil,
  +             SecurityContext:       s"&PodSecurityContext{SELinuxOptions:nil,RunAsUser:nil,RunAsNonRoot:nil,SupplementalGroups:[],FSGroup:nil,RunAsGroup:nil,Sysctls:[]Sysctl{},WindowsOptions:nil,FSGroupChangePolicy:nil,SeccompProfile:nil,}",
                ImagePullSecrets:      nil,
                Hostname:              "",
                Subdomain:             "",
                Affinity:              nil,
  -             SchedulerName:         "",
  +             SchedulerName:         "default-scheduler",
  -             Tolerations:           nil,
  +             Tolerations: []v1.Toleration{
  +                     {
  +                             Key:               "node.kubernetes.io/not-ready",
  +                             Operator:          "Exists",
  +                             Effect:            "NoExecute",
  +                             TolerationSeconds: &300,
  +                     },
  +                     {
  +                             Key:               "node.kubernetes.io/unreachable",
  +                             Operator:          "Exists",
  +                             Effect:            "NoExecute",
  +                             TolerationSeconds: &300,
  +                     },
  +             },
                HostAliases:               nil,
                PriorityClassName:         "",
  -             Priority:                  nil,
  +             Priority:                  &0,
                DNSConfig:                 nil,
                ReadinessGates:            nil,
                RuntimeClassName:          nil,
  -             EnableServiceLinks:        nil,
  +             EnableServiceLinks:        &true,
  -             PreemptionPolicy:          nil,
  +             PreemptionPolicy:          &"PreemptLowerPriority",
                Overhead:                  nil,
                TopologySpreadConstraints: nil,
                ... // 2 identical fields
        },
        Status: v1.PodStatus{
  -             Phase:      "",
  +             Phase:      "Pending",
                Conditions: nil,
                Message:    "",
                ... // 6 identical fields
                InitContainerStatuses:      nil,
                ContainerStatuses:          nil,
  -             QOSClass:                   "",
  +             QOSClass:                   "BestEffort",
                EphemeralContainerStatuses: nil,
        },
    }

このため、kube-apiserverに登録前のオブジェクトと、kube-apiserverから取得したオブジェクトを単純にEqual Matcherで比較すると異なるオブジェクトであると判断されます。

Semantic Custom Matcher

Equal Matcherのいくつかの問題を解決するために、equality.Semantic.DeepEqualequality.Semantic.DeepDerivativeを利用したCustom Matcherをつくってみましょう。

package controllers

import (
	"fmt"

	"github.com/google/go-cmp/cmp"
	"github.com/onsi/gomega/types"
	"k8s.io/apimachinery/pkg/api/equality"
	"k8s.io/apimachinery/pkg/api/resource"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/fields"
	"k8s.io/apimachinery/pkg/labels"
)

func SemanticEqual(expected interface{}) types.GomegaMatcher {
	return &semanticMatcher{
		expected: expected,
		compare:  equality.Semantic.DeepEqual,
	}
}

func SemanticDerivative(expected interface{}) types.GomegaMatcher {
	return &semanticMatcher{
		expected: expected,
		compare:  equality.Semantic.DeepDerivative,
	}
}

type semanticMatcher struct {
	expected interface{}
	compare  func(a1, a2 interface{}) bool
}

func (matcher *semanticMatcher) Match(actual interface{}) (bool, error) {
	return matcher.compare(matcher.expected, actual), nil
}

var diffOptions = []cmp.Option{
	cmp.Comparer(func(x, y resource.Quantity) bool {
		return x.Cmp(y) == 0
	}),
	cmp.Comparer(func(a, b metav1.MicroTime) bool {
		return a.UTC() == b.UTC()
	}),
	cmp.Comparer(func(a, b metav1.Time) bool {
		return a.UTC() == b.UTC()
	}),
	cmp.Comparer(func(a, b labels.Selector) bool {
		return a.String() == b.String()
	}),
	cmp.Comparer(func(a, b fields.Selector) bool {
		return a.String() == b.String()
	}),
}

func (matcher *semanticMatcher) FailureMessage(actual interface{}) (message string) {
	diff := cmp.Diff(matcher.expected, actual, diffOptions...)
	return fmt.Sprintf("diff: \n%s", diff)
}

func (matcher *semanticMatcher) NegatedFailureMessage(actual interface{}) (message string) {
	diff := cmp.Diff(matcher.expected, actual, diffOptions...)
	return fmt.Sprintf("diff: \n%s", diff)
}

equality.Semantic.DeepEqualreflect.DeepEqualと似ていますが、より意味を重視した比較をおこないます。
例えば、先ほど例に挙げたCPU Requestの比較をただしくおこないますし、空のスライスやマップをnil値と比較した場合も同じであると判断します。

equality.Semantic.DeepDerivativeは、オブジェクトが派生したものかどうかを判断することができます。
例えば、以下のようにpod1では設定されていなかったフィールドをpod2に設定すると、pod2pod1の派生オブジェクトとなります。ただし逆は真ではありません。

package controllers

import (
	. "github.com/onsi/ginkgo/v2"
	. "github.com/onsi/gomega"
	corev1 "k8s.io/api/core/v1"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/utils/pointer"
)

var _ = Describe("Test controller", func() {
	It("should be delivative", func() {
		pod1 := &corev1.Pod{
			ObjectMeta: metav1.ObjectMeta{
				Namespace: "default",
				Name:      "sample",
			},
			Spec: corev1.PodSpec{
				Containers: []corev1.Container{
					{
						Name:  "ubuntu",
						Image: "ubuntu:20.04",
					},
				},
			},
		}
		pod2 := pod1.DeepCopy()
		pod2.Spec.SecurityContext = &corev1.PodSecurityContext{
			RunAsNonRoot: pointer.Bool(true),
		}

		// pod2はpod1の派生オブジェクトである
		Expect(pod2).Should(SemanticDerivative(pod1))

		// pod1はpod2の派生オブジェクトではない
		Expect(pod1).ShouldNot(SemanticDerivative(pod2))
	})
})

equality.Semantic.DeepDerivativeを利用すると、kube-apiserverに登録前のオブジェクトと、kube-apiserverから取得したオブジェクトを比較できる場合があります。
ただし、すべての場合に適用できるわけではなく、例えばtime.Time型のフィールドに値が設定された場合は、派生したオブジェクトであると判断することができません[1]

このようにequality.Semantic.DeepEqualequality.Semantic.DeepDerivativeが利用できるケースは限定的なので、次のgstructを利用することをおすすめします。

gstructの利用

Gomegaでは、複雑な構造体をAssertionするためにgstructというパッケージを提供しています。
これを利用するためには"github.com/onsi/gomega/gstruct"のインポートが必要になります。

gstructパッケージを利用して、DeploymentオブジェクトのAssertionを書いてみましょう。

package controllers

import (
	. "github.com/onsi/ginkgo/v2"
	. "github.com/onsi/gomega"
	. "github.com/onsi/gomega/gstruct"
	corev1 "k8s.io/api/core/v1"
)

var _ = Describe("gstruct", func() {
	It("should equal", func() {
		dep1 := createDeployment("nginx:latest")

		Expect(dep1).Should(PointTo(MatchFields(IgnoreExtras, Fields{
			"ObjectMeta": MatchFields(IgnoreExtras, Fields{
				"Namespace": Equal("default"),
				"Name":      Equal("nginx-deployment"),
				"Labels":    MatchAllKeys(Keys{"app": Equal("nginx")}),
			}),
			"Spec": MatchFields(IgnoreExtras, Fields{
				"Replicas": PointTo(BeNumerically("==", 3)),
				"Selector": PointTo(MatchFields(IgnoreExtras, Fields{
					"MatchLabels": MatchAllKeys(Keys{"app": Equal("nginx")}),
				})),
				"Template": MatchFields(IgnoreExtras, Fields{
					"ObjectMeta": MatchFields(IgnoreExtras, Fields{
						"Labels": MatchAllKeys(Keys{"app": Equal("nginx")}),
					}),
					"Spec": MatchFields(IgnoreExtras, Fields{
						"Containers": MatchAllElements(containerIdentity, Elements{
							"nginx": MatchFields(IgnoreExtras, Fields{
								"Image": Equal("nginx:latest"),
								"Ports": MatchAllElements(portIdentity, Elements{
									"80": HaveField("ContainerPort", BeNumerically("==", 80)),
								}),
							}),
						},
						),
					}),
				}),
			}),
		})))
	})
})

func containerIdentity(element interface{}) string {
	container, ok := element.(corev1.Container)
	if !ok {
		panic("Cannot cast to Container")
	}
	return container.Name
}

func portIdentity(element interface{}) string {
	port, ok := element.(corev1.ContainerPort)
	if !ok {
		panic("Cannot cast to ContainerPort")
	}
	return strconv.FormatInt(int64(port.ContainerPort), 10)
}

ネストした構造体の比較にはMatchFieldsMatchAllFields, マップの比較にはMatchKeysMatchAllKeys, スライスの比較にはMatchElementsMatchAllElementsを利用します。

先ほど説明したようにkube-apiserverから取得したオブジェクトは、様々なフィールドが更新されています。
MatchFields, MatchKeys, MatchElementsを利用する際にIgnoreExtrasを指定することで、余分なフィールドは比較しないようにできます。

また、MatchElementsMatchAllElementsの第1引数には、スライスの中の要素を一意に識別できるキーを返す関数を指定します。
上記の例では、Container型のNameフィールドを返すcontainerIdentityと、ContainerPort型のContainerPortフィールドを返すportIdentity関数を利用しています

gstructによるAssertionは、慣れるまで書きにくいかもしれません。
しかし、テストに失敗したときに、どこに問題があったのかが非常に分かりやく表示されます。

  Expected
      <string>: Deployment
  to match fields: {
  .Spec.Template.Spec.Containers[nginx].Image:
        Expected
            <string>: nginx:latest
        to equal
            <string>: nginx:1.14.2
  }

その他のAssertion

他にもいくつかのAssertionの書き方を比較してみたので、気になる方は以下のスクラップをご覧ください。

https://zenn.dev/zoetro/scraps/48d0ab65175857
脚注
  1. https://groups.google.com/g/kubernetes-sig-api-machinery/c/L2F-dvxi2ew ↩︎