Chapter 03

テストの基本

zoetro
zoetro
2022.08.22に更新

Ginkgo/Gomega

GinkgoはBDDテストフレームワークであり、GomegaはAssertionライブラリです。

本章ではGinkgoとGomegaの基本的な機能について解説しますが、 すべての機能については紹介しきれませんので、より詳しく知りたい方は公式ドキュメントをおすすめします。

https://onsi.github.io/ginkgo/
https://onsi.github.io/gomega/

import

Goでは一般的にドットインポートは推奨されていません。
しかし、Ginkgo/Gomegaではドットインポートを利用することで、テストコードがDSLとして書きやすくなります。

本書でも以下のようにドットインポートしたものとしてコードの説明をおこないます。

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

エントリーポイント

Goのtestingパッケージでは、func TestXxx(t *testing.T) {}のようにTestから始まる名前の関数を用意します。
1つのテストケースごとにこの関数を用意することとなります。

一方、Ginkgoではテストケースは以下のように独自のDSLを用いて記述します。

package controllers

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

var _ = Describe("Empty", func() {
	It("should be success 1", func() {
		// テストケースその1
	})

	It("should be success 2", func() {
		// テストケースその2
	})
})

このIt("", func() { ... })の部分が1つのテストケースとなり、Ginkgoではこれをスペックと呼びます。

また、スペックの集合をスイートと呼びます。
スイートはfunc TestXxx(t *testing.T) {}形式の関数を1つだけ持ち、これがテストのエントリーポイントとなります。

package controllers

import (
	"testing"

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

func TestAPIs(t *testing.T) {
	RegisterFailHandler(Fail)
	RunSpecs(t, "Controller Suite")
}

ノード

Ginkgoでは、以下のような形式でテストを書いていくことになります。

package controllers

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

var _ = Describe("Empty", func() {
	BeforeEach(func() {
		// 初期化処理
	})

	AfterEach(func() {
		// 後処理
	})

	It("should be true", func() {
		// テストケースその1
	})

	It("should be false", func() {
		// テストケースその2
	})
})

ここで登場するDescribe, BeforeEach, Itなどの関数はノードと呼ばれており、Ginkgoには以下の3種類のノードが存在します。

コンテナノード

Describe, Context, Whenはコンテナノードと呼ばれ、テストの構造を階層化するために利用されます。
例えば、以下のような階層構造にすることができます。

package controllers

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

var _ = Describe("Sample Controller Test", func() {
	Context("for a valid input", func() {
		When("the mode is normal", func() {
			It("should be true", func() {
				// テストケースを書く
			})

			It("should be not nil", func() {
				// テストケースを書く
			})
		})

		When("the mode is advanced", func() {
			It("should be true", func() {
				// テストケースを書く
			})

			It("should be not nil", func() {
				// テストケースを書く
			})
		})
	})

	Context("for a invalid input", func() {
		It("should be false", func() {
			// テストケースを書く
		})

		It("should be nil", func() {
			// テストケースを書く
		})
	})
})

また、コンテナノードは階層化するだけでなく、グループごとにセットアップノードのスコープを制御したり、ラベルを付与してテストの実行単位を制御したり、デコレータを付与してテストの動きを変えることができます。

なお、Describe, Context, Whenという3つの関数が用意されていますが、これらの挙動に違いはありません。
コードを読んだときに意味が分かりやすくなる関数を選択するとよいでしょう。

セットアップノード

BeforeEach, AfterEachなどはセットアップノードと呼ばれ、Specを実行する前のセットアップや後片付けなどの処理を記述するのに使われます。
例えば、BeforeEachはスペックを実行する前に必ず実行され、AfterEachはスペックの実行後に必ず呼び出されます。
なお、セットアップノードの中に他のノードを書くことはできません。

また、コンテナノードを利用することで、セットアップノードを実行するスコープを変えることができます。
例えば、以下のようにそれぞれのContextの中にBeforeEachを用意すると、1つめのBeforeEachはテストケース1と2の前に呼び出され、2つめのBeforeEachはテストケース3と4の前に呼び出されることになります。

package controllers

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

var _ = Describe("Empty", func() {
	Context("for a valid input", func() {
		BeforeEach(func() {
			// 初期化処理1
		})

		It("should be true", func() {
			// テストケースその1
		})

		It("should be not nil", func() {
			// テストケースその2
		})
	})

	Context("for a invalid input", func() {
		BeforeEach(func() {
			// 初期化処理2
		})

		It("should be false", func() {
			// テストケースその3
		})

		It("should be nil", func() {
			// テストケースその4
		})
	})
})

サブジェクトノード

It, Specifyはサブジェクトノードと呼ばれ、実際のテストケース(スペック)を記述するために使われます。
なお、サブジェクトノードの中に他のノードを書くことはできません。

package controllers

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

var _ = Describe("Sample", func() {
	It("should be true", func() {
		// テストケースその1
	})

	Specify("it will be false", func() {
		// テストケースその2
	})
})

ItSpecifyの挙動に違いはないので、好みのものを利用しましょう。

By

Byはノードではありませんが、セットアップノードやサブジェクトノードの中で利用できる関数です。

例えば、セットアップノードやサブジェクトノード内の処理が長くなってしまった場合、以下のように処理のブロックごとにByを書きます。
このようにしておくことで、テスト実行時にどこまで処理が進んだのか、どこで失敗したのかが分かりやすくなります。

package controllers

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

var _ = Describe("Sample", func() {
	It("should be true", func() {
		By("setting up object")
		// セットアップ処理
		
		By("sending the request to apiserver")
		// APIサーバーへのリクエスト
		
		By("retrieving the result")
		// 結果の取得

		By("checking the result")
		// 結果のチェック
	})
})

Assertion

次にAssertionライブラリであるGomegaの使い方を見ていきましょう。
テスト対象となる機能を実行した結果が、期待したとおりの値になったかどうかをチェックするためにAssertionを利用します。

Gomegaの利用例を以下に示します。

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).Should(ConsistOf(SatisfyAll(
			HaveField("Name", Equal("nginx")),
			HaveField("Image", Equal("nginx:latest")),
			HaveField("Ports", Not(BeEmpty())),
			HaveField("Ports", ConsistOf(HaveField("ContainerPort", BeNumerically("==", 80)))),
		)))
	})
})

例えば、Expect(dep1.Namespace).Should(Equal("default"))は、dep1.Namespaceの値が"default"と一致していることを確認しています。
もし一致しなければテスト失敗となり、エラーメッセージが表示されます。

Expect関数には、テスト対象(createDeployment)から得られた結果(dep1)を渡し、その戻り値に対してShould関数を呼び出し、その引数にはMatcher(Equal)を渡します。

Gomegaでは、reflect.DeepEqualを利用して比較するEqual Matcher, 数値を比較するBeNumerically Matcher, mapにキーと値が存在することを確認するHaveKeyWithValue Matcherなど、非常にたくさんのMatcherが用意されています。
詳しくは以下のドキュメントをご覧ください。

https://onsi.github.io/gomega/#provided-matchers

EnvTest

「はじめに」で説明したように、Kindやminikubeを利用したテストは、クラスタの立ち上げにとても時間がかかります。
そこで、etcdとkube-apiserverのプロセスをローカルに立ち上げてテストするためのenvtestパッケージが用意されています。

https://pkg.go.dev/sigs.k8s.io/controller-runtime/pkg/envtest

ローカルに起動したkube-apiserverにアクセスすることで、Kubernetes OperatorはあたかもKubernetesクラスタ上で動作しているのと同じようにテストすることができます。

ただし、EnvTestが立ち上げるのはetcdとkube-apiserverのみであり、kube-schedulerやkube-controller-managerは立ち上がりません。
そのため、Deploymentリソースを作成してもReplicaSetやPodは生成されませんし、Podのステータスが変化することも、OwnerReferenceを付与したリソースが自動削除されることもありません。
テストを書く際には本物のKubernetesクラスタとの挙動の違いに注意する必要があります。

EnvTestが利用するetcdやkube-apiserverなどのバイナリを管理するための、setup-envtestというツールが提供されています。

https://github.com/kubernetes-sigs/controller-runtime/tree/master/tools/setup-envtest

このツールを利用すると、テストしたいKubernetesのバージョンを指定してバイナリをダウンロードし、テスト実行に必要な環境変数を設定することができます。

Kubebuilderを利用すると、setup-envtestとenvtestパッケージを利用したテストコードを自動生成してくれるので、make testを実行するだけでEnvTestによるテストが実行できます。