|
| 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. |
0 commit comments