How to unit-test your helm charts with Golang

Learn how to write Golang unit tests for your Helm charts to keep quality high and make changes with confidence.

In both my open source and commercial work I have needed to write helm charts. These charts are a definition of how to install an application on kubernetes. They often include configuration options like if we should enable auth, clustering or specific database options.

These options usually boil down to some “logic” or code within the helm chart. Here is an example of a configuration option being used to set a flag.


{{- if .Values.auth.enabled }}
  - --enable-auth
{{- end }}

This is a basic example and often these logic statements can be much more complex and have larger impacts to many different parts of the chart.

How do we test this?

The software developer in me wants to wtie tests for this. If I pass the auth.enabled=true flag when calling helm install I want to be sure that my above logic generates the expected outcome.

I did a lot of searching before embarking on my own solution for testing this type of logic at a unit test level. Most of the options I found were for an integration level test, such as the official helm website. This level of testing is great, but can be slow to test out the potentially hundreds of options and flags we may want to set in our chart.

So, I set out in search of a way to define my unit tests in code. I wanted to define what I expected the helm command to generate and then compare what was actually generated.

The code

I chose Golang - partly because I use golang every day, and partly because Helm is written in go. This means we can potentially import parts of the helm codebase into our test library and avoid “re inventing the wheel”.

The first place to look is the code for the helm cli binary its’self, we want to be able to leverage the existing implementation so our tests match our real world use.

This snippet adapted the helm apply command is a good place to start:


func Template(name, namespace, chartPath, filePath string, valueFilePaths, overrideValues []string, output interface{}) error  {
    // Some setup code omitted for readability
    ...

    // This is where the magic happens, This takes the values and the template and runs the helm logic
	release, err := client.Run(chart, values)
	if err != nil {
		return err
	}

    // A function to split the output of the template, (A single array of all files in 1 variable)
    // it returns a map of filename to  contents
	manifests := splitChart(release.Manifest)

	if _, exists := manifests[filePath]; !exists {
		return fmt.Errorf("no file found at path%s", filePath)
	}
    
    // convert the returned YAML to JSON, all internal kubernetes communication uses JSON
	jsonBytes, err := yaml.YAMLToJSON(manifests[filePath])
	if err != nil {
		return err
	}
    // Unmarshal the file (as json now) into a struct, We will see how we use k8s definitions in the test to reduce 
    // code even more!
	if err = json.Unmarshal(jsonBytes, &output); err != nil {
		return err
	}

	return nil
}

This allows us to write tests where we define the struct we expect our file to match, and then compare that expected value with what we get. Below is an example using a simple ingress record.

func Test_IngressWithTLS(t *testing.T) {
	want := v1.Ingress{
		TypeMeta:   metav1.TypeMeta{
			Kind:       "Ingress",
			APIVersion: "networking.k8s.io/v1beta1",
		},
		ObjectMeta: metav1.ObjectMeta{
			Name:                       "helmChart-nginx-example",
			Labels:                   map[string]string{"app.kubernetes.io/instance": "helmChart", "app.kubernetes.io/name": "nginx-example"} ,
		},
		Spec:       v1.IngressSpec{
			Rules: []v1.IngressRule{
				{Host: "chart-example.local" , IngressRuleValue: v1.IngressRuleValue{HTTP: &v1.HTTPIngressRuleValue{Paths: nil}}},
			},
		},
	}
	var got v1.Ingress

	if err := Template("helmChart",
		"default",
		"./chart",
		"template/ingress.yaml",
		[]string{"./chart/values.yaml"},
		nil,
		&got,
	); err != nil {
		t.Fatalf("got an error templating chart: %v", err)
	}

	if !reflect.DeepEqual(want, got) {
		t.Fatalf("difference in chart and expected. Got:\n%+v\nWant:\n%+v\n", got, want)
	}
}

We can now describe what we expect our chart to produce in our code, we can add logic to generate sections, like if we enabled TLS in the values with --set then we could add a section in our want struct to match what the helm template logic should generate.

When we make changes to our chart we can then use our tests to validate that we dont make any other unintended changes. For example we could update an image version in our base values.yaml, run our tests and see that our deployment now fails because we didn’t update out tests to use the new image.

The Upsides

With a good suite of unit tests written we can be confident that when we make changes to our chart that they have the desired effect. Its hard having to manually verify the changes each time and this is time we could better spend developing more features!

This type of testing is particularly helpful for new members of the team, they can make changes with confidence and start having an immediate impact. Additionally, when reviewing helm chart changes we get a version of the change written in a more familiar and strongly typed language. This should reduce the time taken to digest the changes and make informed decisions when reviewing your team’s PRs.

The downsides?

It wouldnt be fair to not talk about a few possible downsides when showing off this technique. Using this approach (using go code from helm) means vendoring large chunks of the k8s.io repos and pulling in helm. This is a lot of dependencies we have just added to our codebase. The other alternative is to write a go wrapper that executes the helm template binary with the desired values, this removed the need to vendor k8s/helm code. However, it does mean we would need to write our own version of the v1.Ingress structs. I have chosen to vendor the code because it makes writing tests quicker as we don’t need to keep adding to our own type definitions of the kubernetes objects.

Full example

I have written a full example with a basic helm chart in this repo

This is my second attempt at unit testing Helm charts. I took a similar approach when writing the OpenFaaS Cloud helm chart. Last time I went with the “DIY” approach and wrote the type definitions and a wrapper around the helm binary. You can check out the implementation at the link.

Let me know what you think on Twitter as I would be keen to hear if everyone else is unit testing their helm charts or if you see the value in this approach.