コントローラーのテスト(Envtest)

ここではEnvtestを利用したカスタムコントローラーのテストの書き方を学びます。

テスト環境のセットアップ

まず、自動生成されたinternal/controller/suite_test.goを見てみましょう。

internal/controller/suite_test.go

/*
Copyright 2024.

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 (
    "fmt"
    "path/filepath"
    "runtime"
    "testing"

    . "github.com/onsi/ginkgo/v2"
    . "github.com/onsi/gomega"
    apiruntime "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 = apiruntime.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,

        // The BinaryAssetsDirectory is only required if you want to run the tests directly
        // without call the makefile target test. If not informed it will look for the
        // default path defined in controller-runtime which is /usr/local/kubebuilder/.
        // Note that you must have the required binaries setup under the bin directory to perform
        // the tests directly. When we run make test it will be setup and used automatically.
        BinaryAssetsDirectory: filepath.Join("..", "..", "bin", "k8s",
            fmt.Sprintf("1.30.0-%s-%s", runtime.GOOS, runtime.GOARCH)),
    }

    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())

})

var _ = AfterSuite(func() {
    By("tearing down the test environment")
    err := testEnv.Stop()
    Expect(err).NotTo(HaveOccurred())
})

最初にenvtest.Environmentでテスト用の環境設定をおこなっています。 CRDDirectoryPathsで適用するCRDのマニフェストのパスを指定し、BinaryAssetsDirectoryでEnvtestのバイナリファイルのディレクトリを指定しています。

testEnv.Start()を呼び出すとetcdとkube-apiserverが起動します。 あとはカスタムコントローラーのmain関数と同様にscheme初期化処理をおこない、最後にEnvtestのapiserverに接続するためのクライアントを作成しています。

テスト終了時にはetcdとkube-apiserverを終了するように、AfterSuiteでtestEnv.Stop()を呼び出します。

コントローラーのテスト

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

markdownview_controller_test.go

/*
Copyright 2024.

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"

    . "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"
    apierrors "k8s.io/apimachinery/pkg/api/errors"
    metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    "k8s.io/apimachinery/pkg/types"
    "k8s.io/apimachinery/pkg/util/intstr"
    "k8s.io/utils/ptr"
    "sigs.k8s.io/controller-runtime/pkg/client"
    "sigs.k8s.io/controller-runtime/pkg/reconcile"
)

var _ = Describe("MarkdownView Controller", func() {
    Context("When reconciling a resource", func() {
        const resourceName = "sample"
        const testNamespace = "test"

        ctx := context.Background()

        typeNamespacedName := types.NamespacedName{
            Name:      resourceName,
            Namespace: testNamespace,
        }

        BeforeEach(func() {
            By("creating the namespace for the test")
            ns := &corev1.Namespace{}
            ns.Name = testNamespace
            err := k8sClient.Create(context.Background(), ns)
            Expect(err).NotTo(HaveOccurred())

            By("creating the custom resource for the Kind MarkdownView")
            markdownview := &viewv1.MarkdownView{}
            err = k8sClient.Get(ctx, typeNamespacedName, markdownview)
            if err != nil && apierrors.IsNotFound(err) {
                resource := &viewv1.MarkdownView{
                    ObjectMeta: metav1.ObjectMeta{
                        Name:      resourceName,
                        Namespace: testNamespace,
                    },
                    Spec: viewv1.MarkdownViewSpec{
                        Markdowns: map[string]string{
                            "SUMMARY.md": `summary`,
                            "page1.md":   `page1`,
                        },
                        Replicas:    3,
                        ViewerImage: "peaceiris/mdbook:0.4.10",
                    },
                }
                Expect(k8sClient.Create(ctx, resource)).To(Succeed())
            }
        })

        AfterEach(func() {
            // TODO(user): Cleanup logic after each test, like removing the resource instance.
            resource := &viewv1.MarkdownView{}
            err := k8sClient.Get(ctx, typeNamespacedName, resource)
            Expect(err).NotTo(HaveOccurred())

            By("Cleanup the specific resource instance MarkdownView")
            Expect(k8sClient.Delete(ctx, resource)).To(Succeed())

            err = k8sClient.DeleteAllOf(ctx, &corev1.ConfigMap{}, client.InNamespace(testNamespace))
            Expect(err).NotTo(HaveOccurred())
            err = k8sClient.DeleteAllOf(ctx, &appsv1.Deployment{}, client.InNamespace(testNamespace))
            Expect(err).NotTo(HaveOccurred())
            svcs := &corev1.ServiceList{}
            err = k8sClient.List(ctx, svcs, client.InNamespace(testNamespace))
            Expect(err).NotTo(HaveOccurred())
            for _, svc := range svcs.Items {
                err := k8sClient.Delete(ctx, &svc)
                Expect(err).NotTo(HaveOccurred())
            }
        })

        It("should successfully reconcile the resource", func() {
            By("Reconciling the created resource")
            controllerReconciler := &MarkdownViewReconciler{
                Client: k8sClient,
                Scheme: k8sClient.Scheme(),
            }

            _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{
                NamespacedName: typeNamespacedName,
            })
            Expect(err).NotTo(HaveOccurred())

            By("Making sure the ConfigMap created successfully")
            cm := corev1.ConfigMap{}
            err = k8sClient.Get(ctx, client.ObjectKey{Namespace: testNamespace, Name: "markdowns-sample"}, &cm)
            Expect(err).NotTo(HaveOccurred())
            Expect(cm.Data).Should(HaveKey("SUMMARY.md"))
            Expect(cm.Data).Should(HaveKey("page1.md"))

            By("Making sure the Deployment created successfully")
            dep := appsv1.Deployment{}
            err = k8sClient.Get(ctx, client.ObjectKey{Namespace: testNamespace, Name: "viewer-sample"}, &dep)
            Expect(err).NotTo(HaveOccurred())
            Expect(dep.Spec.Replicas).Should(Equal(ptr.To[int32](3)))
            Expect(dep.Spec.Template.Spec.Containers[0].Image).Should(Equal("peaceiris/mdbook:0.4.10"))

            By("Making sure the Service created successfully")
            svc := corev1.Service{}
            err = k8sClient.Get(ctx, client.ObjectKey{Namespace: testNamespace, Name: "viewer-sample"}, &svc)
            Expect(err).NotTo(HaveOccurred())
            Expect(svc.Spec.Ports[0].Port).Should(Equal(int32(80)))
            Expect(svc.Spec.Ports[0].TargetPort).Should(Equal(intstr.FromInt32(3000)))

            By("Making sure the Status updated successfully")
            updated := viewv1.MarkdownView{}
            err = k8sClient.Get(ctx, typeNamespacedName, &updated)
            Expect(err).NotTo(HaveOccurred())
            Expect(updated.Status.Conditions).ShouldNot(BeEmpty(), "status should be updated")
        })
    })
})

まずは各テストの実行前と実行後に呼び出されるBeforeEachAfterEachを実装します。 BeforeEachでは、テスト用のNamespaceとMarkdownViewリソースを作成しています。 AfterEachでは、テストで利用したリソースを削除していmます。 (なお、ServiceリソースはDeleteAllOfをサポートしていないため、1つずつ削除しています。)

次にItを利用してテストケースを記述します。 このテストケースではk8sClientを利用してKubernetesクラスターにMarkdownViewリソースを作成し、その後に期待するリソースが作成されていることを確認しています。

最後のテストではStatusが更新されることを確認しています。 本来はここでStatusがHealthyになることをテストすべきでしょう。 しかし、Envtestではcontroller-managerが存在しないためDeploymentがReadyにならず、MarkdownViewのStatusもHealthyになることはありません。 よってここではStatusが何かしら更新されればOKというテストにしています。 Envtestは実際のKubernetesクラスターとは異なるということを意識してテストを書くようにしましょう。

テストが書けたら、make testで実行してみましょう。 テストに成功すると以下のようにokと表示されます。

$ make test
KUBEBUILDER_ASSETS="~/markdown-view/bin/k8s/1.30.0-linux-amd64" go test $(go list ./... | grep -v /e2e) -coverprofile cover.out
        github.com/zoetrope/markdown-view/cmd           coverage: 0.0% of statements
        github.com/zoetrope/markdown-view/test/utils            coverage: 0.0% of statements
ok      github.com/zoetrope/markdown-view/api/v1        5.573s  coverage: 51.6% of statements
ok      github.com/zoetrope/markdown-view/internal/controller   6.136s  coverage: 69.6% of statements

results matching ""

    No results matching ""