kubebuilder 中文文档由云原生社区主导翻译。任何问题可以在这儿提issue。issue模版可以参考这个

编写控制器测试示例

测试 Kubernetes 控制器是一个大的课题,kubebuilder 为您生成的样板测试文件相当少。

为了带您了解 Kubebuilder 生成的控制器的集成测试模式,我们将重新阅读一遍我们在第一篇教程中构建的 CronJob,并为它编写一个简单的测试。

基本的方法是,在生成的 suite_test.go 文件中,您将用 envtest 去创建一个本地 Kubernetes API 服务端,并实例化和运行你的控制器,然后编写附加的 *_test.go 文件并用 Ginko 去测试它。

如果您想修改您的 envtest 集群的配置方式,请查看 编写和运行集成测试envtest docs 章节。

测试环境配置

../../cronjob-tutorial/testdata/project/controllers/suite_test.go

当我们在之前章节kubebuilder create api 创建 CronJob API,Kubebuilder 已经为您创建了一些测试工作。 Kubebuilder 生成了一个用于配置基本测试环境框架的文件 controllers/suite_test.go

首先,它将包含必要的 imports 。

Apache License

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.

Imports
package controllers

import (
	"path/filepath"
	"testing"

	. "github.com/onsi/ginkgo"
	. "github.com/onsi/gomega"
	"k8s.io/client-go/kubernetes/scheme"
	"k8s.io/client-go/rest"
	ctrl "sigs.k8s.io/controller-runtime"
	"sigs.k8s.io/controller-runtime/pkg/client"
	"sigs.k8s.io/controller-runtime/pkg/envtest"
	"sigs.k8s.io/controller-runtime/pkg/envtest/printer"
	logf "sigs.k8s.io/controller-runtime/pkg/log"
	"sigs.k8s.io/controller-runtime/pkg/log/zap"

	batchv1 "tutorial.kubebuilder.io/project/api/v1"
	// +kubebuilder:scaffold:imports
)

// 这些测试示例使用了 Ginkgo (BDD-style Go 测试框架)。学习更多关于 Ginkgo 请参考 http://onsi.github.io/ginkgo/。

现在,让我们看看生成的代码。

var cfg *rest.Config
var k8sClient client.Client //  您将在你的测试代码中使用这个 client。
var testEnv *envtest.Environment

var _ = BeforeSuite(func(done Done) {
	logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true)))

首先, envtest 集群将从 Kubebuilder 为您生成的 CRD 目录下读取 CRD 信息。

	By("bootstrapping test environment")
	testEnv = &envtest.Environment{
		CRDDirectoryPaths: []string{filepath.Join("..", "config", "crd", "bases")},
	}

然后,我们启动 envtest 集群。

	var err error
	cfg, err = testEnv.Start()
	Expect(err).ToNot(HaveOccurred())
	Expect(cfg).ToNot(BeNil())

自动生成的测试代码将把 CronJob Kind schema 添加到默认的 client-go k8s scheme 中。 这保证了 CronJob 的 API/Kind 可以在我们控制器测试代码中正常使用。

	err = batchv1.AddToScheme(scheme.Scheme)
	Expect(err).NotTo(HaveOccurred())

schemas 之后,你将看到下面标记。 当一个新的 API 添加到项目中,这个标记允许新的 schemas 自动的添加到这里。

	// +kubebuilder:scaffold:scheme

为我们测试 CRUD 操作创建一个客户端。

	k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme})
	Expect(err).ToNot(HaveOccurred())
	Expect(k8sClient).ToNot(BeNil())

然而,这个自动生成的文件缺少了实际启动控制器的方法。 上面的代码将会建立一个和您自定义的 Kind 交互的客户端,但是无法测试您的控制器的行为。 如果你想要测试自定义的控制器逻辑,您需要添加一些相似的管理逻辑到 BeforeSuite() 函数, 这样就可以将你的自定义控制器运行在这个测试集群上。

您可能注意到了,下面运行在控制器中的逻辑代码几乎和您的 CronJob 项目中的 main.go 中是相同的! 唯一不同的是 manager 启动在一个独立的 goroutine 中,因此,当您运行完测试后,它不会阻止 envtest 的清理工作。

一旦添加了下面的代码,你将可以删除掉上面的 k8sClient,因为你可以从 manager 中获取到 k8sClient (如下所示)。

	k8sManager, err := ctrl.NewManager(cfg, ctrl.Options{
		Scheme: scheme.Scheme,
	})
	Expect(err).ToNot(HaveOccurred())

	err = (&CronJobReconciler{
		Client: k8sManager.GetClient(),
		Log:    ctrl.Log.WithName("controllers").WithName("CronJob"),
	}).SetupWithManager(k8sManager)
	Expect(err).ToNot(HaveOccurred())

	go func() {
		err = k8sManager.Start(ctrl.SetupSignalHandler())
		Expect(err).ToNot(HaveOccurred())
	}()

	k8sClient = k8sManager.GetClient()
	Expect(k8sClient).ToNot(BeNil())

	close(done)
}, 60)

Kubebuilder 还为清除 envtest 和在 controller/ 目录中实际运行测试的文件生成样板函数。 你不需要更改这部分代码

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

func TestAPIs(t *testing.T) {
	RegisterFailHandler(Fail)

	RunSpecsWithDefaultAndCustomReporters(t,
		"Controller Suite",
		[]Reporter{printer.NewlineReporter{}})
}

现在,您已经在测试集群上运行了控制器,并且客户端已经准备好对 CronJob 执行操作,我们可以开始编写集成测试了!

测试控制器行为

../../cronjob-tutorial/testdata/project/controllers/cronjob_controller_test.go

理想情况下,每个控制器都应该存在对应的测试文件 <kind>_conroller_test.go,并在 test_suite.go 中调用。 接下来,让我们为CronJob控制器编写示例测试(cronjob_controller_test.go。)

Apache License

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.

Imports

像往常一样,我们从必要的导入开始。我们还定义了一些有用的变量。

package controllers

import (
	"context"
	"reflect"
	"time"

	. "github.com/onsi/ginkgo"
	. "github.com/onsi/gomega"
	batchv1 "k8s.io/api/batch/v1"
	batchv1beta1 "k8s.io/api/batch/v1beta1"
	v1 "k8s.io/api/core/v1"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/types"

	cronjobv1 "tutorial.kubebuilder.io/project/api/v1"
)

编写一个简单的集成测试的第一步是真实的创建一个您可以运行测试的 CronJob 的实例。 请注意,要创建一个 CronJob,你需要先创建一个包含 CronJob 定义的 stub 结构体。

请注意,当我们创建一个存根 CronJob ,CronJob 还需要它所需要的下游对象的存根。 没有下面存根的 Job 模板 spec 和 Pod 模板 spec ,Kubernetes API 将不能创建 CronJob 。

var _ = Describe("CronJob controller", func() {

	// 定义对象名称、测试超时时间、持续时间以及测试间隔等常量。
	const (
		CronjobName      = "test-cronjob"
		CronjobNamespace = "default"
		JobName          = "test-job"

		timeout  = time.Second * 10
		duration = time.Second * 10
		interval = time.Millisecond * 250
	)

	Context("When updating CronJob Status", func() {
		It("Should increase CronJob Status.Active count when new Jobs are created", func() {
			By("By creating a new CronJob")
			ctx := context.Background()
			cronJob := &cronjobv1.CronJob{
				TypeMeta: metav1.TypeMeta{
					APIVersion: "batch.tutorial.kubebuilder.io/v1",
					Kind:       "CronJob",
				},
				ObjectMeta: metav1.ObjectMeta{
					Name:      CronjobName,
					Namespace: CronjobNamespace,
				},
				Spec: cronjobv1.CronJobSpec{
					Schedule: "1 * * * *",
					JobTemplate: batchv1beta1.JobTemplateSpec{
						Spec: batchv1.JobSpec{
							// 简单起见,我们只填写必需的字段。
							Template: v1.PodTemplateSpec{
								Spec: v1.PodSpec{
									// 简单起见,我们只填写必需的字段。
									Containers: []v1.Container{
										{
											Name:  "test-container",
											Image: "test-image",
										},
									},
									RestartPolicy: v1.RestartPolicyOnFailure,
								},
							},
						},
					},
				},
			}
			Expect(k8sClient.Create(ctx, cronJob)).Should(Succeed())

		

在创建这个 CronJob 之后,让我们检查 CronJob 的 Spec 字段与我们传入的字段是否匹配。 请注意,因为 k8s apiserver 在前面的 ‘Create()’ 调用之后可能还没有完成 CronJob 的创建,我将用 Gomega 的 Eventually() 测试函数代替 Expect() 去给 apiserver 一个机会去完成CronJob的创建。

Eventually() 方法每隔一个时间间隔执行一次参数中指定的函数,直到满足下列两个条件之一才会退出方法。 (a) 函数的输出与Should()调用的期望输出匹配 (b) 重试时间(重试次数 * 间隔周期)大于指定的超时时间

在下面的示例中,timeout 和 interval 是我们选择的 Go Duration 值。

			cronjobLookupKey := types.NamespacedName{Name: CronjobName, Namespace: CronjobNamespace}
			createdCronjob := &cronjobv1.CronJob{}

			// 创建操作可能不会立马完成,因此我们需要多次重试去获取这个新建的 CronJob。
			Eventually(func() bool {
				err := k8sClient.Get(ctx, cronjobLookupKey, createdCronjob)
				if err != nil {
					return false
				}
				return true
			}, timeout, interval).Should(BeTrue())
			// 让我们确保我们的Schedule 字符串值被正确地转换/处理。
			Expect(createdCronjob.Spec.Schedule).Should(Equal("1 * * * *"))
		

现在,我们已经在我们的测试集群中创建了一个CronJob,下一步是写一个测试用例去真正的测试我们 CronJob 控制器的行为。 让我们测试一下 CronJob 控制器根据正在运行的 Jobs 更新 CronJob.Status.Active 的逻辑。 我们将验证当 CronJob 有一个活动的下游 Job ,它的 CronJob.Status.Active 字段包含对该Job的引用。

首先,我们应该获取之前创建的测试 CronJob ,并验证它目前没有任何正在运行的 Job。 在这里我们使用 Gomega 的 Consistently() 检查,以确保正在运行的 Job 总数在一段时间内保持为 0 。

			By("By checking the CronJob has zero active Jobs")
			Consistently(func() (int, error) {
				err := k8sClient.Get(ctx, cronjobLookupKey, createdCronjob)
				if err != nil {
					return -1, err
				}
				return len(createdCronjob.Status.Active), nil
			}, duration, interval).Should(Equal(0))
		

下一步,为我们的 CronJob 创建一个 Job 的 stub 对象以及它的下游模板 specs 。 我们将 Job 的状态 “Active” 设置为 2,来模拟当前 Job 运行了 2 个 pod ,这表示我们的 Job 正在运行。

然后,我们获取 Job 的 stub 对象 ,并将其所有者引用指向我们的测试 CronJob 。 这确保了测试 Job 属于我们的测试 CronJob ,并被它跟踪。 一旦完成,我们就创建新的 Job 实例。

			By("By creating a new Job")
			testJob := &batchv1.Job{
				ObjectMeta: metav1.ObjectMeta{
					Name:      JobName,
					Namespace: CronjobNamespace,
				},
				Spec: batchv1.JobSpec{
					Template: v1.PodTemplateSpec{
						Spec: v1.PodSpec{
							// 简单起见,我们只填写必需的字段。
							Containers: []v1.Container{
								{
									Name:  "test-container",
									Image: "test-image",
								},
							},
							RestartPolicy: v1.RestartPolicyOnFailure,
						},
					},
				},
				Status: batchv1.JobStatus{
					Active: 2,
				},
			}

			// 请注意,所有者引用需要配置 CronJob 的 GroupVersionKind。
			kind := reflect.TypeOf(cronjobv1.CronJob{}).Name()
			gvk := cronjobv1.GroupVersion.WithKind(kind)

			controllerRef := metav1.NewControllerRef(createdCronjob, gvk)
			testJob.SetOwnerReferences([]metav1.OwnerReference{*controllerRef})
			Expect(k8sClient.Create(ctx, testJob)).Should(Succeed())
		

添加这个 Job 到我们的测试 CronJob 中将会触发我们的控制器的协调逻辑。 之后,我们可以编写一个测试用例验证我们的控制器最终是否按照预期更新了我们的 CronJob 的状态字段!

			By("By checking that the CronJob has one active Job")
			Eventually(func() ([]string, error) {
				err := k8sClient.Get(ctx, cronjobLookupKey, createdCronjob)
				if err != nil {
					return nil, err
				}

				names := []string{}
				for _, job := range createdCronjob.Status.Active {
					names = append(names, job.Name)
				}
				return names, nil
			}, timeout, interval).Should(ConsistOf(JobName), "should list our active job %s in the active jobs list in status", JobName)
		})
	})

})

完成这些代码后,您可以在 controllers/ 目录下执行 go test ./... ,运行新的测试代码!

上面状态更新的示例演示了一个带有下游对象的自定义 Kind 的通用测试策略。到此,希望您已经学到了下列用于测试控制器行为的方法:

  • 配置你的控制器运行在 envtest 集群上
  • 为创建测试对象编写测试示例
  • 隔离对对象的更改,以测试特定的控制器行为

高级示例

还有更多使用 envtest 来严格测试控制器行为的例子。包括: