Testing Helm Charts: ct, helm unittest, and CI Validation
Helm charts are infrastructure code, and infrastructure code deserves tests. A broken chart reaches production when no one verifies the rendered YAML matches what Kubernetes actually needs. A values change silently removes a required environment variable. A dependency upgrade shifts a default that breaks a downstream service. Without tests, you discover these failures during deployment — the worst possible time.
Three tools cover the full testing surface for Helm charts: helm lint for basic validation, helm unittest for template unit tests, and chart-testing (ct) for integration tests against real clusters.
helm lint: Catching Obvious Errors
Start with lint — it's free and catches structural problems before anything else runs:
helm lint ./charts/myapp
# With custom values
helm lint ./charts/myapp -f ./charts/myapp/values-production.yaml
<span class="hljs-comment"># Strict mode: warnings become errors
helm lint ./charts/myapp --strictLint catches malformed YAML, missing required fields, and chart metadata issues. It does not validate that your templates render correctly for different value combinations or that the rendered manifests are valid Kubernetes objects. That's what helm unittest is for.
helm unittest: Template Unit Testing
The helm-unittest plugin renders your chart templates and asserts the output matches expectations:
helm plugin install https://github.com/helm-unittest/helm-unittestTests live alongside your chart:
charts/myapp/
Chart.yaml
values.yaml
templates/
deployment.yaml
service.yaml
ingress.yaml
_helpers.tpl
tests/
deployment_test.yaml
service_test.yaml
ingress_test.yamlA deployment test:
# charts/myapp/tests/deployment_test.yaml
suite: deployment
templates:
- templates/deployment.yaml
tests:
- it: should set the correct image
set:
image.repository: mycompany/myapp
image.tag: "1.2.3"
asserts:
- equal:
path: spec.template.spec.containers[0].image
value: mycompany/myapp:1.2.3
- it: should configure resource limits
set:
resources.limits.cpu: "500m"
resources.limits.memory: "256Mi"
asserts:
- equal:
path: spec.template.spec.containers[0].resources.limits.cpu
value: "500m"
- equal:
path: spec.template.spec.containers[0].resources.limits.memory
value: "256Mi"
- it: should mount the config secret
set:
config.secretName: app-secrets
asserts:
- contains:
path: spec.template.spec.volumes
content:
name: config-secret
secret:
secretName: app-secrets
- contains:
path: spec.template.spec.containers[0].volumeMounts
content:
name: config-secret
mountPath: /etc/secrets
readOnly: true
- it: should fail when image tag is not set
set:
image.tag: ""
asserts:
- failedTemplate:
errorMessage: "image.tag is required"
- it: should render three replicas when HA mode is enabled
set:
ha.enabled: true
asserts:
- equal:
path: spec.replicas
value: 3
- it: should not render HPA when autoscaling is disabled
set:
autoscaling.enabled: false
asserts:
- hasDocuments:
count: 1
template: templates/hpa.yaml
documentIndex: 0An ingress test covering TLS configuration:
# charts/myapp/tests/ingress_test.yaml
suite: ingress
templates:
- templates/ingress.yaml
tests:
- it: should not render ingress by default
asserts:
- hasDocuments:
count: 0
- it: should render ingress when enabled
set:
ingress.enabled: true
ingress.host: app.example.com
asserts:
- hasDocuments:
count: 1
- equal:
path: spec.rules[0].host
value: app.example.com
- it: should configure TLS when cert is specified
set:
ingress.enabled: true
ingress.host: app.example.com
ingress.tls.enabled: true
ingress.tls.secretName: app-tls
asserts:
- isNotEmpty:
path: spec.tls
- equal:
path: spec.tls[0].secretName
value: app-tls
- contains:
path: spec.tls[0].hosts
content: app.example.com
- it: should use the correct ingress class
set:
ingress.enabled: true
ingress.host: app.example.com
ingress.className: nginx
asserts:
- equal:
path: spec.ingressClassName
value: nginxRun the tests:
helm unittest ./charts/myapp
# With verbose output
helm unittest ./charts/myapp -v
<span class="hljs-comment"># Run specific test file
helm unittest ./charts/myapp -f <span class="hljs-string">"tests/deployment_test.yaml"chart-testing (ct): Linting and Integration Tests
chart-testing automates the full lifecycle: lint, detect changed charts, deploy to a kind cluster, and verify the deployment succeeds.
Install:
# macOS
brew install chart-testing
<span class="hljs-comment"># Linux
curl -Lo ct.tar.gz https://github.com/helm/chart-testing/releases/download/v3.11.0/chart-testing_3.11.0_linux_amd64.tar.gz
tar -xvf ct.tar.gz ct
<span class="hljs-built_in">sudo <span class="hljs-built_in">mv ct /usr/local/bin/Configure ct:
# ct.yaml
remote: origin
target-branch: main
chart-dirs:
- charts
chart-repos:
- bitnami=https://charts.bitnami.com/bitnami
helm-extra-args: "--timeout 300s"
validate-maintainers: falseLint all charts:
ct lint --config ct.yaml --allOr lint only charts changed since the last commit to main:
ct lint --config ct.yamlct detects changed charts by comparing the current branch against the target branch. This avoids re-testing unchanged charts in PRs — a significant time saving in repositories with many charts.
Integration Testing with kind
ct install deploys changed charts to a real Kubernetes cluster and runs Helm test hooks:
# Create a kind cluster
kind create cluster --name ct-test
<span class="hljs-comment"># Run integration tests
ct install --config ct.yaml
<span class="hljs-comment"># Cleanup
kind delete cluster --name ct-testFor the install test to be meaningful, your chart needs Helm test hooks — pods that run after installation and verify the deployment works:
# charts/myapp/templates/tests/connection-test.yaml
apiVersion: v1
kind: Pod
metadata:
name: "{{ include "myapp.fullname" . }}-test-connection"
labels:
{{- include "myapp.labels" . | nindent 4 }}
annotations:
"helm.sh/hook": test
"helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
spec:
restartPolicy: Never
containers:
- name: wget
image: busybox:stable
command:
- sh
- -c
- |
echo "Testing HTTP connection..."
wget -qO- http://{{ include "myapp.fullname" . }}:{{ .Values.service.port }}/health
echo "Testing database connectivity..."
wget -qO- http://{{ include "myapp.fullname" . }}:{{ .Values.service.port }}/readyRun with:
helm test myapp -n test-namespaceTesting with Multiple Values Files
Use ci/ values files to test different configurations:
charts/myapp/
ci/
default-values.yaml # Default configuration
minimal-values.yaml # Minimum required values
ha-values.yaml # High availability configuration
custom-ingress-values.yaml# charts/myapp/ci/ha-values.yaml
ha:
enabled: true
replicaCount: 3
autoscaling:
enabled: true
minReplicas: 3
maxReplicas: 10
resources:
requests:
cpu: "200m"
memory: "256Mi"
limits:
cpu: "1"
memory: "512Mi"ct install automatically tests every file in the ci/ directory. Each values file gets its own install/test/uninstall cycle.
GitHub Actions CI Pipeline
# .github/workflows/helm-test.yaml
name: Helm Chart Testing
on:
push:
paths:
- 'charts/**'
pull_request:
paths:
- 'charts/**'
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # ct needs full git history to detect changed charts
- name: Install Helm
uses: azure/setup-helm@v4
with:
version: v3.14.0
- name: Install helm-unittest plugin
run: helm plugin install https://github.com/helm-unittest/helm-unittest
- name: Install chart-testing
uses: helm/chart-testing-action@v2.6.1
- name: Run unit tests
run: |
for chart in charts/*/; do
if [ -d "$chart/tests" ]; then
echo "Running unit tests for $chart"
helm unittest "$chart"
fi
done
- name: Run ct lint
run: ct lint --config ct.yaml
integration:
runs-on: ubuntu-latest
needs: lint
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install Helm
uses: azure/setup-helm@v4
with:
version: v3.14.0
- name: Install chart-testing
uses: helm/chart-testing-action@v2.6.1
- name: Check for chart changes
id: list-changed
run: |
changed=$(ct list-changed --config ct.yaml)
if [[ -n "$changed" ]]; then
echo "changed=true" >> "$GITHUB_OUTPUT"
fi
- name: Create kind cluster
if: steps.list-changed.outputs.changed == 'true'
uses: helm/kind-action@v1.10.0
with:
cluster_name: ct-test
- name: Run ct install
if: steps.list-changed.outputs.changed == 'true'
run: ct install --config ct.yaml
schema-validation:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Validate values schema
run: |
for chart in charts/*/; do
if [ -f "$chart/values.schema.json" ]; then
echo "Validating schema for $chart"
helm lint "$chart" --strict
fi
doneThe list-changed step is the key optimization — it skips the kind cluster creation entirely when no charts have changed, keeping unrelated PRs fast.
Values Schema Validation
Add a values.schema.json to your chart to validate values at install time:
{
"$schema": "https://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["image"],
"properties": {
"image": {
"type": "object",
"required": ["repository", "tag"],
"properties": {
"repository": {
"type": "string",
"minLength": 1
},
"tag": {
"type": "string",
"minLength": 1
},
"pullPolicy": {
"type": "string",
"enum": ["Always", "IfNotPresent", "Never"]
}
}
},
"replicaCount": {
"type": "integer",
"minimum": 1,
"maximum": 100
},
"resources": {
"type": "object",
"properties": {
"limits": {
"type": "object"
},
"requests": {
"type": "object"
}
}
}
}
}Helm validates values against this schema on every helm install and helm upgrade. Invalid values fail fast with a clear error message instead of a cryptic Kubernetes API rejection.
Template Rendering Verification
For complex charts, render the full template output and inspect it:
# Render with default values
helm template myapp ./charts/myapp > /tmp/rendered-default.yaml
<span class="hljs-comment"># Render with production values
helm template myapp ./charts/myapp \
-f ./charts/myapp/values-production.yaml \
> /tmp/rendered-production.yaml
<span class="hljs-comment"># Validate against Kubernetes API schemas (requires kubeconform)
kubeconform -strict -summary /tmp/rendered-default.yaml
<span class="hljs-comment"># Count resources by kind
kubectl api-resources --verbs=list -o name <span class="hljs-pipe">| \
xargs -I {} grep -c <span class="hljs-string">"^kind: {}" /tmp/rendered-default.yaml 2>/dev/null <span class="hljs-pipe">| \
grep -v <span class="hljs-string">"^0$"kubeconform validates rendered manifests against official Kubernetes JSON schemas without requiring a live cluster — useful as a fast pre-flight check before ct install.
A well-tested Helm chart is one where you can confidently change a value or bump a dependency version knowing that if the tests pass, the deployment will succeed. Unit tests catch template logic errors in seconds; ct install catches runtime problems in minutes. The combination eliminates the class of "chart works in dev but fails in prod" surprises that come from skipping chart testing entirely.