Skip to content

Commit fbf17de

Browse files
Brian Mendozaamold1
Brian Mendoza
authored andcommitted
enhance with docs
1 parent 749475b commit fbf17de

File tree

8 files changed

+408
-198
lines changed

8 files changed

+408
-198
lines changed

controller/linodeobjectstoragebucket_controller_test.go

Lines changed: 23 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -184,11 +184,11 @@ var _ = Describe("lifecycle", Ordered, Label("bucket", "lifecycle"), func() {
184184
Expect(key.StringData.AccessKeyRO).To(Equal("access-key-1"))
185185
Expect(key.StringData.SecretKeyRO).To(Equal("secret-key-1"))
186186

187-
Expect(mck.Events()).To(ContainSubstring("Object storage keys assigned"))
188-
Expect(mck.Events()).To(ContainSubstring("Object storage keys stored in secret"))
189-
Expect(mck.Events()).To(ContainSubstring("Object storage bucket synced"))
187+
Expect(suite.Events()).To(ContainSubstring("Object storage keys assigned"))
188+
Expect(suite.Events()).To(ContainSubstring("Object storage keys stored in secret"))
189+
Expect(suite.Events()).To(ContainSubstring("Object storage bucket synced"))
190190

191-
logOutput := mck.Logs()
191+
logOutput := suite.Logs()
192192
Expect(logOutput).To(ContainSubstring("Reconciling apply"))
193193
Expect(logOutput).To(ContainSubstring("Secret lifecycle-bucket-details was applied with new access keys"))
194194
}),
@@ -252,9 +252,9 @@ var _ = Describe("lifecycle", Ordered, Label("bucket", "lifecycle"), func() {
252252
Expect(k8sClient.Get(ctx, objectKey, &obj)).To(Succeed())
253253
Expect(*obj.Status.LastKeyGeneration).To(Equal(1))
254254

255-
Expect(mck.Events()).To(ContainSubstring("Object storage keys assigned"))
255+
Expect(suite.Events()).To(ContainSubstring("Object storage keys assigned"))
256256

257-
logOutput := mck.Logs()
257+
logOutput := suite.Logs()
258258
Expect(logOutput).To(ContainSubstring("Reconciling apply"))
259259
Expect(logOutput).To(ContainSubstring("Secret lifecycle-bucket-details was applied with new access keys"))
260260
}),
@@ -308,11 +308,11 @@ var _ = Describe("lifecycle", Ordered, Label("bucket", "lifecycle"), func() {
308308
Expect(key.StringData.AccessKeyRO).To(Equal("access-key-3"))
309309
Expect(key.StringData.SecretKeyRO).To(Equal("secret-key-3"))
310310

311-
Expect(mck.Events()).To(ContainSubstring("Object storage keys retrieved"))
312-
Expect(mck.Events()).To(ContainSubstring("Object storage keys stored in secret"))
313-
Expect(mck.Events()).To(ContainSubstring("Object storage bucket synced"))
311+
Expect(suite.Events()).To(ContainSubstring("Object storage keys retrieved"))
312+
Expect(suite.Events()).To(ContainSubstring("Object storage keys stored in secret"))
313+
Expect(suite.Events()).To(ContainSubstring("Object storage bucket synced"))
314314

315-
logOutput := mck.Logs()
315+
logOutput := suite.Logs()
316316
Expect(logOutput).To(ContainSubstring("Reconciling apply"))
317317
Expect(logOutput).To(ContainSubstring("Secret lifecycle-bucket-details was applied with new access keys"))
318318
}),
@@ -348,9 +348,9 @@ var _ = Describe("lifecycle", Ordered, Label("bucket", "lifecycle"), func() {
348348
Expect(err).NotTo(HaveOccurred())
349349
Expect(apierrors.IsNotFound(k8sClient.Get(ctx, objectKey, &obj))).To(BeTrue())
350350

351-
Expect(mck.Events()).To(ContainSubstring("Object storage keys revoked"))
351+
Expect(suite.Events()).To(ContainSubstring("Object storage keys revoked"))
352352

353-
logOutput := mck.Logs()
353+
logOutput := suite.Logs()
354354
Expect(logOutput).To(ContainSubstring("Reconciling delete"))
355355
}),
356356
),
@@ -411,7 +411,7 @@ var _ = Describe("errors", Label("bucket", "errors"), func() {
411411
NamespacedName: client.ObjectKeyFromObject(bScope.Bucket),
412412
})
413413
Expect(err.Error()).To(ContainSubstring("non-404 error"))
414-
Expect(mck.Logs()).To(ContainSubstring("Failed to fetch LinodeObjectStorageBucket"))
414+
Expect(suite.Logs()).To(ContainSubstring("Failed to fetch LinodeObjectStorageBucket"))
415415
}),
416416
),
417417
),
@@ -422,7 +422,7 @@ var _ = Describe("errors", Label("bucket", "errors"), func() {
422422
NamespacedName: client.ObjectKeyFromObject(bScope.Bucket),
423423
})
424424
Expect(err.Error()).To(ContainSubstring("failed to create object storage bucket scope"))
425-
Expect(mck.Logs()).To(ContainSubstring("Failed to create object storage bucket scope"))
425+
Expect(suite.Logs()).To(ContainSubstring("Failed to create object storage bucket scope"))
426426
}),
427427
Call("scheme with no infrav1alpha1", func(ctx context.Context, mck Mock) {
428428
prev := mck.K8sClient.EXPECT().Scheme().Return(scheme.Scheme)
@@ -456,8 +456,8 @@ var _ = Describe("errors", Label("bucket", "errors"), func() {
456456
bScope.Client = mck.K8sClient
457457
err := reconciler.reconcileApply(ctx, &bScope)
458458
Expect(err.Error()).To(ContainSubstring("api error"))
459-
Expect(mck.Events()).To(ContainSubstring("api error"))
460-
Expect(mck.Logs()).To(ContainSubstring("Failed to ensure access key secret exists"))
459+
Expect(suite.Events()).To(ContainSubstring("api error"))
460+
Expect(suite.Logs()).To(ContainSubstring("Failed to ensure access key secret exists"))
461461
}),
462462
),
463463
Call("secret deleted", func(ctx context.Context, mck Mock) {
@@ -485,9 +485,9 @@ var _ = Describe("errors", Label("bucket", "errors"), func() {
485485
bScope.Client = mck.K8sClient
486486
err := reconciler.reconcileApply(ctx, &bScope)
487487
Expect(err.Error()).To(ContainSubstring("secret creation error"))
488-
Expect(mck.Events()).To(ContainSubstring("keys retrieved"))
489-
Expect(mck.Events()).To(ContainSubstring("secret creation error"))
490-
Expect(mck.Logs()).To(ContainSubstring("Failed to apply key secret"))
488+
Expect(suite.Events()).To(ContainSubstring("keys retrieved"))
489+
Expect(suite.Events()).To(ContainSubstring("secret creation error"))
490+
Expect(suite.Logs()).To(ContainSubstring("Failed to apply key secret"))
491491
}),
492492
),
493493
Path(
@@ -504,9 +504,9 @@ var _ = Describe("errors", Label("bucket", "errors"), func() {
504504
bScope.Client = mck.K8sClient
505505
err := reconciler.reconcileApply(ctx, &bScope)
506506
Expect(err.Error()).To(ContainSubstring("no kind is registered"))
507-
Expect(mck.Events()).To(ContainSubstring("keys retrieved"))
508-
Expect(mck.Events()).To(ContainSubstring("no kind is registered"))
509-
Expect(mck.Logs()).To(ContainSubstring("Failed to generate key secret"))
507+
Expect(suite.Events()).To(ContainSubstring("keys retrieved"))
508+
Expect(suite.Events()).To(ContainSubstring("no kind is registered"))
509+
Expect(suite.Logs()).To(ContainSubstring("Failed to generate key secret"))
510510
}),
511511
),
512512
),
@@ -522,7 +522,7 @@ var _ = Describe("errors", Label("bucket", "errors"), func() {
522522
bScope.Client = mck.K8sClient
523523
err := reconciler.reconcileDelete(ctx, &bScope)
524524
Expect(err.Error()).To(ContainSubstring("failed to remove finalizer from bucket"))
525-
Expect(mck.Events()).To(ContainSubstring("failed to remove finalizer from bucket"))
525+
Expect(suite.Events()).To(ContainSubstring("failed to remove finalizer from bucket"))
526526
}),
527527
))
528528
})

docs/src/developers/testing.md

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,177 @@ In order to run the unit tests run the following command
66
```bash
77
make test
88
```
9+
### Adding tests
10+
General unit tests of functions follow the same conventions for testing using Go's `testing` standard library, along with the [testify](https://github.com/stretchr/testify) toolkit for making assertions.
11+
12+
Unit tests that require API clients use mock clients generated using [gomock](https://github.com/uber-go/mock). To simplify the usage of mock clients, this repo also uses an internal library defined in `mock/mocktest`.
13+
14+
`mocktest` is usually imported as a dot import along with the `mock` package:
15+
16+
```go
17+
import (
18+
"github.com/linode/cluster-api-provider-linode/mock"
19+
20+
. "github.com/linode/cluster-api-provider-linode/mock/mocktest"
21+
)
22+
```
23+
24+
Using `mocktest` involves creating a test suite that specifies the mock clients to be used within each test scope and running the test suite using a DSL for defnining test nodes belong to one or more `Paths`.
25+
26+
#### Example
27+
The following is a contrived example using the mock Linode machine client.
28+
29+
Let's say we've written an idempotent function `EnsureInstanceRuns` that 1) gets an instance or creates it if it doesn't exist, 2) boots the instance if it's offline. Testing this function would mean we'd need to write test cases for all permutations, i.e.
30+
* instance exists and is not offline
31+
* instance exists but is offline, and is able to boot
32+
* instance exists but is offline, and is not able to boot
33+
* instance does not exist, and is not able to be created
34+
* instance does not exist, and is able to be created, and is able to boot
35+
* instance does not exist, and is able to be created, and is not able to boot
36+
37+
While writing test cases for each scenario, we'd likely find a lot of overlap between each. `mocktest` provides a DSL for defining each unique test case without needing to spell out all required mock client calls for each case. Here's how we could test `EnsureInstanceRuns` using `mocktest`:
38+
39+
```go
40+
func TestEnsureInstanceNotOffline(t *testing.T) {
41+
suite := NewTestSuite(t, mock.MockLinodeMachineClient{})
42+
43+
suite.Run(t, Paths(
44+
Either(
45+
Path(
46+
Call("instance exists and is not offline", func(ctx context.Context, mck Mock) {
47+
mck.MachineClient.EXPECT().GetInstance(ctx, /* ... */).Return(&linodego.Instance{Status: linodego.InstanceRunning}, nil)
48+
}),
49+
Result("success", func(ctx context.Context, mck Mock) {
50+
inst, err := EnsureInstanceNotOffline(ctx, /* ... */)
51+
require.NoError(t, err)
52+
assert.Equal(t, inst.Status, linodego.InstanceRunning)
53+
})
54+
),
55+
Path(
56+
Call("instance does not exist", func(ctx context.Context, mck Mock) {
57+
mck.MachineClient.EXPECT().GetInstance(ctx, /* ... */).Return(nil, linodego.Error{Code: 404})
58+
}),
59+
Either(
60+
Call("able to be created", func(ctx context.Context, mck Mock) {
61+
mck.MachineClient.EXPECT().CreateInstance(ctx, /* ... */).Return(&linodego.Instance{Status: linodego.InstanceOffline}, nil)
62+
}),
63+
Path(
64+
Call("not able to be created", func(ctx context.Context, mck Mock) {/* ... */})
65+
Result("error", func(ctx context.Context, mck Mock) {
66+
inst, err := EnsureInstanceNotOffline(ctx, /* ... */)
67+
require.ErrorContains(t, err, "instance was not booted: failed to create instance: reasons...")
68+
assert.Empty(inst)
69+
})
70+
)
71+
),
72+
),
73+
Call("instance exists but is offline", func(ctx context.Context, mck Mock) {
74+
mck.MachineClient.EXPECT().GetInstance(ctx, /* ... */).Return(&linodego.Instance{Status: linodego.InstanceOffline}, nil)
75+
}),
76+
),
77+
Either(
78+
Path(
79+
Call("able to boot", func(ctx context.Context, mck Mock) {/* */})
80+
Result("success", func(ctx context.Context, mck Mock) {
81+
inst, err := EnsureInstanceNotOffline(ctx, /* ... */)
82+
require.NoError(t, err)
83+
assert.Equal(t, inst.Status, linodego.InstanceBooting)
84+
})
85+
),
86+
Path(
87+
Call("not able to boot", func(ctx context.Context, mck Mock) {/* returns API error */})
88+
Result("error", func(ctx context.Context, mck Mock) {
89+
inst, err := EnsureInstanceNotOffline(/* options */)
90+
require.ErrorContains(t, err, "instance was not booted: boot failed: reasons...")
91+
assert.Empty(inst)
92+
})
93+
)
94+
),
95+
)
96+
}
97+
```
98+
In this example, the nodes passed into `Paths` are used to describe each permutation of the function being called with different results from the mock Linode machine client.
99+
100+
#### Nodes
101+
* `Call` describes the behavior of method calls by mock clients. A `Call` node can belong to one or more paths.
102+
* `Result` invokes the function with mock clients and tests the output. A `Result` node terminates each path it belongs to.
103+
* `Path` is a list of nodes that all belong to the same test path. Each child node of a `Path` is evaluated in order.
104+
* `Either` is a list of nodes that all belong to different test paths. It is used to define diverging test path, with each path containing the set of all preceding `Call` nodes.
105+
106+
#### Setup, tear down, and event triggers
107+
Setup and teardown nodes can be scheduled before and after each run:
108+
* `suite.BeforeEach` receives a `func(context.Context, Mock)` function that will run before each path is evaluated. Likewise, `suite.AfterEach` will run after each path is evaluated.
109+
* `suite.BeforeAll` receives a `func(context.Context, Mock)` function taht will run once before all paths are evaluated. Likewise, `suite.AfterEach` will run after each path is evaluated.
110+
111+
In addition to the path nodes listed in the section above, a special node type `Once` may be specified to inject a function that will only be evaluated one time across all paths. It can be used to trigger side effects outside of mock client behavior that can impact the output of the function being tested.
112+
113+
#### Control flow
114+
When `Run` is called on a test suite, paths are evaluated in parallel using `t.Parallel()`. Each path will be run with a separate `t.Run` call, and each test run will be named according to the descriptions specified in each node.
115+
116+
To help with visualizing the paths that will be rendered from nodes, a `Describe` helper method can be called which returns a slice of strings describing each path. For instance, the following shows the output of `Describe` on the paths described in the example above:
117+
118+
```go
119+
paths := Paths(/* see example above */)
120+
121+
paths.Describe() /* [
122+
"instance exists and is not offline > success",
123+
"instance does not exist > not able to be created > error",
124+
"instance does not exist > able to be created > able to boot > success",
125+
"instance does not exist > able to be created > not able to boot > error",
126+
"instance exists but is offline > able to boot > success",
127+
"instance exists but is offline > not able to boot > error"
128+
] */
129+
```
130+
131+
#### Testing controllers
132+
CAPL uses controller-runtime's [envtest](https://book.kubebuilder.io/reference/envtest) package which runs an instance of etcd and the Kubernetes API server for testing controllers. The test setup uses [ginkgo](https://onsi.github.io/ginkgo/) as its test runner as well as [gomega](https://onsi.github.io/gomega/) for assertions.
133+
134+
`mocktest` is also recommended when writing tests for controllers. The following is another contrived example of how to use it within the context of a Ginkgo `Describe` node:
135+
136+
```go
137+
// Add the `Ordered` decorator so that tests run in order versus in parallel.
138+
// This is needed when relying on EnvTest for managing Kubernetes API server state.
139+
var _ = Describe("test name", Ordered, func() {
140+
// Create a mocktest controller test suite.
141+
suite := NewControllerTestSuite(GinkgoT(), mock.MockLinodeMachineClient{})
142+
143+
obj := infrav1alpha1.LinodeMachine{
144+
ObjectMeta: metav1.ObjectMeta{/* ... */}
145+
Spec: infrav1alpha1.LinodeMachineSpec{/* ... */}
146+
}
147+
148+
suite.Run(Paths(
149+
Once("create resource", func(ctx context.Context, _ Mock) {
150+
// Use the EnvTest k8sClient to create the resource in the test server
151+
Expect(k8sClient.Create(ctx, &obj).To(Succeed()))
152+
})
153+
Call("create a linode", func(ctx context.Context, mck Mock) {
154+
mck.MachineClient.CreateInstance(ctx, gomock.Any(), gomock.Any()).Return(&linodego.Instance{/* ... */}, nil)
155+
})
156+
Result("update the resource with info about the linode", func(ctx context.Context, mck Mock) {
157+
reconciler := LinodeMachineReconciler{
158+
// Configure the reconciler to use the mock client for this test path
159+
LinodeClient: mck.MachineClient,
160+
// Use a managed recorder for capturing events published during this test
161+
Recorder: suite.Recorder(),
162+
// Use a managed logger for capturing logs written during the test
163+
Logger: suite.Logger(), // Note: This isn't a real struct field. A logger is configured elsewhere.
164+
}
165+
166+
_, err := reconciler.Reconcile(ctx, reconcile.Request{/* ... */})
167+
Expect(err).NotTo(HaveOccurred())
168+
169+
// Fetch the updated object in the test server and confirm it was updated
170+
Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(obj))).To(Succeed())
171+
Expect(obj.Status.Ready).To(BeTrue())
172+
173+
// Check for expected events and logs
174+
Expect(suite.Events()).To(ContainSubstring("Linode created!"))
175+
Expect(suite.Logs()).To(ContainSubstring("Linode created!"))
176+
})
177+
))
178+
})
179+
```
9180

10181
## E2E Tests
11182
For e2e tests CAPL uses the [Chainsaw project](https://kyverno.github.io/chainsaw) which leverages `kind` and `tilt` to

mock/mocktest/controller_suite.go

Lines changed: 0 additions & 78 deletions
This file was deleted.

0 commit comments

Comments
 (0)