Skip to content

Commit ca92058

Browse files
committed
Refactor CronJob controller tests: utils extraction, env isolation & structure optimization
1 parent 25d1717 commit ca92058

File tree

1 file changed

+126
-154
lines changed

1 file changed

+126
-154
lines changed
Lines changed: 126 additions & 154 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
/*
2-
32
Licensed under the Apache License, Version 2.0 (the "License");
43
you may not use this file except in compliance with the License.
54
You may obtain a copy of the License at
@@ -14,19 +13,13 @@ limitations under the License.
1413
*/
1514
// +kubebuilder:docs-gen:collapse=Apache License
1615

17-
/*
18-
Ideally, we should have one `<kind>_controller_test.go` for each controller scaffolded and called in the `suite_test.go`.
19-
So, let's write our example test for the CronJob controller (`cronjob_controller_test.go.`)
20-
*/
21-
22-
/*
23-
As usual, we start with the necessary imports. We also define some utility variables.
24-
*/
2516
package controller
2617

2718
import (
2819
"context"
20+
"math/rand"
2921
"reflect"
22+
"strings"
3023
"time"
3124

3225
. "github.com/onsi/ginkgo/v2"
@@ -35,179 +28,158 @@ import (
3528
v1 "k8s.io/api/core/v1"
3629
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
3730
"k8s.io/apimachinery/pkg/types"
31+
"k8s.io/utils/ptr"
3832

3933
cronjobv1 "tutorial.kubebuilder.io/project/api/v1"
4034
)
4135

4236
// +kubebuilder:docs-gen:collapse=Imports
4337

44-
// Helper function to check if a specific condition exists with expected status
45-
func hasCondition(conditions []metav1.Condition, conditionType string, expectedStatus metav1.ConditionStatus) bool {
46-
for _, condition := range conditions {
47-
if condition.Type == conditionType && condition.Status == expectedStatus {
48-
return true
38+
// Helper function to check condition status with proper timestamp validation
39+
func assertCondition(conditions []metav1.Condition, conditionType string, expectedStatus metav1.ConditionStatus) bool {
40+
for _, cond := range conditions {
41+
if cond.Type == conditionType {
42+
return cond.Status == expectedStatus && !cond.LastTransitionTime.IsZero()
4943
}
5044
}
5145
return false
5246
}
5347

54-
/*
55-
The first step to writing a simple integration test is to actually create an instance of CronJob you can run tests against.
56-
Note that to create a CronJob, you’ll need to create a stub CronJob struct that contains your CronJob’s specifications.
48+
// Manually implement random string generation (compatible with Ginkgo versions below v2.1.0)
49+
func randomString(length int) string {
50+
rand.Seed(time.Now().UnixNano() + int64(GinkgoParallelProcess()))
51+
chars := []rune("abcdefghijklmnopqrstuvwxyz0123456789")
52+
var sb strings.Builder
53+
for i := 0; i < length; i++ {
54+
sb.WriteRune(chars[rand.Intn(len(chars))])
55+
}
56+
return sb.String()
57+
}
5758

58-
Note that when we create a stub CronJob, the CronJob also needs stubs of its required downstream objects.
59-
Without the stubbed Job template spec and the Pod template spec below, the Kubernetes API will not be able to
60-
create the CronJob.
61-
*/
62-
var _ = Describe("CronJob controller", func() {
59+
// Helper to create test CronJob
60+
func createTestCronJob(ctx context.Context, name, namespace, schedule string, suspend bool) *cronjobv1.CronJob {
61+
cj := &cronjobv1.CronJob{
62+
ObjectMeta: metav1.ObjectMeta{
63+
Name: name,
64+
Namespace: namespace,
65+
},
66+
Spec: cronjobv1.CronJobSpec{
67+
Schedule: schedule,
68+
Suspend: ptr.To(suspend),
69+
JobTemplate: batchv1.JobTemplateSpec{
70+
Spec: batchv1.JobSpec{
71+
Template: v1.PodTemplateSpec{
72+
Spec: v1.PodSpec{
73+
Containers: []v1.Container{{
74+
Name: "test-container",
75+
Image: "busybox",
76+
}},
77+
RestartPolicy: v1.RestartPolicyOnFailure,
78+
},
79+
},
80+
},
81+
},
82+
},
83+
}
84+
Expect(k8sClient.Create(ctx, cj)).To(Succeed())
85+
return cj
86+
}
6387

64-
// Define utility constants for object names and testing timeouts/durations and intervals.
65-
const (
66-
CronjobName = "test-cronjob"
67-
CronjobNamespace = "default"
68-
JobName = "test-job"
88+
// Helper to create owned Job
89+
func createOwnedJob(ctx context.Context, cj *cronjobv1.CronJob, jobName string, active int32) *batchv1.Job {
90+
gvk := cronjobv1.GroupVersion.WithKind(reflect.TypeOf(cronjobv1.CronJob{}).Name())
91+
job := &batchv1.Job{
92+
ObjectMeta: metav1.ObjectMeta{
93+
Name: jobName,
94+
Namespace: cj.Namespace,
95+
OwnerReferences: []metav1.OwnerReference{
96+
*metav1.NewControllerRef(cj, gvk),
97+
},
98+
},
99+
Spec: cj.Spec.JobTemplate.Spec,
100+
}
101+
102+
Expect(k8sClient.Create(ctx, job)).To(Succeed())
103+
104+
// Update job status
105+
job.Status.Active = active
106+
Expect(k8sClient.Status().Update(ctx, job)).To(Succeed())
107+
return job
108+
}
69109

70-
timeout = time.Second * 10
71-
duration = time.Second * 10
72-
interval = time.Millisecond * 250
110+
var _ = Describe("CronJob controller", func() {
111+
const (
112+
timeout = time.Second * 15
113+
interval = time.Millisecond * 500
73114
)
74115

75-
Context("When updating CronJob Status", func() {
76-
It("Should increase CronJob Status.Active count when new Jobs are created", func() {
77-
By("By creating a new CronJob")
78-
ctx := context.Background()
79-
cronJob := &cronjobv1.CronJob{
80-
TypeMeta: metav1.TypeMeta{
81-
APIVersion: "batch.tutorial.kubebuilder.io/v1",
82-
Kind: "CronJob",
83-
},
84-
ObjectMeta: metav1.ObjectMeta{
85-
Name: CronjobName,
86-
Namespace: CronjobNamespace,
87-
},
88-
Spec: cronjobv1.CronJobSpec{
89-
Schedule: "1 * * * *",
90-
JobTemplate: batchv1.JobTemplateSpec{
91-
Spec: batchv1.JobSpec{
92-
// For simplicity, we only fill out the required fields.
93-
Template: v1.PodTemplateSpec{
94-
Spec: v1.PodSpec{
95-
// For simplicity, we only fill out the required fields.
96-
Containers: []v1.Container{
97-
{
98-
Name: "test-container",
99-
Image: "test-image",
100-
},
101-
},
102-
RestartPolicy: v1.RestartPolicyOnFailure,
103-
},
104-
},
105-
},
106-
},
107-
},
108-
}
109-
Expect(k8sClient.Create(ctx, cronJob)).To(Succeed())
116+
// Use unique names for each test to prevent interference
117+
var (
118+
ns = "cronjob-test-" + randomString(5) // Using manually implemented function
119+
ctx context.Context
120+
cronJobName string
121+
namespacedName types.NamespacedName
122+
)
110123

111-
/*
112-
After creating this CronJob, let's check that the CronJob's Spec fields match what we passed in.
113-
Note that, because the k8s apiserver may not have finished creating a CronJob after our `Create()` call from earlier, we will use Gomega’s Eventually() testing function instead of Expect() to give the apiserver an opportunity to finish creating our CronJob.
124+
// Setup test namespace before each test
125+
BeforeEach(func() {
126+
ctx = context.Background()
127+
cronJobName = "test-cj-" + randomString(5) // Using manually implemented function
128+
namespacedName = types.NamespacedName{Name: cronJobName, Namespace: ns}
114129

115-
`Eventually()` will repeatedly run the function provided as an argument every interval seconds until
116-
(a) the assertions done by the passed-in `Gomega` succeed, or
117-
(b) the number of attempts * interval period exceed the provided timeout value.
130+
// Create test namespace
131+
nsObj := &v1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: ns}}
132+
Expect(k8sClient.Create(ctx, nsObj)).To(Succeed())
133+
})
118134

119-
In the examples below, timeout and interval are Go Duration values of our choosing.
120-
*/
135+
// Cleanup after each test
136+
AfterEach(func() {
137+
nsObj := &v1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: ns}}
138+
Expect(k8sClient.Delete(ctx, nsObj)).To(Succeed())
139+
})
121140

122-
cronjobLookupKey := types.NamespacedName{Name: CronjobName, Namespace: CronjobNamespace}
123-
createdCronjob := &cronjobv1.CronJob{}
141+
Context("Basic CronJob reconciliation", func() {
142+
It("Should create and reconcile CronJob successfully", func() {
143+
By("Creating initial CronJob")
144+
createTestCronJob(ctx, cronJobName, ns, "*/1 * * * *", false)
124145

125-
// We'll need to retry getting this newly created CronJob, given that creation may not immediately happen.
146+
// Verify creation
147+
createdCj := &cronjobv1.CronJob{}
126148
Eventually(func(g Gomega) {
127-
g.Expect(k8sClient.Get(ctx, cronjobLookupKey, createdCronjob)).To(Succeed())
149+
g.Expect(k8sClient.Get(ctx, namespacedName, createdCj)).To(Succeed())
150+
g.Expect(createdCj.Spec.Schedule).To(Equal("*/1 * * * *"))
151+
g.Expect(*createdCj.Spec.Suspend).To(BeFalse())
128152
}, timeout, interval).Should(Succeed())
129-
// Let's make sure our Schedule string value was properly converted/handled.
130-
Expect(createdCronjob.Spec.Schedule).To(Equal("1 * * * *"))
131-
/*
132-
Now that we've created a CronJob in our test cluster, the next step is to write a test that actually tests our CronJob controller’s behavior.
133-
Let’s test the CronJob controller’s logic responsible for updating CronJob.Status.Active with actively running jobs.
134-
We’ll verify that when a CronJob has a single active downstream Job, its CronJob.Status.Active field contains a reference to this Job.
135-
136-
First, we should get the test CronJob we created earlier, and verify that it currently does not have any active jobs.
137-
We use Gomega's `Consistently()` check here to ensure that the active job count remains 0 over a duration of time.
138-
*/
139-
By("By checking the CronJob has zero active Jobs")
153+
})
154+
})
155+
156+
Context("Active Jobs tracking", func() {
157+
It("Should update Active count when Jobs are created", func() {
158+
By("Creating base CronJob")
159+
cj := createTestCronJob(ctx, cronJobName, ns, "*/1 * * * *", false)
160+
161+
By("Verifying initial state has no active jobs")
162+
createdCj := &cronjobv1.CronJob{}
140163
Consistently(func(g Gomega) {
141-
g.Expect(k8sClient.Get(ctx, cronjobLookupKey, createdCronjob)).To(Succeed())
142-
g.Expect(createdCronjob.Status.Active).To(BeEmpty())
143-
}, duration, interval).Should(Succeed())
144-
/*
145-
Next, we actually create a stubbed Job that will belong to our CronJob, as well as its downstream template specs.
146-
We set the Job's status's "Active" count to 2 to simulate the Job running two pods, which means the Job is actively running.
147-
148-
We then take the stubbed Job and set its owner reference to point to our test CronJob.
149-
This ensures that the test Job belongs to, and is tracked by, our test CronJob.
150-
Once that’s done, we create our new Job instance.
151-
*/
152-
By("By creating a new Job")
153-
testJob := &batchv1.Job{
154-
ObjectMeta: metav1.ObjectMeta{
155-
Name: JobName,
156-
Namespace: CronjobNamespace,
157-
},
158-
Spec: batchv1.JobSpec{
159-
Template: v1.PodTemplateSpec{
160-
Spec: v1.PodSpec{
161-
// For simplicity, we only fill out the required fields.
162-
Containers: []v1.Container{
163-
{
164-
Name: "test-container",
165-
Image: "test-image",
166-
},
167-
},
168-
RestartPolicy: v1.RestartPolicyOnFailure,
169-
},
170-
},
171-
},
172-
}
173-
174-
// Note that your CronJob’s GroupVersionKind is required to set up this owner reference.
175-
kind := reflect.TypeOf(cronjobv1.CronJob{}).Name()
176-
gvk := cronjobv1.GroupVersion.WithKind(kind)
177-
178-
controllerRef := metav1.NewControllerRef(createdCronjob, gvk)
179-
testJob.SetOwnerReferences([]metav1.OwnerReference{*controllerRef})
180-
Expect(k8sClient.Create(ctx, testJob)).To(Succeed())
181-
// Note that you can not manage the status values while creating the resource.
182-
// The status field is managed separately to reflect the current state of the resource.
183-
// Therefore, it should be updated using a PATCH or PUT operation after the resource has been created.
184-
// Additionally, it is recommended to use StatusConditions to manage the status. For further information see:
185-
// https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md#spec-and-status
186-
testJob.Status.Active = 2
187-
Expect(k8sClient.Status().Update(ctx, testJob)).To(Succeed())
188-
/*
189-
Adding this Job to our test CronJob should trigger our controller’s reconciler logic.
190-
After that, we can write a test that evaluates whether our controller eventually updates our CronJob’s Status field as expected!
191-
*/
192-
By("By checking that the CronJob has one active Job")
164+
g.Expect(k8sClient.Get(ctx, namespacedName, createdCj)).To(Succeed())
165+
g.Expect(createdCj.Status.Active).To(BeEmpty())
166+
}, time.Second*3, interval).Should(Succeed())
167+
168+
By("Creating owned Job")
169+
job := createOwnedJob(ctx, cj, "test-job-1", 1)
170+
171+
By("Verifying Active count updates")
193172
Eventually(func(g Gomega) {
194-
g.Expect(k8sClient.Get(ctx, cronjobLookupKey, createdCronjob)).To(Succeed(), "should GET the CronJob")
195-
g.Expect(createdCronjob.Status.Active).To(HaveLen(1), "should have exactly one active job")
196-
g.Expect(createdCronjob.Status.Active[0].Name).To(Equal(JobName), "the wrong job is active")
197-
}, timeout, interval).Should(Succeed(), "should list our active job %s in the active jobs list in status", JobName)
173+
g.Expect(k8sClient.Get(ctx, namespacedName, createdCj)).To(Succeed())
174+
g.Expect(createdCj.Status.Active).To(HaveLen(1))
175+
g.Expect(createdCj.Status.Active[0].Name).To(Equal(job.Name))
176+
}, timeout, interval).Should(Succeed())
198177

199-
By("By checking that the CronJob status conditions are properly set")
178+
By("Verifying Available condition is set")
200179
Eventually(func(g Gomega) {
201-
g.Expect(k8sClient.Get(ctx, cronjobLookupKey, createdCronjob)).To(Succeed())
202-
// Check that the Available condition is set to True when job is active
203-
g.Expect(hasCondition(createdCronjob.Status.Conditions, "Available", metav1.ConditionTrue)).To(BeTrue(),
204-
"CronJob should have Available condition set to True")
180+
g.Expect(k8sClient.Get(ctx, namespacedName, createdCj)).To(Succeed())
181+
g.Expect(assertCondition(createdCj.Status.Conditions, "Available", metav1.ConditionTrue)).To(BeTrue())
205182
}, timeout, interval).Should(Succeed())
206183
})
207184
})
208-
209185
})
210-
211-
/*
212-
After writing all this code, you can run `go test ./...` in your `controllers/` directory again to run your new test!
213-
*/

0 commit comments

Comments
 (0)