コントローラーのテスト(Envtest)
ここではEnvtestを利用したカスタムコントローラーのテストの書き方を学びます。
テスト環境のセットアップ
まず、自動生成された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()
を呼び出します。
コントローラーのテスト
それでは実際のテストを書いていきましょう。
/*
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")
})
})
})
まずは各テストの実行前と実行後に呼び出されるBeforeEach
とAfterEach
を実装します。
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