Skip to content

Commit 5c94a50

Browse files
committed
Add tutorial for writing Component tests
1 parent d398771 commit 5c94a50

5 files changed

Lines changed: 208 additions & 0 deletions

File tree

Makefile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ all: pdf
1010

1111
pdf: build/tutorial.pdf
1212

13+
.PHONY: build/tutorial.pdf
1314
build/tutorial.pdf: tutorial.adoc
1415
$(asciidoctor_pdf_cmd) $(asciidoctor_opts) $<
1516

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package main
2+
3+
import (
4+
"github.com/stretchr/testify/assert"
5+
"testing"
6+
)
7+
8+
var (
9+
testPath = "../../compiled/espejo/espejo"
10+
)
11+
12+
func Test_Deployment_DefaultParameters(t *testing.T) {
13+
14+
subject := DecodeDeployment(t, testPath+"/10_deployment.yaml")
15+
container := subject.Spec.Template.Spec.Containers[0]
16+
17+
assert.Equal(t, "espejo", container.Name)
18+
assert.Contains(t, container.Args, "--verbose=false")
19+
assert.Contains(t, container.Args, "--reconcile-interval=10m")
20+
assert.Contains(t, container.Args, "--metrics-addr=:8080")
21+
assert.Contains(t, container.Args, "--enable-leader-election=true")
22+
23+
env := container.Env[0]
24+
assert.Equal(t, "WATCH_NAMESPACE", env.Name)
25+
assert.Equal(t, "metadata.namespace", env.ValueFrom.FieldRef.FieldPath)
26+
}
27+
28+
func Test_Namespace(t *testing.T) {
29+
30+
subject := DecodeNamespace(t, testPath+"/01_namespace.yaml")
31+
32+
assert.Equal(t, "syn-espejo", subject.Name)
33+
assert.Contains(t, subject.Labels, "name")
34+
}

docs/modules/ROOT/nav.adoc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
.Tutorial
22
* xref:index.adoc[Home]
3+
* xref:testing.adoc[Tests]
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
ifndef::backend-pdf[]
2+
:examplesdir: example$
3+
endif::[]
4+
5+
= Tutorial: Writing Commodore Component Tests
6+
7+
This tutorial covers the topic of writing tests for your new or existing Commodore Component.
8+
It assumes that you are familiar with writing Commodore Components.
9+
If not, see link:index.adoc[Writing your First Commodore Component].
10+
11+
Currently, we can test components with two approaches:
12+
13+
. Unit tests with Go. Easy to understand and write if you are already a Go developer.
14+
. Policy tests with Conftest. Uses the https://www.openpolicyagent.org/docs/latest/policy-language/[Rego] syntax from https://www.openpolicyagent.org/[OpenPolicyAgent].
15+
16+
It is up to you to decide which test framework you want to use.
17+
Some tests are simpler to do in Go, some are simpler in Rego.
18+
A combination of both will combine their advantages.
19+
20+
NOTE: The policy tests run with the Conftest tool, but for the purpose of this tutorial we will refer to the Rego language, as the policies are written in that syntax.
21+
22+
== Requirements
23+
24+
NOTE: This tutorial *was written on a Linux system*.
25+
26+
. `Go` version 1.15, developer environment with Go modules enabled.
27+
. `make` version 4
28+
. `docker` version 19
29+
30+
== Setting up test infrastructure with Go
31+
32+
We'll start with Go.
33+
Create the following directory structure:
34+
[source,console]
35+
----
36+
.
37+
├── tests
38+
│   ├── test.yml
39+
│   └── unit
40+
│   ├── defaults_test.go
41+
│   ├── go.mod
42+
│   └── go.sum
43+
----
44+
The `go.mod` and `go.sum` files are created when executing `go mod init` inside `test/unit/`.
45+
Since we are only creating test code and not an actual Go binary, all Go test files have to end with `_test.go`.
46+
`tests/test.yml` is sometimes used by components to override values that would only be needed by Commodore when compiling whole catalogs, you can leave it empty for now.
47+
We will now start writing the first tests in `defaults_test.go`.
48+
49+
== Writing unit tests with Go
50+
51+
If you are already a Go developer, these should look fairly familiar to you.
52+
We will showcase the tests with the Espejo component.
53+
If you have `component-somename`, then leave out `component-`.
54+
55+
[source,go]
56+
----
57+
include::{examplesdir}defaults_test.go[]
58+
----
59+
60+
CAUTION: We have not yet built a library to host the boilerplate code and common functions.
61+
62+
As you can see, it's pretty straight forward:
63+
64+
. First, load the pre-compiled YAML file into a Go K8s struct that we all know and love
65+
. Then, we verify if the values were parsed correctly, using any assertion library of your choice.
66+
67+
To actually run our unit test case, we need to run a Commodore Component compilation first.
68+
Luckiliy, we have already a `make` target for that.
69+
So simply run `make compile test_go` and see whether they pass.
70+
Alternatively, `go test ./...` inside `tests/go` or inside your IDE work too after compilation.
71+
72+
Running the tests could look like this:
73+
[source,bash]
74+
----
75+
$ make test_go
76+
===> Running Go unit tests
77+
cd tests/unit && go test -v ./...
78+
=== RUN Test_Deployment_DefaultParameters
79+
--- PASS: Test_Deployment_DefaultParameters (0.01s)
80+
=== RUN Test_Namespace
81+
--- PASS: Test_Namespace (0.00s)
82+
PASS
83+
ok github.com/projectsyn/component-espejo
84+
----
85+
86+
== Writing policy tests with Rego
87+
88+
Some tests are easier to write in Rego than Go unit tests.
89+
Consider the following use case:
90+
We want to ensure that all generated manifests have a certain label.
91+
92+
With Go unit tests, we would have to
93+
94+
. Recursively parse all YAML files
95+
. Decode the YAML files into generic objects, so that we can access `.metadata.labels`
96+
. Assert that the desired label is there.
97+
98+
With Rego, this particular test is relatively easy:
99+
[source,rego]
100+
----
101+
recommended_labels {
102+
input.metadata.labels["app.kubernetes.io/managed-by"]
103+
}
104+
105+
warn_labels[msg] {
106+
input.kind != "CustomResourceDefinition"
107+
not recommended_labels
108+
109+
msg = sprintf("%s/%s has not recommended labels", [input.kind, name])
110+
}
111+
----
112+
113+
Let's break down the structure:
114+
115+
. `recommended_labels` is an object that verifies that `.metadata.labels` contain the desired label keys.
116+
. `warn_labels[msg]` is a Rule. If all expressions in the brackets match (including `msg`), this Rule is considered `true`.
117+
. Since the prefix of the rule is `warn_`, it will only print a Warning message if there is an object that matches the rule. With `deny_`, it would fail the test.
118+
119+
IMPORTANT: Rego (like Datalog and its ancestor Prolog) is declarative.
120+
The lines within a rule are not evaluated imperatively.
121+
It is important to keep that in mind when writing rules, as it can cause many headaches.
122+
123+
Let's translate the example to English:
124+
125+
. In `recommended_labels`, we will test whether the Kubernetes object (named `input`) contains "app.kubernetes.io/managed-by" in the `.metadata.labels` dictionary. We ignore the actual value here. Since `recommended_labels` is not a rule, it's not yet used.
126+
. When conftest matches an Object against the rule `warn_labels`, all expressions in the rule have to evaluate `True`.
127+
. If we pass a CRD, the result of the rule is `False` because of `input.kind != "CustomResourceDefinition"`, thus the rule does not match, and the test passes.
128+
. If we pass a `Deployment`, we have at least `input.kind != "CustomResourceDefinition"` that equals to `True`, but remember, all expressions have to be evaluated.
129+
. The other expression, `not recommended_labels` checks if the object is missing the desired labels. If the given Deployment has the labels, it will fail the rule and pass the test. A Deployment that doesn't have the labels would match the rule, and thus fail the test.
130+
. By now the rule would already match with a Deployment without the labels, and thus fail the test, but we want to give a reason why. As the final expression, we will assign the `msg` variable a human readable message why the rule matches. Remember, this line can also be the first one since the execution order is determined by Rego and not line by line.
131+
132+
If we now also pass a `Namespace` or `Service` objects, the same rules can be applied, since all these objects share the common property `.metadata.labels`.
133+
134+
135+
If we want to check whether a Namespace has the correct name, this could look like this:
136+
[source, rego]
137+
----
138+
deny_namespace[msg] {
139+
input.kind = "Namespace"
140+
ns := "syn-espejo"
141+
not input.metadata.name = ns
142+
143+
msg = sprintf("Namespace is not %s", [ns])
144+
}
145+
----
146+
In this example, we are using the variable `ns` to not repeat ourselves.
147+
The expression `not input.metadata.name = "syn-espejo"` is equivalent, but we want to reduce code duplication in the `msg` expression.
148+
149+
Running the policies could look like this:
150+
[source,bash]
151+
----
152+
$ make test_conftest
153+
===> Running Conftest policies
154+
WARN - ./compiled/espejo/espejo/05_rbac.yaml - ClusterRole/syn-espejo has not recommended labels
155+
WARN - ./compiled/espejo/espejo/05_rbac.yaml - ServiceAccount/espejo has not recommended labels
156+
WARN - ./compiled/espejo/espejo/05_rbac.yaml - ClusterRoleBinding/syn-espejo has not recommended labels
157+
WARN - ./compiled/espejo/espejo/01_namespace.yaml - Namespace/syn-espejo has not recommended labels
158+
159+
14 tests, 10 passed, 4 warnings, 0 failures, 0 exceptions
160+
----
161+
162+
== Run all tests
163+
164+
At the time of writing, this is done simply with `make test`.
165+
This is also applicable in any CI/CD pipelines, such as GitHub Actions.
166+
167+
== Conclusion
168+
169+
I hope this guide has shown how we can test our component without having to compile a whole catalog and applying it to a cluster.
170+
171+
At the moment, we are limited to only have tests against a single compilation (e.g. the default parameters). Later on, we want to enable testing different parameter sets.

tutorial.adoc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,4 @@
1313
:examplesdir: /documents/docs/modules/ROOT/examples/
1414

1515
include::docs/modules/ROOT/pages/index.adoc[]
16+
include::docs/modules/ROOT/pages/testing.adoc[]

0 commit comments

Comments
 (0)