コントローラーのテスト
controller-runtimeはenvtestというパッケージを提供しており、 コントローラーやWebhookの簡易的なテストを実施できます。
envtestはetcdとkube-apiserverを立ち上げてテスト用の環境を構築します。
また環境変数USE_EXISTING_CLUSTER
を指定すれば、既存のKubernetesクラスターを利用したテストをおこなうことも可能です。
Envtestでは、etcdとkube-apiserverのみを立ち上げており、controller-managerやschedulerは動いていません。 そのため、DeploymentやCronJobリソースを作成しても、Podは作成されないので注意してください。
controller-runtimeは、Envtest Binaries Manager というツールを提供しています。 このツールを利用することで、Envtestで利用するetcdやkube-apiserverの任意のバージョンのバイナリをセットアップできます。
なおcontroller-genが生成するテストコードでは、Ginkgoというテストフレームワークを利用しています。 このフレームワークの利用方法についてはGinkgoのドキュメントを御覧ください。
テスト環境のセットアップ
controller-genによって自動生成されたinternal/controller/suite_test.go
を見てみましょう。
/*
Copyright 2023.
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 controller
import (
"context"
"path/filepath"
"testing"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/runtime"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
"k8s.io/client-go/rest"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/envtest"
logf "sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/log/zap"
viewv1 "github.com/zoetrope/markdown-view/api/v1"
//+kubebuilder:scaffold:imports
)
// These tests use Ginkgo (BDD-style Go testing framework). Refer to
// http://onsi.github.io/ginkgo/ to learn more about Ginkgo.
var cfg *rest.Config
var k8sClient client.Client
var testEnv *envtest.Environment
var scheme = runtime.NewScheme()
func TestControllers(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Controller Suite")
}
var _ = BeforeSuite(func() {
logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true)))
By("bootstrapping test environment")
testEnv = &envtest.Environment{
CRDDirectoryPaths: []string{filepath.Join("..", "..", "config", "crd", "bases")},
ErrorIfCRDPathMissing: true,
}
var err error
// cfg is defined in this file globally.
cfg, err = testEnv.Start()
Expect(err).NotTo(HaveOccurred())
Expect(cfg).NotTo(BeNil())
err = viewv1.AddToScheme(scheme)
Expect(err).NotTo(HaveOccurred())
err = clientgoscheme.AddToScheme(scheme)
Expect(err).NotTo(HaveOccurred())
//+kubebuilder:scaffold:scheme
k8sClient, err = client.New(cfg, client.Options{Scheme: scheme})
Expect(err).NotTo(HaveOccurred())
Expect(k8sClient).NotTo(BeNil())
ns := &corev1.Namespace{}
ns.Name = "test"
err = k8sClient.Create(context.Background(), ns)
Expect(err).NotTo(HaveOccurred())
})
var _ = AfterSuite(func() {
By("tearing down the test environment")
err := testEnv.Stop()
Expect(err).NotTo(HaveOccurred())
})
まずenvtest.Environment
でテスト用の環境設定をおこないます。
ここでは、CRDDirectoryPaths
で適用するCRDのマニフェストのパスを指定しています。
testEnv.Start()
を呼び出すとetcdとkube-apiserverが起動します。
あとはコントローラーのメイン関数と同様に初期化処理をおこなうだけです。
テスト終了時にはetcdとkube-apiserverを終了するようにtestEnv.Stop()
を呼び出します。
コントローラーのテスト
それでは実際のテストを書いていきましょう。
package controller
import (
"context"
"errors"
"time"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
viewv1 "github.com/zoetrope/markdown-view/api/v1"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/intstr"
"k8s.io/utils/pointer"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
)
var _ = Describe("MarkdownView controller", func() {
ctx := context.Background()
var stopFunc func()
BeforeEach(func() {
err := k8sClient.DeleteAllOf(ctx, &viewv1.MarkdownView{}, client.InNamespace("test"))
Expect(err).NotTo(HaveOccurred())
err = k8sClient.DeleteAllOf(ctx, &corev1.ConfigMap{}, client.InNamespace("test"))
Expect(err).NotTo(HaveOccurred())
err = k8sClient.DeleteAllOf(ctx, &appsv1.Deployment{}, client.InNamespace("test"))
Expect(err).NotTo(HaveOccurred())
svcs := &corev1.ServiceList{}
err = k8sClient.List(ctx, svcs, client.InNamespace("test"))
Expect(err).NotTo(HaveOccurred())
for _, svc := range svcs.Items {
err := k8sClient.Delete(ctx, &svc)
Expect(err).NotTo(HaveOccurred())
}
time.Sleep(100 * time.Millisecond)
mgr, err := ctrl.NewManager(cfg, ctrl.Options{
Scheme: scheme,
})
Expect(err).ToNot(HaveOccurred())
reconciler := MarkdownViewReconciler{
Client: k8sClient,
Scheme: scheme,
}
err = reconciler.SetupWithManager(mgr)
Expect(err).NotTo(HaveOccurred())
ctx, cancel := context.WithCancel(ctx)
stopFunc = cancel
go func() {
err := mgr.Start(ctx)
if err != nil {
panic(err)
}
}()
time.Sleep(100 * time.Millisecond)
})
AfterEach(func() {
stopFunc()
time.Sleep(100 * time.Millisecond)
})
It("should create ConfigMap", func() {
mdView := newMarkdownView()
err := k8sClient.Create(ctx, mdView)
Expect(err).NotTo(HaveOccurred())
cm := corev1.ConfigMap{}
Eventually(func() error {
return k8sClient.Get(ctx, client.ObjectKey{Namespace: "test", Name: "markdowns-sample"}, &cm)
}).Should(Succeed())
Expect(cm.Data).Should(HaveKey("SUMMARY.md"))
Expect(cm.Data).Should(HaveKey("page1.md"))
})
It("should create Deployment", func() {
mdView := newMarkdownView()
err := k8sClient.Create(ctx, mdView)
Expect(err).NotTo(HaveOccurred())
dep := appsv1.Deployment{}
Eventually(func() error {
return k8sClient.Get(ctx, client.ObjectKey{Namespace: "test", Name: "viewer-sample"}, &dep)
}).Should(Succeed())
Expect(dep.Spec.Replicas).Should(Equal(pointer.Int32Ptr(3)))
Expect(dep.Spec.Template.Spec.Containers[0].Image).Should(Equal("peaceiris/mdbook:0.4.10"))
})
It("should create Service", func() {
mdView := newMarkdownView()
err := k8sClient.Create(ctx, mdView)
Expect(err).NotTo(HaveOccurred())
svc := corev1.Service{}
Eventually(func() error {
return k8sClient.Get(ctx, client.ObjectKey{Namespace: "test", Name: "viewer-sample"}, &svc)
}).Should(Succeed())
Expect(svc.Spec.Ports[0].Port).Should(Equal(int32(80)))
Expect(svc.Spec.Ports[0].TargetPort).Should(Equal(intstr.FromInt(3000)))
})
It("should update status", func() {
mdView := newMarkdownView()
err := k8sClient.Create(ctx, mdView)
Expect(err).NotTo(HaveOccurred())
updated := viewv1.MarkdownView{}
Eventually(func() error {
err := k8sClient.Get(ctx, client.ObjectKey{Namespace: "test", Name: "sample"}, &updated)
if err != nil {
return err
}
if updated.Status == "" {
return errors.New("status should be updated")
}
return nil
}).Should(Succeed())
})
})
func newMarkdownView() *viewv1.MarkdownView {
return &viewv1.MarkdownView{
ObjectMeta: metav1.ObjectMeta{
Name: "sample",
Namespace: "test",
},
Spec: viewv1.MarkdownViewSpec{
Markdowns: map[string]string{
"SUMMARY.md": `summary`,
"page1.md": `page1`,
},
Replicas: 3,
ViewerImage: "peaceiris/mdbook:0.4.10",
},
}
}
まずは各テストの実行前と実行後に呼び出されるBeforeEach
とAfterEach
を実装します。
BeforeEach
では、テストで利用したリソースをすべて削除します。 (なお、ServiceリソースはDeleteAllOf
をサポートしていないため、1つずつ削除しています。)
その後、MarkdownViewReconcilerを作成し、Reconciliation Loop処理を開始します。
AfterEach
では、BeforeEach
で起動したReconciliation Loop処理を停止します。
次にIt
を利用してテストケースを記述します。
これらのテストケースではk8sClient
を利用してKubernetesクラスターにMarkdownViewリソースを作成し、
その後に期待するリソースが作成されていることを確認しています。
Reconcile処理はテストコードとは非同期に動くため、Eventually関数を利用してリソースが作成できるまで待つようにしています。
なお、newMarkdownView
はテスト用のMarkdownViewリソースを作成するための補助関数です。
最後のテストではStatusが更新されることを確認しています。 本来はここでStatusがHealthyになることをテストすべきでしょう。 しかし、Envtestではcontroller-managerが存在しないためDeploymentがReadyにならず、MarkdownViewのStatusもHealthyになることはありません。 よってここではStatusが何かしら更新されればOKというテストにしています。 Envtestは実際のKubernetesクラスターとは異なるということを意識してテストを書くようにしましょう。
テストが書けたら、make test
で実行してみましょう。
テストに成功すると以下のようにokと表示されます。
? github.com/zoetrope/markdown-view [no test files]
ok github.com/zoetrope/markdown-view/api/v1 6.957s coverage: 51.6% of statements
ok github.com/zoetrope/markdown-view/controllers 8.319s coverage: 85.3% of statements