We have two main types of tests:
- Golden File tests which run a StandardOperator on an input object, generate a manifest, and compare it to expected output.
- E2E tests which bring up a real cluster, invoke the operators and can then check that the code is performing as expected.
We believe this combination strikes a good balance between coverage and making it easy to write tests.
The kubebuilder tests, which bring up an embedded apiserver and perform some basic testing, feel unnecessary with the other two approaches.
The bulk of these tests are implemented in pkg/tests
via the StandardValidator
.
The test scans a tests
directory for *.in.yaml
files, runs the operator for each
of them, and compares the output to *.out.yaml
files.
It is also possible to provide *.side_in.yaml
file that contains resources that
should exist before the resource defined in *.in.yaml
is applied. These resources
are going to be created before running a test, and cleaned up upon finishing. They
will be accessible via Get and List.
We perform simple comparison-of-output tests, any changes to the output are treated as test failures, and printed as a diff. This means that the output is materialized and checked in to the repo; this proves to be very handy for understanding the impact of a change.
There's also a helpful "cheat" function, which rewrite the output when you run the tests locally - set the HACK_AUTOFIX_EXPECTED_OUTPUT env var to a non-empty string. This is useful when you have a big set of changes; it's just as easy to review the changes yourself in the diff and there's not a ton of value in typing them out.
This is the approach that we use in kops, and it works well - particularly with the env-var cheat code.
-
Remove the autogenerated tests
cd <your operator>/ find . -name "*test.go" -delete
-
Create a new golden file test for your controller in
pkg/controller/{{operator}}/{{operator}}_controller_test.go
package {{operator}} import ( "testing" api "{{operator}}/pkg/apis/addons/v1alpha1" "sigs.k8s.io/controller-runtime/pkg/tests" ) func TestController(t *testing.T) { v := golden.NewValidator(t, api.SchemeBuilder) dr := &{{operator}}Reconciler{ Client: v.Client(), } err := dr.setupReconciler(v.Manager()) if err != nil { t.Fatalf("creating reconciler: %v", err) } v.Validate(dr.Reconciler) }
-
Create a basic input to the test based on the autogenerated CR instance. This should be updated if you have any required fields
mkdir --p pkg/controller/{{operator}}/tests cp k8s/resources/addons_v1alpha1_{{operator}}.yaml pkg/controller/{{operator}}/tests/simple.in.yaml
-
Generate the test output
cd pkg/controller/{{operator}}/tests touch tests/simple.out.yaml HACK_AUTOFIX_EXPECTED_OUTPUT=1 go test ./...
-
Verify the output is reproducible
go test ./...
-
Update the BUILD.bazel file your controller package to pull in the channel and test data
filegroup( name = "testdata", srcs = glob(["tests/**"]), ) go_test( name = "go_default_test", srcs = ["{{operator}}_controller_test.go"], data = [ "testdata", "//{{operator}}-operator/channels", ], // remainder unchanged, eg embeded, deps, .. )
-
Verify bazel can run the test:
bazel test //{{operator}}-operator/...
End to End tests spin up a Kubernetes cluster with no default addon management via a modified kube-up.sh, deploy each operator to the cluster, create a CR for each operator, and run each operators validation.
An example is to deploy KubeProxy, create a KubeProxy CR, then validate the KubeProxy DaemonSet is deployed.
Your operator needs to provide a set of manifests in a the following structure:
foo-operator/
k8s/
crds/ - FooCrd lives in here
operators/ - All the manifests to deploy the operator (including RBAC)
resources/ - An example CR instance of Foo
This structure is not autogenerated. You can use the generated kubebuilder manifests to populate it:
cd foo-operator/
mkdir -p k8s/crds k8s/operators k8s/resources
cp config/crds/* k8s/crds
cp config/manager/* k8s/operators
cp config/samples/* k8s/resources
For this example, we will be adding a test for KubeDNS. All code changes will live in smoketest.go.
-
Create a test class that inherits from
CommonAddonsTest
and a constructor.type KubednsTest struct { CommonAddonTest } func NewKubednsTest(c CommonAddonTest) *KubednsTest { t := &KubednsTest{CommonAddonTest: c} t.Base = "kubedns-operator" return t }
This will deploy your operator from
k8s/{crds,operators}
and CR fromk8s/resources/
. -
Create a verify function to assert your operator successfully deployed its workload:
func (k *KubednsTest) Verify() error { h := k.Harness err := verifyReadyPods(h, defaultSystemNamespace, "kube-dns") if err != nil { return err } return nil }
This function will be called periodically until it returns nil (indicating success) or a timeout is reached (indicating failure).
There are several
verify*
functions avaliable for your test to use. See other implementations for inspiration.
By default, the test will use your local kubeconfig to deploy and test the operators:
go run smoketest.go
You can create and destroy a fresh cluster with the following flags:
go run smoketest.go --cluster-up=true --cluster-down=true