Helm charts are the de facto standard for packaging Kubernetes applications. But here's a question worth asking: how do you know your chart actually produces the manifests you expect, across every environment, before it reaches a cluster?
If you're like most teams, the answer is some combination of helm template eyeball checks, catching issues in staging, or hoping for the best. That's slow, error-prone, and doesn't scale. In this post, we'll walk through a better way: a render-and-assert approach to unit testing Helm charts using Terratest and Go. The result? Type-safe, automated tests that run locally in seconds with no cluster required.
The Problem
Let's start with why this matters. Helm charts are templates that produce YAML, and templates have logic: conditionals, loops, value overrides per environment. That logic can break silently:
- A values-prod.yaml override points to the wrong container registry
- A security context gets removed during a refactor and nobody notices
- An ingress host is correct in dev but wrong in production
- HPA scaling bounds are accidentally swapped between environments
- Label selectors drift out of alignment with pod templates, causing orphaned ReplicaSets
These aren't hypothetical scenarios. They're real bugs that slip through helm lint and code review because those tools don't understand what your chart should produce. They only check whether the YAML is syntactically valid. These bugs surface at deploy time, or worse, in production.
So how do we catch them earlier?
The Approach: Render and Assert
The idea is straightforward. Instead of deploying to a cluster to see if things work, we render the chart locally and validate the output programmatically. Here's the three-step model:
- Render: Terratest calls helm template with your base values.yaml + an environment-specific values-<env>.yaml override
- Unmarshal: The rendered YAML is deserialized into real Kubernetes API structs (appsV1.Deployment, coreV1.ConfigMap, networkingV1.Ingress, etc.)
- Assert: Testify assertions validate every field that matters, including names, labels, security context, probes, resource limits, ingress routing, and more
No cluster. No mocks. No flaky integration tests. Just fast, deterministic validation of your chart's output.
Here's what that looks like in practice:
// Arrange
options := &helm.Options{
ValuesFiles: s.valuesFiles,
}
output := helm.RenderTemplate(s.T(), options, s.chartPath, s.releaseName, s.templates)
// Act
var deployment appsV1.Deployment
helm.UnmarshalK8SYaml(s.T(), output, &deployment)
// Assert: security context is hardened
secCtx := deployment.Spec.Template.Spec.Containers[0].SecurityContext
require.Equal(s.T(), int64(1000), *secCtx.RunAsUser)
require.True(s.T(), *secCtx.RunAsNonRoot)
require.True(s.T(), *secCtx.ReadOnlyRootFilesystem)
require.False(s.T(), *secCtx.AllowPrivilegeEscalation)
Notice something important here: because you're working with real Go structs, the compiler catches schema errors. If you typo a field path like secCtx.RunAsUsr, the code won't compile. With YAML-based assertion tools, that same typo would fail silently at runtime. This type safety is a big deal when you're validating complex resources like Deployments.
What to Test: 16 Patterns Across 6 Categories
That covers the how. But what should you actually assert? Through applying this approach across multiple charts, we've identified 16 test patterns that consistently catch real bugs. They fall into six categories:
| Category | What Gets Validated |
|---|---|
| Identity & Labels | Resource names, 5 standard Helm/K8s labels, selector alignment |
| Configuration | Environment-specific configmap data, env var injection |
| Container | Image registry per env, ports, resource requests/limits |
| Security | Non-root user, read-only FS, dropped capabilities, AppArmor, seccomp, SA token automount |
| Reliability | Startup/liveness/readiness probes, volume mounts |
| Networking & Scaling | Ingress hosts/TLS per env, service port wiring, HPA bounds per env |
You don't need all 16 on day one. Start with resource name and label validation, since those apply to every resource and catch the most common _helpers.tpl bugs. Then add security and environment-specific patterns as your coverage grows.
Now, let's look at how to structure these tests to handle the trickiest part: multiple environments.
Multi-Environment Testing
One of the most common Helm chart bugs is environment drift, where values that are correct in dev are wrong in production. A single test suite that only validates one set of values will miss these entirely.
The solution is to maintain separate test suites per environment:
tests/unit/my-chart/
├── dev/ ← Asserts against values.yaml + values-dev.yaml
├── test/ ← Asserts against values.yaml + values-test.yaml
└── prod/ ← Asserts against values.yaml + values-prod.yaml
Each environment's tests assert the merged result of values.yaml + values-<env>.yaml. So when your values-prod.yaml overrides the container registry to prod.azurecr.io, the prod tests verify exactly that, while the dev tests verify dev.azurecr.io.
This structure catches a class of bugs that no other approach does: "it works in dev" issues where an environment-specific override has a typo, a missing field, or an outdated value.
But environment-specific configuration isn't the only thing worth testing per commit. Let's talk about security.
Security as Code
Security controls in Kubernetes manifests are notoriously easy to weaken by accident. Someone refactors a deployment template, removes a securityContext block they think is unused, and suddenly your containers are running as root in production. No linter catches this. No code reviewer is going to diff every field of a rendered manifest.
With this approach, you encode your security posture directly into your test suite. Every deployment test should validate:
- Container runs as non-root (UID 1000)
- Root filesystem is read-only
- All Linux capabilities are dropped
- Privilege escalation is blocked
- AppArmor profile is set to runtime/default
- Seccomp profile is set to RuntimeDefault
- Service account token automount is disabled
If someone removes a security control during a refactor, the test fails immediately, not after a security review weeks later. Security becomes a CI gate, not a review checklist.
With patterns and environments covered, the next question is: how do you wire this into your CI/CD pipeline?
CI/CD Integration with Azure DevOps
These tests integrate naturally into Azure DevOps pipelines. Since they're just Go tests that call helm template under the hood, all you need is a Helm CLI and a Go runtime on your build agent. A typical multi-stage pipeline looks like:
stages:
- stage: Build # Package the Helm chart
- stage: Dev # Lint + test against values-dev.yaml
- stage: Test # Lint + test against values-test.yaml
- stage: Production # Lint + test against values-prod.yaml
Each stage uses a shared template that installs Helm and Go, extracts the packaged chart, runs helm lint, and executes the Go tests with gotestsum. Environment gates ensure production tests pass before deployment proceeds.
Here's the key part of a reusable test template:
- script: |
export PATH=$PATH:/usr/local/go/bin:$(go env GOPATH)/bin
go install gotest.tools/gotestsum@latest
cd $(Pipeline.Workspace)/helm.artifact/tests/unit
gotestsum --format testname --junitfile $(Agent.TempDirectory)/test-results.xml \
-- ./${{ parameters.helmTestPath }}/... -count=1 -timeout 50m
displayName: 'Test helm chart'
env:
HELM_RELEASE_NAME: ${{ parameters.helmReleaseName }}
HELM_VALUES_FILE_OVERRIDE: ${{ parameters.helmValuesFileOverride }}
- task: PublishTestResults@2
displayName: 'Publish test results'
inputs:
testResultsFormat: 'JUnit'
testResultsFiles: '$(Agent.TempDirectory)/test-results.xml'
condition: always()
The PublishTestResults@2 task makes pass/fail results visible on the build's Tests tab, showing individual test names, durations, and failure details. The condition: always() ensures results are published even when tests fail, so you always have visibility.
At this point you might be wondering: why Go and Terratest? Why not a simpler YAML-based tool?
Why Terratest + Go Instead of helm-unittest?
helm-unittest is a popular YAML-based alternative, and it's a fair question. Both tools are valid. Here's why we landed on Terratest:
| Terratest + Go | helm-unittest (YAML) | |
|---|---|---|
| Type safety | Renders into real K8s API structs; compiler catches schema errors | String matching on raw YAML; typos in field paths fail silently |
| Language features | Loops, conditionals, shared setup, table-driven tests | Limited to YAML assertion DSL |
| Debugging | Standard Go debugger, stack traces | YAML diff output only |
| Ecosystem alignment | Same language as Terraform tests, one testing stack | Separate tool, YAML-only |
The type safety argument is the strongest. When you unmarshal into appsV1.Deployment, the Go compiler guarantees your assertions reference real fields. With helm-unittest, a YAML path like spec.template.spec.containers[0].securityContest (note the typo) would silently pass because it matches nothing, rather than failing loudly.
That said, if your team has no Go experience and needs the lowest adoption barrier, helm-unittest is a reasonable starting point. For teams already using Go or Terraform, Terratest is the stronger long-term choice.
Getting Started
Ready to try this? Here's a minimal project structure to get you going:
your-repo/
├── charts/
│ └── your-chart/
│ ├── Chart.yaml
│ ├── values.yaml
│ ├── values-dev.yaml
│ ├── values-test.yaml
│ ├── values-prod.yaml
│ └── templates/
├── tests/
│ └── unit/
│ ├── go.mod
│ └── your-chart/
│ ├── dev/
│ ├── test/
│ └── prod/
└── Makefile
Prerequisites: Go 1.22+, Helm 3.14+
You'll need three Go module dependencies:
github.com/gruntwork-io/terratest v0.46.16
github.com/stretchr/testify v1.8.4
k8s.io/api v0.28.4
Initialize your test module, write your first test using the patterns above, and run:
cd tests/unit
HELM_RELEASE_NAME=your-chart \
HELM_VALUES_FILE_OVERRIDE=values-dev.yaml \
go test -v ./your-chart/dev/... -timeout 30m
Start with a ConfigMap test. It's the simplest resource type and lets you validate the full render-unmarshal-assert flow before tackling Deployments. Once that passes, work your way through the pattern categories, adding security and environment-specific assertions as you go.
Wrapping Up
Unit testing Helm charts with Terratest gives you something that helm lint and manual review can't:
- Type-safe validation: The compiler catches schema errors, not production
- Environment-specific coverage: Each environment's values are tested independently
- Security as code: Security controls are verified on every commit, not in periodic reviews
- Fast feedback: Tests run in seconds with no cluster required
- CI/CD integration: JUnit results published natively to Azure DevOps
The patterns we've covered here are the ones that have caught the most real bugs for us. Start small with resource names and labels, and expand from there. The investment is modest, and the first time a test catches a broken values-prod.yaml override before it reaches production, it'll pay for itself.
We'd Love Your Feedback
We'd love to hear how this approach works for your team:
- Which patterns were most useful for your charts?
- What resource types or patterns are missing?
- How did the adoption experience go?
Drop a comment below. Happy to dig into any of these topics further!