コントローラーのテスト

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を見てみましょう。

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()を呼び出します。

コントローラーのテスト

それでは実際のテストを書いていきましょう。

markdownview_controller_test.go

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",
        },
    }
}

まずは各テストの実行前と実行後に呼び出されるBeforeEachAfterEachを実装します。

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

results matching ""

    No results matching ""