|
| 1 | += Tutorial: Writing Commodore Component Tests |
| 2 | + |
| 3 | +This tutorial covers the topic of writing tests for your new or existing Commodore Component. |
| 4 | +It assumes that you are familiar with writing Commodore Components. |
| 5 | +If not, see link:index.adoc[Writing your First Commodore Component]. |
| 6 | + |
| 7 | +Currently, we can test components with two approaches: |
| 8 | + |
| 9 | +. Unit tests with Go. |
| 10 | + Easy to understand and write if you are already a Go developer. |
| 11 | +. Policy tests with Conftest. |
| 12 | + Uses the https://www.openpolicyagent.org/docs/latest/policy-language/[Rego] syntax from https://www.openpolicyagent.org/[OpenPolicyAgent]. |
| 13 | + |
| 14 | +It is up to you to decide which test framework you want to use. |
| 15 | +Some tests are simpler to do in Go, some are simpler in Rego. |
| 16 | +A combination of both will combine their advantages. |
| 17 | + |
| 18 | +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. |
| 19 | + |
| 20 | +== Requirements |
| 21 | + |
| 22 | +NOTE: This tutorial *was written on a Linux system*. |
| 23 | + |
| 24 | +. `Go` version 1.15, developer environment with Go modules enabled. |
| 25 | +. `docker` version 19 |
| 26 | + |
| 27 | +== Setting up test infrastructure with Go |
| 28 | + |
| 29 | +We'll start with Go. |
| 30 | +Create the following directory structure: |
| 31 | +[source,console] |
| 32 | +---- |
| 33 | +. |
| 34 | +├── tests |
| 35 | +│ ├── test.yml |
| 36 | +│ └── unit |
| 37 | +│ ├── defaults_test.go |
| 38 | +│ ├── go.mod |
| 39 | +│ └── go.sum |
| 40 | +---- |
| 41 | +The `go.mod` and `go.sum` files are created when executing `go mod init` inside `test/unit/`. |
| 42 | +Since we are only creating test code and not an actual Go binary, all Go test files have to end with `_test.go`. |
| 43 | +`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. |
| 44 | +We will now start writing the first tests in `defaults_test.go`. |
| 45 | + |
| 46 | +== Writing unit tests with Go |
| 47 | + |
| 48 | +If you are already a Go developer, these should look fairly familiar to you. |
| 49 | +We will showcase the tests with the Espejo component. |
| 50 | +If you have `component-somename`, then leave out `component-`. |
| 51 | + |
| 52 | +[source,go] |
| 53 | +---- |
| 54 | +package main |
| 55 | +
|
| 56 | +import ( |
| 57 | + "github.com/stretchr/testify/assert" |
| 58 | + "github.com/stretchr/testify/require" |
| 59 | + "testing" |
| 60 | +) |
| 61 | +
|
| 62 | +var ( |
| 63 | + testPath = "../../compiled/espejo/espejo" |
| 64 | +) |
| 65 | +
|
| 66 | +func Test_Deployment_DefaultParameters(t *testing.T) { |
| 67 | +
|
| 68 | + subject := DecodeDeployment(t, testPath+"/10_deployment.yaml") |
| 69 | + require.NotEmpty(t, subject.Spec.Template.Spec.Containers) |
| 70 | + container := subject.Spec.Template.Spec.Containers[0] |
| 71 | +
|
| 72 | + assert.Equal(t, "espejo", container.Name) |
| 73 | + assert.Contains(t, container.Args, "--verbose=false") |
| 74 | + assert.Contains(t, container.Args, "--reconcile-interval=10m") |
| 75 | + assert.Contains(t, container.Args, "--metrics-addr=:8080") |
| 76 | + assert.Contains(t, container.Args, "--enable-leader-election=true") |
| 77 | +
|
| 78 | + require.NotEmpty(t, container.Env) |
| 79 | + env := container.Env[0] |
| 80 | + assert.Equal(t, "WATCH_NAMESPACE", env.Name) |
| 81 | + assert.Equal(t, "metadata.namespace", env.ValueFrom.FieldRef.FieldPath) |
| 82 | +} |
| 83 | +
|
| 84 | +func Test_Namespace(t *testing.T) { |
| 85 | +
|
| 86 | + subject := DecodeNamespace(t, testPath+"/01_namespace.yaml") |
| 87 | +
|
| 88 | + assert.Equal(t, "syn-espejo", subject.Name) |
| 89 | + assert.Contains(t, subject.Labels, "name") |
| 90 | +} |
| 91 | +
|
| 92 | +---- |
| 93 | + |
| 94 | +CAUTION: We have not yet built a library to host the boilerplate code and common functions. |
| 95 | + |
| 96 | +As you can see, it's pretty straight forward: |
| 97 | + |
| 98 | +. First, load the pre-compiled YAML file into a Go K8s struct that we all know and love |
| 99 | +. Then, we verify if the values were parsed correctly, using any assertion library of your choice. |
| 100 | + |
| 101 | +To actually run our unit test case, we need to run a Commodore Component compilation first: |
| 102 | +[source,bash] |
| 103 | +---- |
| 104 | +COMPONENT_NAME=$(basename ${PWD} | sed s/component-//) |
| 105 | +DOCKER_CMD() {docker run --rm --user "$(id -u)" -v "${PWD}:/${COMPONENT_NAME}" --workdir /${COMPONENT_NAME} $*} |
| 106 | +DOCKER_CMD --entrypoint /usr/local/bin/jb projectsyn/commodore:latest install |
| 107 | +DOCKER_CMD projectsyn/commodore:latest component compile . -f tests/test.yml |
| 108 | +---- |
| 109 | + |
| 110 | +Running the tests could look like this: |
| 111 | +[source,bash] |
| 112 | +---- |
| 113 | +$ pushd tests/unit > /dev/null && go test -v ./... && popd > /dev/null |
| 114 | +=== RUN Test_Deployment_DefaultParameters |
| 115 | +--- PASS: Test_Deployment_DefaultParameters (0.01s) |
| 116 | +=== RUN Test_Namespace |
| 117 | +--- PASS: Test_Namespace (0.00s) |
| 118 | +PASS |
| 119 | +ok github.com/projectsyn/component-espejo |
| 120 | +---- |
| 121 | + |
| 122 | +== Writing policy tests with Rego |
| 123 | + |
| 124 | +Some tests are easier to write in Rego than Go unit tests. |
| 125 | +Consider the following use case: |
| 126 | +We want to ensure that all generated manifests have a certain label. |
| 127 | + |
| 128 | +With Go unit tests, we would have to |
| 129 | + |
| 130 | +. Recursively parse all YAML files |
| 131 | +. Decode the YAML files into generic objects, so that we can access `.metadata.labels` |
| 132 | +. Assert that the desired label is there. |
| 133 | + |
| 134 | +With Rego, this particular test is relatively easy: |
| 135 | +[source,rego] |
| 136 | +---- |
| 137 | +recommended_labels { |
| 138 | + input.metadata.labels["app.kubernetes.io/managed-by"] |
| 139 | +} |
| 140 | +
|
| 141 | +warn_labels[msg] { |
| 142 | + input.kind != "CustomResourceDefinition" |
| 143 | + not recommended_labels |
| 144 | +
|
| 145 | + msg = sprintf("%s/%s has not recommended labels", [input.kind, name]) |
| 146 | +} |
| 147 | +---- |
| 148 | + |
| 149 | +Let's break down the structure: |
| 150 | + |
| 151 | +. `recommended_labels` is an object that verifies that `.metadata.labels` contain the desired label keys. |
| 152 | +. `warn_labels[msg]` is a Rule. |
| 153 | + If all expressions in the brackets match (including `msg`), this Rule is considered `true`. |
| 154 | +. Since the prefix of the rule is `warn_`, it will only print a Warning message if there is an object that matches the rule. |
| 155 | + With `deny_`, it would fail the test. |
| 156 | + |
| 157 | +IMPORTANT: Rego (like Datalog and its ancestor Prolog) is declarative. |
| 158 | +The lines within a rule are not evaluated imperatively. |
| 159 | +It is important to keep that in mind when writing rules, as it can cause many headaches. |
| 160 | + |
| 161 | +Let's translate the example to English: |
| 162 | + |
| 163 | +. In `recommended_labels`, we will test whether the Kubernetes object (named `input`) contains "app.kubernetes.io/managed-by" in the `.metadata.labels` dictionary. |
| 164 | + We ignore the actual value here. |
| 165 | + Since `recommended_labels` is not a rule, it's not yet used. |
| 166 | +. When conftest matches an Object against the rule `warn_labels`, all expressions in the rule have to evaluate `True`. |
| 167 | +. 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. |
| 168 | +. If we pass a `Deployment`, we have at least `input.kind != "CustomResourceDefinition"` that equals to `True`, but remember, all expressions have to be evaluated. |
| 169 | +. The other expression, `not recommended_labels` checks if the object is missing the desired labels. |
| 170 | + If the given Deployment has the labels, it will fail the rule and pass the test. |
| 171 | + A Deployment that doesn't have the labels would match the rule, and thus fail the test. |
| 172 | +. 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. |
| 173 | + As the final expression, we will assign the `msg` variable a human readable message why the rule matches. |
| 174 | + Remember, this line can also be the first one since the execution order is determined by Rego and not line by line. |
| 175 | + |
| 176 | +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`. |
| 177 | + |
| 178 | + |
| 179 | +If we want to check whether a Namespace has the correct name, this could look like this: |
| 180 | +[source, rego] |
| 181 | +---- |
| 182 | +deny_namespace[msg] { |
| 183 | + input.kind = "Namespace" |
| 184 | + ns := "syn-espejo" |
| 185 | + not input.metadata.name = ns |
| 186 | +
|
| 187 | + msg = sprintf("Namespace is not %s", [ns]) |
| 188 | +} |
| 189 | +---- |
| 190 | +In this example, we are using the variable `ns` to not repeat ourselves. |
| 191 | +The expression `not input.metadata.name = "syn-espejo"` is equivalent, but we want to reduce code duplication in the `msg` expression. |
| 192 | + |
| 193 | +Running the policies could look like this: |
| 194 | +[source,bash] |
| 195 | +---- |
| 196 | +$ DOCKER_CMD --volume "${PWD}/tests/policies:/policy" openpolicyagent/conftest:latest test --policy /policy $(find . -type f -wholename "./compiled/${COMPONENT_NAME}/*.yaml") |
| 197 | +WARN - ./compiled/espejo/espejo/05_rbac.yaml - ClusterRole/syn-espejo has not recommended labels |
| 198 | +WARN - ./compiled/espejo/espejo/05_rbac.yaml - ServiceAccount/espejo has not recommended labels |
| 199 | +WARN - ./compiled/espejo/espejo/05_rbac.yaml - ClusterRoleBinding/syn-espejo has not recommended labels |
| 200 | +WARN - ./compiled/espejo/espejo/01_namespace.yaml - Namespace/syn-espejo has not recommended labels |
| 201 | +
|
| 202 | +14 tests, 10 passed, 4 warnings, 0 failures, 0 exceptions |
| 203 | +---- |
| 204 | + |
| 205 | +== Run all tests |
| 206 | + |
| 207 | +You could declare all the test commands in the `Makefile`. |
| 208 | +Have a look at https://github.com/projectsyn/component-espejo/blob/master/Makefile[Component-Espejo] for an example. |
| 209 | +This should also help running tests in any CI/CD pipelines, such as GitHub Actions. |
| 210 | + |
| 211 | +== Conclusion |
| 212 | + |
| 213 | +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. |
| 214 | + |
| 215 | +At the moment, we are limited to only have tests against a single compilation (e.g. the default parameters). |
| 216 | +Later on, we want to enable testing different parameter sets. |
0 commit comments