Blog Post

Apps on Azure Blog
7 MIN READ

Unit Testing Helm Charts with Terratest: A Pattern Guide for Type-Safe Validation

pratikpanda's avatar
pratikpanda
Icon for Microsoft rankMicrosoft
Mar 27, 2026

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:

  1. Render: Terratest calls helm template with your base values.yaml + an environment-specific values-<env>.yaml override
  2. Unmarshal: The rendered YAML is deserialized into real Kubernetes API structs (appsV1.Deployment, coreV1.ConfigMap, networkingV1.Ingress, etc.)
  3. 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:

CategoryWhat Gets Validated
Identity & LabelsResource names, 5 standard Helm/K8s labels, selector alignment
ConfigurationEnvironment-specific configmap data, env var injection
ContainerImage registry per env, ports, resource requests/limits
SecurityNon-root user, read-only FS, dropped capabilities, AppArmor, seccomp, SA token automount
ReliabilityStartup/liveness/readiness probes, volume mounts
Networking & ScalingIngress 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 + Gohelm-unittest (YAML)
Type safetyRenders into real K8s API structs; compiler catches schema errorsString matching on raw YAML; typos in field paths fail silently
Language featuresLoops, conditionals, shared setup, table-driven testsLimited to YAML assertion DSL
DebuggingStandard Go debugger, stack tracesYAML diff output only
Ecosystem alignmentSame language as Terraform tests, one testing stackSeparate 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!

Published Mar 27, 2026
Version 1.0
No CommentsBe the first to comment