Yes, you can unit test Helm Charts
I have found creating Helm charts a challenge as it’s difficult to test as a chart gets more complex. Linting, packaging, etc. all help ease the pain and need to be used as part of a robust continuous integration, CI, process for maintaining charts. I wanted something more and started researching if it’s possible to unit test a Helm chart. After a little searching, I found Terratest, by the great people over at Gruntwork, actually has a module for Helm. I have heard of Terratest when unit testing Terraform so I had experience with it already. Terratest offers two scenarios for testing charts. The first option is to test the template generation, I think of these as unit tests, and the second option is integration testing that deploys to an actual Kubernetes cluster. Let’s set up a chart that is more complex than the basic example that is in the GitHub repository that will detect the version of Kubernetes and install a specific version of the Nginx image. We will create some template tests and then create some integration tests using kind. I will explain what is happening in the chart as we go. Let’s get started. The GitHub repository can be found here.
Creating our Helm Chart
The first thing is we need to create a directory for our project, a charts
directory inside of it.
mkdir -p helm-unit-tests/charts
Now we can use helm
to create our chart with the starter template.
cd helm-unit-tests/charts
helm create my-chart
Delete the service.yaml
file in the templates
directory and the charts
directory inside of my-chart
. Now we can start editing the remaining files. Let’s start with creating our values.yaml
file. We will be defining two root items, versionOverrides
which will contain the constraint we want to use to determine which nginx image we want to use. Then for each constraint, we define the key and values we want to override. The last item in the file is the default version which is just the latest image.
versionOverrides:
- constraint: ">= 1.21 < 1.24"
values:
nginx:
repository: docker.io/nginx
tag: 1.21
- constraint: "~ 1.20"
values:
nginx:
repository: docker.io/nginx
tag: 1.20.0
- constraint: "~ 1.19"
values:
nginx:
repository: docker.io/nginx
tag: 1.19
nginx:
repository: docker.io/nginx
tag: latest
Next, we need to update _helpers.tpl
within the template directory. Replace it with the following.
{{- define "applyVersionOverrides" -}}
{{- $overrides := dict -}}
{{- range $override := .Values.versionOverrides -}}
{{- if semverCompare $override.constraint $.Capabilities.KubeVersion.Version -}}
{{- $_ := mergeOverwrite $overrides $override.values -}}
{{- end -}}
{{- end -}}
{{- $_ := mergeOverwrite .Values $overrides -}}
{{- end -}}
The helper above is what is going to allow us to detect the Kubernetes version of the cluster, then using semantic versioning it decides which version of nginx we want to deploy based on the constraint. Finally, it overrides the default values.
The very last piece of our chart is our deployment.yaml
that deploys nginx. Notice how we call our helper at the top which will override the default values based on the Kubernetes version.
{{- template "applyVersionOverrides" . -}}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .Chart.Name }}-nginx
labels:
component: {{ .Chart.Name }}-nginx
namespace: {{ .Release.Namespace }}
spec:
strategy:
type: Recreate
selector:
matchLabels:
app: {{ .Chart.Name }}-nginx
replicas: 2
template:
metadata:
labels:
app: {{ .Chart.Name }}-nginx
spec:
containers:
- name: {{ .Chart.Name }}-nginx
image: {{ .Values.nginx.repository }}:{{ .Values.nginx.tag }}
ports:
- containerPort: 80
Now that we have that out of the way, we can get to creating our unit test.
Creating our unit tests
Since we have a chart that makes decisions based on the Kubernetes version, it would be great to have some unit tests that exercise that logic to ensure that at least at a basic level it is working correctly. We can do that using Terratest. It is a Go-based framework so you will need to have Go installed and set up correctly. I am using Go 1.18. Let’s get to the root of the project and create our directories for our tests.
mkdir -p tests/unit
Inside of the unit
directory let’s create the my_chart_template_test.go
file. Once that file is created, we can create our test. This test will define a structure for holding our different test parameters, we will then create several different scenarios, and execute each one to verify that our template is generated correctly.
package unit
import (
"path/filepath"
"strings"
"testing"
"github.com/gruntwork-io/terratest/modules/helm"
"github.com/gruntwork-io/terratest/modules/k8s"
"github.com/gruntwork-io/terratest/modules/random"
"github.com/stretchr/testify/require"
appsv1 "k8s.io/api/apps/v1"
)
const myChart = "../../charts/my-chart"
func TestTemplateRenderedDeployment(t *testing.T) {
type args struct {
kubeVersion string
namespace string
releaseName string
chartRelPath string
expectedImage string
}
tests := []struct {
name string
args args
}{
{
name: "Kubernetes 1.23",
args: args{
kubeVersion: "1.23",
namespace: "test-" + strings.ToLower(random.UniqueId()),
releaseName: "test-" + strings.ToLower(random.UniqueId()),
chartRelPath: myChart,
expectedImage: "docker.io/nginx:1.21",
},
},
{
name: "Kubernetes 1.22",
args: args{
kubeVersion: "1.22",
namespace: "test-" + strings.ToLower(random.UniqueId()),
releaseName: "test-" + strings.ToLower(random.UniqueId()),
chartRelPath: myChart,
expectedImage: "docker.io/nginx:1.21",
},
},
{
name: "Kubernetes 1.21",
args: args{
kubeVersion: "1.21",
namespace: "test-" + strings.ToLower(random.UniqueId()),
releaseName: "test-" + strings.ToLower(random.UniqueId()),
chartRelPath: myChart,
expectedImage: "docker.io/nginx:1.21",
},
},
{
name: "Kubernetes 1.20",
args: args{
kubeVersion: "1.20",
namespace: "test-" + strings.ToLower(random.UniqueId()),
releaseName: "test-" + strings.ToLower(random.UniqueId()),
chartRelPath: myChart,
expectedImage: "docker.io/nginx:1.20.0",
},
},
{
name: "Kubernetes 1.19",
args: args{
kubeVersion: "1.19",
namespace: "test-" + strings.ToLower(random.UniqueId()),
releaseName: "test-" + strings.ToLower(random.UniqueId()),
chartRelPath: myChart,
expectedImage: "docker.io/nginx:1.19",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// arrange
chartPath, err := filepath.Abs(tt.args.chartRelPath)
require.NoError(t, err)
options := &helm.Options{
KubectlOptions: k8s.NewKubectlOptions("", "", tt.args.namespace),
}
// act
output := helm.RenderTemplate(t, options, chartPath, tt.args.releaseName, []string{"templates/deployment.yaml"}, "--kube-version", tt.args.kubeVersion)
var deployment appsv1.Deployment
helm.UnmarshalK8SYaml(t, output, &deployment)
// assert
require.Equal(t, tt.args.namespace, deployment.Namespace)
deploymentSetContainers := deployment.Spec.Template.Spec.Containers
require.Equal(t, len(deploymentSetContainers), 1)
require.Equal(t, tt.args.expectedImage, deploymentSetContainers[0].Image)
})
}
}
Terratest provides much of the capability here, by allowing you to render a single template from a chart, you can get that YAML to then assert if the correct version was set. The only special step is we had to provide the Kubernetes version to the RenderTemplate
function. Then it’s a matter of marshaling the resulting YAML to a Go struct to perform our assertions. Let’s execute our tests now.
$ go test -v -tags helm ./tests/unit
--- PASS: TestTemplateRenderedDeployment (1.09s)
--- PASS: TestTemplateRenderedDeployment/Kubernetes_1.23 (0.53s)
--- PASS: TestTemplateRenderedDeployment/Kubernetes_1.22 (0.14s)
--- PASS: TestTemplateRenderedDeployment/Kubernetes_1.21 (0.14s)
--- PASS: TestTemplateRenderedDeployment/Kubernetes_1.20 (0.14s)
--- PASS: TestTemplateRenderedDeployment/Kubernetes_1.19 (0.14s)
PASS
ok github.com/user/helm-unit-tests/tests/unit 1.350s
Wrapping Up
Some Helm charts can get pretty complex pretty fast and manually testing by installing it just stinks. I have already used this one chart at work and it has already saved me several times. I was able to find three different bugs in a chart from just having several test cases that exercise the helpers. Unit tests don’t always capture every scenario and that is why Terratest also supports integration testing, which I will do in the following post showing how to accomplish that.
Thanks for reading,
Jamie
If you enjoy the content, then consider buying me a coffee.