Unit testing with Operator SDK

Table of Contents

Overview

Testing your operator should involve both unit and end-to-end tests. Unit tests assess the expected outcomes of individual operator components without requiring coordination between components. Operator unit tests should test multiple scenarios likely to be encountered by your custom operator logic at runtime. Much of your custom logic will involve API server calls via a client; Reconcile() in particular will be making API calls on each reconciliation loop. These API calls can be mocked by using controller-runtime‘s fake client, perfect for unit testing. This document steps through writing a unit test for the memcached-operator‘s Reconcile() method using a fake client.

Using a Fake client

The controller-runtime‘s fake client exposes the same set of operations as a typical client, but simply tracks objects rather than sending requests over a network. You can create a new fake client that tracks an initial set of objects with the following code:

  1. import (
  2. "context"
  3. "testing"
  4. cachev1alpha1 "github.com/example-inc/memcached-operator/pkg/apis/cache/v1alpha1"
  5. "k8s.io/apimachinery/pkg/runtime"
  6. "sigs.k8s.io/controller-runtime/pkg/client"
  7. "sigs.k8s.io/controller-runtime/pkg/client/fake"
  8. )
  9. func TestMemcachedController(t *testing.T) {
  10. ...
  11. // A Memcached object with metadata and spec.
  12. memcached := &cachev1alpha1.Memcached{
  13. ObjectMeta: metav1.ObjectMeta{
  14. Name: "memcached",
  15. Namespace: "memcached-operator",
  16. Labels: map[string]string{
  17. "label-key": "label-value",
  18. },
  19. },
  20. }
  21. // Objects to track in the fake client.
  22. objs := []runtime.Object{memcached}
  23. // Create a fake client to mock API calls.
  24. cl := fake.NewFakeClient(objs...)
  25. // List Memcached objects filtering by labels
  26. opt := client.MatchingLabels(map[string]string{"label-key": "label-value"})
  27. memcachedList := &cachev1alpha1.MemcachedList{}
  28. err := cl.List(context.TODO(), memcachedList, opt)
  29. if err != nil {
  30. t.Fatalf("list memcached: (%v)", err)
  31. }
  32. ...
  33. }

The fake client cl will cache memcached in an internal object tracker so that CRUD operations via cl can be performed on it.

Testing Reconcile

Reconcile() performs most API server calls a particular operator controller will make. ReconcileMemcached.Reconcile() will ensure the Memcached resource exists as well as reconcile the state of owned Deployments and Pods. We can test runtime reconciliation scenarios using the above client. The following is an example that tests if Reconcile() creates a deployment if one is not found, and whether the created deployment is correct:

  1. import (
  2. "context"
  3. "testing"
  4. cachev1alpha1 "github.com/example-inc/memcached-operator/pkg/apis/cache/v1alpha1"
  5. appsv1 "k8s.io/api/apps/v1"
  6. metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
  7. "k8s.io/apimachinery/pkg/runtime"
  8. "k8s.io/apimachinery/pkg/types"
  9. "k8s.io/client-go/kubernetes/scheme"
  10. "sigs.k8s.io/controller-runtime/pkg/client/fake"
  11. "sigs.k8s.io/controller-runtime/pkg/reconcile"
  12. logf "sigs.k8s.io/controller-runtime/pkg/log"
  13. )
  14. func TestMemcachedControllerDeploymentCreate(t *testing.T) {
  15. var (
  16. name = "memcached-operator"
  17. namespace = "memcached"
  18. replicas int32 = 3
  19. )
  20. // A Memcached object with metadata and spec.
  21. memcached := &cachev1alpha1.Memcached{
  22. ObjectMeta: metav1.ObjectMeta{
  23. Name: name,
  24. Namespace: namespace,
  25. },
  26. Spec: cachev1alpha1.MemcachedSpec{
  27. Size: replicas, // Set desired number of Memcached replicas.
  28. },
  29. }
  30. // Objects to track in the fake client.
  31. objs := []runtime.Object{ memcached }
  32. // Register operator types with the runtime scheme.
  33. s := scheme.Scheme
  34. s.AddKnownTypes(cachev1alpha1.SchemeGroupVersion, memcached)
  35. // Create a fake client to mock API calls.
  36. cl := fake.NewFakeClient(objs...)
  37. // Create a ReconcileMemcached object with the scheme and fake client.
  38. r := &ReconcileMemcached{client: cl, scheme: s}
  39. // Mock request to simulate Reconcile() being called on an event for a
  40. // watched resource .
  41. req := reconcile.Request{
  42. NamespacedName: types.NamespacedName{
  43. Name: name,
  44. Namespace: namespace,
  45. },
  46. }
  47. res, err := r.Reconcile(req)
  48. if err != nil {
  49. t.Fatalf("reconcile: (%v)", err)
  50. }
  51. // Check the result of reconciliation to make sure it has the desired state.
  52. if !res.Requeue {
  53. t.Error("reconcile did not requeue request as expected")
  54. }
  55. // Check if deployment has been created and has the correct size.
  56. dep := &appsv1.Deployment{}
  57. err = r.client.Get(context.TODO(), req.NamespacedName, dep)
  58. if err != nil {
  59. t.Fatalf("get deployment: (%v)", err)
  60. }
  61. // Check if the quantity of Replicas for this deployment is equals the specification
  62. dsize := *dep.Spec.Replicas
  63. if dsize != replicas {
  64. t.Errorf("dep size (%d) is not the expected size (%d)", dsize, replicas)
  65. }
  66. }

The above tests check if:

  • Reconcile() fails to find a Deployment object
  • A Deployment is created
  • The request is requeued in the expected manner
  • The number of replicas in the created Deployment’s spec is as expected.

NOTE: A unit test checking more cases can be found in our samples repo.

Testing with 3rd Party Resources

You may have added third-party resources in your operator as described in the Advanced Topics section of the user guide. In order to create a unit-test to test these kinds of resources, it might be necessary to update the Scheme with the third-party resources and pass it to your Reconciler. The following code snippet is an example that adds the v1.Route OpenShift scheme to the ReconcileMemcached reconciler’s scheme.

  1. import (
  2. ...
  3. routev1 "github.com/openshift/api/route/v1"
  4. ...
  5. )
  6. // TestMemcachedController runs ReconcileMemcached.Reconcile() against a
  7. // fake client that tracks a Memcached object.
  8. func TestMemcachedController(t *testing.T) {
  9. ...
  10. // Register operator types with the runtime scheme.
  11. s := scheme.Scheme
  12. // Add route Openshift scheme
  13. if err := routev1.AddToScheme(s); err != nil {
  14. t.Fatalf("Unable to add route scheme: (%v)", err)
  15. }
  16. // Create the mock for the Route
  17. // NOTE: If the object will be created by the reconcile you do not need add a mock for it
  18. route := &routev1.Route{
  19. ObjectMeta: v1.ObjectMeta{
  20. Name: name,
  21. Namespace: namespace,
  22. Labels: getAppLabels(name),
  23. },
  24. }
  25. s.AddKnownTypes(appv1alpha1.SchemeGroupVersion, memcached)
  26. // Create a fake client to mock API calls.
  27. cl := fake.NewFakeClient(objs...)
  28. // Create a ReconcileMemcached object with the scheme and fake client.
  29. r := &ReconcileMemcached{client: cl, scheme: s}
  30. ...
  31. }

NOTE: If your Reconcile has not the scheme attribute you may create the client fake as cl := fake.NewFakeClientWithScheme(s, objs...) in order to add the schema.

In this way, you will be able to get the mock object injected into the Reconcile as the following example.

  1. route := &routev1.Route{}
  2. err = r.client.Get(context.TODO(), types.NamespacedName{Name: name, Namespace: namespace}, route)
  3. if err != nil {
  4. t.Fatalf("get route: (%v)", err)
  5. }

NOTE: Following an example of issue that can be faced because of an invalid TypeMeta.APIVersion informed. It is not recommended declared the TypeMeta since it will be implicit generated.

  1. get route: (no kind "Route" is registered for version "v1" in scheme "k8s.io/client-go/kubernetes/scheme/register.go:61")

Following an example which could cause this error.

  1. ...
  2. route := &routev1.Route{
  3. TypeMeta: v1.TypeMeta{ // TODO (user): Remove the TypeMeta declared
  4. APIVersion: "v1", // the correct value will be `"route.openshift.io/v1"`
  5. Kind: "Route",
  6. },
  7. ObjectMeta: v1.ObjectMeta{
  8. Name: name,
  9. Namespace: namespace,
  10. Labels: ls,
  11. },
  12. }
  13. ...

Following another example of the issue that can be faced when the third-party resource schema was not added properly.

  1. create a route: (no kind is registered for the type v1.Route in scheme "k8s.io/client-go/kubernetes/scheme/register.go:61")`

How to increase the verbosity of the logs?

Following is a snippet code as an example to increase the verbosity of the logs in order to better troubleshoot your tests.

  1. import (
  2. ...
  3. logf "sigs.k8s.io/controller-runtime/pkg/log"
  4. ...
  5. )
  6. func TestMemcachedController(t *testing.T) {
  7. //dev logs
  8. logf.SetLogger(logf.ZapLogger(true))
  9. ...
  10. }

Last modified January 1, 0001