Gomega で Kubernetes オブジェクトの Assertion
Gomega による Kubernetes オブジェクトの Assertion 方法を比較してみる。
テスト用のオブジェクトを作成する関数を用意しておく。
package controllers
import (
"k8s.io/utils/pointer"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
func createDeployment(image string) *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: image,
Ports: []corev1.ContainerPort{
{
ContainerPort: 80,
},
},
},
},
},
},
},
}
}
まずは単純に gomega.Equal
を利用した方法。
わざとテストを失敗させてみる。
package controllers
import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Equal", func() {
It("should equal", func() {
dep1 := createDeployment("nginx:latest")
dep2 := createDeployment("nginx:1.14.2")
Expect(dep1).Should(Equal(dep2))
})
})
失敗メッセージは以下のようになり、非常に分かりにくい。
Expected
<*v1.Deployment | 0xc000b12000>: {
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 | 0xc000b12480>: {
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
gomega.Equal
は内部的に reflect.DeepEqual
を利用しているが、これは Kubernetes のオブジェクト比較に向いていないケースがある。(オブジェクトが resource.Quantity
や labels.Selector
などを含んでいる場合)
そこで、equality.Semantic.DeepEqual
を利用したカスタムマッチャーを用意する。
なお、差分の表示にはgo-cmpを利用する。
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(actual, matcher.expected, diffOptions...)
return fmt.Sprintf("diff: \n%s", diff)
}
func (matcher *semanticMatcher) NegatedFailureMessage(actual interface{}) (message string) {
diff := cmp.Diff(actual, matcher.expected, diffOptions...)
return fmt.Sprintf("diff: \n%s", diff)
}
上記のカスタムマッチャーを使って、Assertion をわざと失敗させてみる。
package controllers
import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("SemanticEqual", func() {
It("should equal", func() {
dep1 := createDeployment("nginx:latest")
dep2 := createDeployment("nginx:1.14.2")
Expect(dep1).Should(SemanticEqual(dep2))
})
})
失敗メッセージは先ほどより大分わかりやすい。
diff:
&v1.Deployment{
TypeMeta: {},
ObjectMeta: {Name: "nginx-deployment", Namespace: "default", Labels: {"app": "nginx"}},
Spec: v1.DeploymentSpec{
Replicas: &3,
Selector: &{MatchLabels: {"app": "nginx"}},
Template: v1.PodTemplateSpec{
ObjectMeta: {Labels: {"app": "nginx"}},
Spec: v1.PodSpec{
Volumes: nil,
InitContainers: nil,
Containers: []v1.Container{
{
Name: "nginx",
- Image: "nginx:latest",
+ Image: "nginx:1.14.2",
Command: nil,
Args: nil,
... // 18 identical fields
},
},
EphemeralContainers: nil,
RestartPolicy: "",
... // 31 identical fields
},
},
Strategy: {},
MinReadySeconds: 0,
... // 3 identical fields
},
Status: {},
}
しかし、実際のテストでは equality.Semantic.DeepEqual
が使えないことのほうが多い。
そこで、Gomega の提供している Matcher を利用して愚直に書いてみる。
package controllers
import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("GomegaMatcher", func() {
It("should equal", func() {
dep1 := createDeployment("nginx:latest")
Expect(dep1.Namespace).Should(Equal("default"))
Expect(dep1.Name).Should(Equal("nginx-deployment"))
Expect(dep1.Labels).Should(HaveKeyWithValue("app", "nginx"))
Expect(dep1.Spec.Replicas).Should(HaveValue(BeNumerically("==", 3)))
Expect(dep1.Spec.Selector).ShouldNot(BeNil())
Expect(dep1.Spec.Selector.MatchLabels).Should(HaveKeyWithValue("app", "nginx"))
Expect(dep1.Spec.Template.Labels).Should(HaveKeyWithValue("app", "nginx"))
Expect(dep1.Spec.Template.Spec.Containers).ShouldNot(BeEmpty())
Expect(dep1.Spec.Template.Spec.Containers[0].Name).Should(Equal("nginx"))
Expect(dep1.Spec.Template.Spec.Containers[0].Image).Should(Equal("nginx:1.14.2"))
Expect(dep1.Spec.Template.Spec.Containers[0].Ports).ShouldNot(BeEmpty())
Expect(dep1.Spec.Template.Spec.Containers[0].Ports[0].ContainerPort).Should(BeNumerically("==", 80))
})
})
失敗メッセージはシンプル。
Expected
<string>: nginx:latest
to equal
<string>: nginx:1.14.2
HaveField や ConsistOf を使ってもう少しいい感じにできないかやってみる。
package controllers
import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = FDescribe("ConsistOf", func() {
It("should equal", func() {
dep1 := createDeployment("nginx:latest")
Expect(dep1).Should(HaveField("Namespace", Equal("default")))
Expect(dep1).Should(HaveField("Name", Equal("nginx-deployment")))
Expect(dep1).Should(HaveField("Labels", HaveKeyWithValue("app", "nginx")))
Expect(dep1).Should(HaveField("Spec.Replicas", HaveValue(BeNumerically("==", 3))))
Expect(dep1).ShouldNot(HaveField("Spec.Selector", BeNil()))
Expect(dep1).Should(HaveField("Spec.Selector.MatchLabels", HaveKeyWithValue("app", "nginx")))
Expect(dep1).Should(HaveField("Spec.Template.Labels", HaveKeyWithValue("app", "nginx")))
Expect(dep1).ShouldNot(HaveField("Spec.Template.Spec.Containers", BeEmpty()))
Expect(dep1.Spec.Template.Spec.Containers).Should(ConsistOf(SatisfyAll(
HaveField("Name", Equal("nginx")),
HaveField("Image", Equal("nginx:1.14.2")),
HaveField("Ports", Not(BeEmpty())),
HaveField("Ports", ConsistOf(HaveField("ContainerPort", BeNumerically("==", 80)))),
)))
})
})
エラーメッセージがかなり分かりにくい。
Expected
<[]v1.Container | len:1, cap:1>: [
{
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,
},
]
to consist of
<[]*matchers.AndMatcher | len:1, cap:1>: [
{
Matchers: [
<*matchers.HaveFieldMatcher | 0xc000ab7f40>{
Field: "Name",
Expected: <*matchers.EqualMatcher | 0xc00045fec0>{
Expected: <string>"nginx",
},
extractedField: <string>"nginx",
expectedMatcher: <*matchers.EqualMatcher | 0xc00045fec0>{
Expected: <string>"nginx",
},
},
<*matchers.HaveFieldMatcher | 0xc000ab7f80>{
Field: "Image",
Expected: <*matchers.EqualMatcher | 0xc00045fee0>{
Expected: <string>"nginx:1.14.2",
},
extractedField: <string>"nginx:latest",
expectedMatcher: <*matchers.EqualMatcher | 0xc00045fee0>{
Expected: <string>"nginx:1.14.2",
},
},
<*matchers.HaveFieldMatcher | 0xc000ab7fc0>{
Field: "Ports",
Expected: <*matchers.NotMatcher | 0xc00045ff00>{
Matcher: <*matchers.BeEmptyMatcher | 0x256e138>{},
},
extractedField: nil,
expectedMatcher: nil,
},
<*matchers.HaveFieldMatcher | 0xc0006d4040>{
Field: "Ports",
Expected: <*matchers.ConsistOfMatcher | 0xc00003cb90>{
Elements: [
<*matchers.HaveFieldMatcher | 0xc0006d4000>{
Field: "ContainerPort",
Expected: <*matchers.BeNumericallyMatcher | 0xc000891e30>{Comparator: "==", CompareTo: [<int>80]},
extractedField: nil,
expectedMatcher: nil,
},
],
missingElements: nil,
extraElements: nil,
},
extractedField: nil,
expectedMatcher: nil,
},
],
firstFailedMatcher: <*matchers.HaveFieldMatcher | 0xc000ab7f80>{
Field: "Image",
Expected: <*matchers.EqualMatcher | 0xc00045fee0>{
Expected: <string>"nginx:1.14.2",
},
extractedField: <string>"nginx:latest",
expectedMatcher: <*matchers.EqualMatcher | 0xc00045fee0>{
Expected: <string>"nginx:1.14.2",
},
},
},
]
the missing elements were
<[]*matchers.AndMatcher | len:1, cap:1>: [
{
Matchers: [
<*matchers.HaveFieldMatcher | 0xc000ab7f40>{
Field: "Name",
Expected: <*matchers.EqualMatcher | 0xc00045fec0>{
Expected: <string>"nginx",
},
extractedField: <string>"nginx",
expectedMatcher: <*matchers.EqualMatcher | 0xc00045fec0>{
Expected: <string>"nginx",
},
},
<*matchers.HaveFieldMatcher | 0xc000ab7f80>{
Field: "Image",
Expected: <*matchers.EqualMatcher | 0xc00045fee0>{
Expected: <string>"nginx:1.14.2",
},
extractedField: <string>"nginx:latest",
expectedMatcher: <*matchers.EqualMatcher | 0xc00045fee0>{
Expected: <string>"nginx:1.14.2",
},
},
<*matchers.HaveFieldMatcher | 0xc000ab7fc0>{
Field: "Ports",
Expected: <*matchers.NotMatcher | 0xc00045ff00>{
Matcher: <*matchers.BeEmptyMatcher | 0x256e138>{},
},
extractedField: nil,
expectedMatcher: nil,
},
<*matchers.HaveFieldMatcher | 0xc0006d4040>{
Field: "Ports",
Expected: <*matchers.ConsistOfMatcher | 0xc00003cb90>{
Elements: [
<*matchers.HaveFieldMatcher | 0xc0006d4000>{
Field: "ContainerPort",
Expected: <*matchers.BeNumericallyMatcher | 0xc000891e30>{Comparator: "==", CompareTo: [<int>80]},
extractedField: nil,
expectedMatcher: nil,
},
],
missingElements: nil,
extraElements: nil,
},
extractedField: nil,
expectedMatcher: nil,
},
],
firstFailedMatcher: <*matchers.HaveFieldMatcher | 0xc000ab7f80>{
Field: "Image",
Expected: <*matchers.EqualMatcher | 0xc00045fee0>{
Expected: <string>"nginx:1.14.2",
},
extractedField: <string>"nginx:latest",
expectedMatcher: <*matchers.EqualMatcher | 0xc00045fee0>{
Expected: <string>"nginx:1.14.2",
},
},
},
]
the extra elements were
<[]v1.Container | len:1, cap:1>: [
{
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,
},
]
Gomega には複雑な構造体をアサーションするための gstruct パッケージが用意されているので、これを利用してみる。
package controllers
import (
"strconv"
. "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:1.14.2"),
"Ports": MatchAllElements(portIdentity, Elements{
"80": HaveField("ContainerPort", BeNumerically("==", 80)),
}),
}),
},
),
}),
}),
}),
})))
})
})
func containerIdentity(element interface{}) string {
container, ok := element.(corev1.Container)
if !ok {
return ""
}
return container.Name
}
func portIdentity(element interface{}) string {
port, ok := element.(corev1.ContainerPort)
if !ok {
return ""
}
return strconv.FormatInt(int64(port.ContainerPort), 10)
}
エラーメッセージは非常にわかりやすい。
どのフィールドが間違っているのかが分かるし、複数箇所の間違いも一度に表示してくれる。
Expected
<string>: Deployment
to match fields: {
.Spec.Template.Spec.Containers[nginx].Image:
Expected
<string>: nginx:latest
to equal
<string>: nginx:1.14.2
}
kmatch というライブラリを見つけたのでこれを使ってみる。
テストはかなりシンプルに書くことができる。
package controllers
import (
. "github.com/kralicky/kmatch"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("kmatch", func() {
It("should equal", func() {
dep1 := createDeployment("nginx:latest")
Expect(dep1).Should(SatisfyAll(
HaveNamespace("default"),
HaveName("nginx-deployment"),
HaveLabels("app", "nginx"),
HaveReplicaCount(3),
HaveMatchingContainer(SatisfyAll(
HaveName("nginx"),
HaveImage("nginx:1.14.2"),
HavePorts(80),
)),
))
})
})
しかし、失敗したときのメッセージは以下のようになり、どこが間違っているのか分からない。
expected nginx-deployment to have a matching container
結論としては、比較的単純な比較ですむ場合は equality.Semantic.DeepEqual
, equality.Semantic.DeepDerivative
のカスタムマッチャーを利用し、そうでない場合は gstruct を利用するのがよさそう。