Helm Chart Testing: ct, helm unittest, and Integration Testing Strategies
Helm charts are complex Go templates that generate Kubernetes manifests. A chart that lints cleanly can still produce broken YAML, silently wrong values, or manifests that fail admission webhooks. This guide covers the full testing stack: helm lint for static analysis, helm template for manifest inspection, chart-testing (ct) for lint and install tests, helm-unittest for value-driven unit tests, and kind-based integration testing.
Key Takeaways
helm template is your first debugging tool. Render the chart to stdout with your values, inspect the output, and pipe through kubectl apply --dry-run=client. Catches 80% of template bugs before any cluster involvement.
chart-testing (ct) runs lint + install tests against changed charts automatically. It detects which charts changed in a PR and runs lint + install tests only for those. This makes it practical even in monorepos with 50+ charts.
helm-unittest tests chart logic, not just output. You write test cases that assert on specific fields of the rendered output: expected replica count, image tag, environment variables. Much more precise than diffing full YAML output.
Snapshot tests catch unintended regressions. After your chart is stable, generate snapshots of the rendered manifests. Future changes that silently modify the output (e.g., an upstream dependency bump) cause test failures.
Integration tests in kind catch what unit tests miss. Admission webhooks, CRD dependencies, init container ordering, and resource limits that fail the scheduler are only caught when the chart is actually installed in a real cluster.
The Helm Testing Stack
Testing Helm charts requires tools at four levels:
| Level | Tool | What It Catches |
|---|---|---|
| Static analysis | helm lint |
Obvious template errors, missing required values |
| Manifest inspection | helm template + kubeconform |
Invalid Kubernetes API usage, deprecated fields |
| Unit tests | helm-unittest |
Wrong values, missing labels, incorrect conditional logic |
| Integration tests | chart-testing (ct) + kind |
Cluster-level failures: webhooks, CRD deps, scheduling |
You need all four. Each layer catches different bugs.
helm lint — Static Analysis
helm lint is the starting point. Run it on every commit:
# Lint with default values
helm lint charts/my-app
<span class="hljs-comment"># Lint with specific values files
helm lint charts/my-app \
--values charts/my-app/values.yaml \
--values charts/my-app/ci/production-values.yaml
<span class="hljs-comment"># Lint strictly — fail on warnings too
helm lint charts/my-app --stricthelm lint catches template syntax errors, references to undefined named templates, and Chart.yaml metadata issues. It does NOT catch values that produce invalid Kubernetes manifests or wrong field names that are quietly ignored.
helm template + Manifest Validation
Render the chart and validate the output:
# Render to stdout
helm template my-release charts/my-app \
--values charts/my-app/values.yaml \
--<span class="hljs-built_in">set image.tag=v1.2.3
<span class="hljs-comment"># Render and validate with kubeconform
helm template my-release charts/my-app \
--values charts/my-app/values.yaml <span class="hljs-pipe">| \
kubeconform \
--strict \
--ignore-missing-schemas \
--kubernetes-version 1.30.0 \
--schema-location default \
--schema-location <span class="hljs-string">'https://raw.githubusercontent.com/datreeio/CRDs-catalog/main/{{.Group}}/{{.ResourceKind}}_{{.ResourceAPIVersion}}.json'
<span class="hljs-comment"># Render and do a dry-run apply
helm template my-release charts/my-app \
--values charts/my-app/values.yaml <span class="hljs-pipe">| \
kubectl apply --dry-run=client -f -Rendering with All CI Values Files
Organize CI-specific values files in a ci/ directory and test all combinations:
charts/my-app/
values.yaml # defaults
ci/
minimal-values.yaml # only required values
ha-values.yaml # high-availability config
debug-values.yaml # debug mode flags#!/usr/bin/env bash
<span class="hljs-comment"># scripts/test-all-values.sh
CHART=<span class="hljs-variable">$1
RELEASE_NAME=test-release
<span class="hljs-keyword">for values_file <span class="hljs-keyword">in <span class="hljs-string">"$CHART"/ci/*.yaml; <span class="hljs-keyword">do
<span class="hljs-built_in">echo <span class="hljs-string">"Testing: $values_file"
output=$(helm template <span class="hljs-string">"$RELEASE_NAME" <span class="hljs-string">"$CHART" \
--values <span class="hljs-string">"$CHART/values.yaml" \
--values <span class="hljs-string">"$values_file" 2>&1)
<span class="hljs-keyword">if [ $? -ne 0 ]; <span class="hljs-keyword">then
<span class="hljs-built_in">echo <span class="hljs-string">"FAIL: helm template failed for $values_file"
<span class="hljs-built_in">echo <span class="hljs-string">"$output"
<span class="hljs-built_in">exit 1
<span class="hljs-keyword">fi
<span class="hljs-built_in">echo <span class="hljs-string">"$output" <span class="hljs-pipe">| kubeconform --strict --ignore-missing-schemas
<span class="hljs-keyword">if [ $? -ne 0 ]; <span class="hljs-keyword">then
<span class="hljs-built_in">echo <span class="hljs-string">"FAIL: kubeconform failed for $values_file"
<span class="hljs-built_in">exit 1
<span class="hljs-keyword">fi
<span class="hljs-built_in">echo <span class="hljs-string">"OK: $values_file"
<span class="hljs-keyword">donehelm-unittest — Unit Testing Chart Logic
helm-unittest lets you write test cases that assert on specific fields of the rendered output. Install the plugin:
helm plugin install https://github.com/helm-unittest/helm-unittestWriting Unit Tests
Tests live in charts/my-app/tests/ as YAML files:
# charts/my-app/tests/deployment_test.yaml
suite: Deployment tests
templates:
- deployment.yaml
tests:
- it: sets correct replica count from values
set:
replicaCount: 3
asserts:
- equal:
path: spec.replicas
value: 3
- it: uses default replica count of 1
asserts:
- equal:
path: spec.replicas
value: 1
- it: sets the container image correctly
set:
image.repository: myrepo/myapp
image.tag: v1.2.3
asserts:
- equal:
path: spec.template.spec.containers[0].image
value: myrepo/myapp:v1.2.3
- it: adds custom environment variables
set:
env:
- name: DATABASE_URL
value: postgres://localhost/mydb
- name: REDIS_URL
value: redis://localhost:6379
asserts:
- contains:
path: spec.template.spec.containers[0].env
content:
name: DATABASE_URL
value: postgres://localhost/mydb
- contains:
path: spec.template.spec.containers[0].env
content:
name: REDIS_URL
value: redis://localhost:6379
- it: does not set resources when not specified
asserts:
- notExists:
path: spec.template.spec.containers[0].resources.limits
- it: sets resource limits when specified
set:
resources:
limits:
cpu: 500m
memory: 512Mi
requests:
cpu: 100m
memory: 128Mi
asserts:
- equal:
path: spec.template.spec.containers[0].resources.limits.cpu
value: 500m
- equal:
path: spec.template.spec.containers[0].resources.limits.memory
value: 512MiTesting Conditional Templates
Test charts that conditionally include resources:
# charts/my-app/tests/hpa_test.yaml
suite: HorizontalPodAutoscaler tests
templates:
- hpa.yaml
tests:
- it: does not render HPA when autoscaling is disabled
set:
autoscaling.enabled: false
asserts:
- hasDocuments:
count: 0
- it: renders HPA when autoscaling is enabled
set:
autoscaling.enabled: true
autoscaling.minReplicas: 2
autoscaling.maxReplicas: 10
autoscaling.targetCPUUtilizationPercentage: 75
asserts:
- hasDocuments:
count: 1
- equal:
path: spec.minReplicas
value: 2
- equal:
path: spec.maxReplicas
value: 10
- equal:
path: spec.metrics[0].resource.target.averageUtilization
value: 75Testing ServiceAccount and RBAC
# charts/my-app/tests/rbac_test.yaml
suite: RBAC tests
templates:
- serviceaccount.yaml
tests:
- it: creates ServiceAccount when serviceAccount.create is true
set:
serviceAccount.create: true
serviceAccount.name: ""
asserts:
- isKind:
of: ServiceAccount
- equal:
path: metadata.name
value: RELEASE-NAME-my-app
- it: uses custom ServiceAccount name
set:
serviceAccount.create: true
serviceAccount.name: my-custom-sa
asserts:
- equal:
path: metadata.name
value: my-custom-sa
- it: does not create ServiceAccount when disabled
set:
serviceAccount.create: false
asserts:
- hasDocuments:
count: 0Running helm-unittest
# Run all tests for a chart
helm unittest charts/my-app
<span class="hljs-comment"># Run with subchart coverage
helm unittest charts/my-app --with-subchart
<span class="hljs-comment"># Run in strict mode
helm unittest charts/my-app --strictSnapshot Testing
Snapshot tests capture the full rendered output and fail when it changes unexpectedly:
# charts/my-app/tests/snapshot_test.yaml
suite: Snapshot tests
templates:
- deployment.yaml
- service.yaml
- ingress.yaml
tests:
- it: matches production snapshot
values:
- ../ci/production-values.yaml
asserts:
- matchSnapshot: {}Generate the initial snapshot:
helm unittest charts/my-app --update-snapshotThe snapshots are stored in charts/my-app/tests/__snapshot__/. Commit them. When a future change modifies the rendered output, the test fails and shows you exactly what changed. Snapshots are especially useful after bumping a chart dependency — subchart updates can silently change the rendered output in ways that affect production.
chart-testing (ct) — Lint and Install Tests
ct is the official Helm chart testing tool. It automates:
- Detecting changed charts in a PR (using git diff)
- Running
helm lintwith all values files inci/ - Installing the chart into a real cluster namespace
- Running
helm testfor each installed release - Cleaning up the namespace after testing
Installing ct
# macOS
brew install chart-testing
<span class="hljs-comment"># Linux
curl -Lo ct.tar.gz https://github.com/helm/chart-testing/releases/latest/download/chart-testing_linux_amd64.tar.gz
tar xzf ct.tar.gz
<span class="hljs-built_in">sudo <span class="hljs-built_in">mv ct /usr/local/bin/ct Configuration
# ct.yaml
remote: origin
target-branch: main
chart-dirs:
- charts
excluded-charts:
- charts/library # helper charts with no deployable resources
helm-extra-args: "--timeout 120s"
check-version-increment: true # fail if chart version not bumped on changesWriting helm test Hooks
helm test runs Jobs or Pods with the helm.sh/hook: test annotation:
# charts/my-app/templates/tests/test-connection.yaml
apiVersion: v1
kind: Pod
metadata:
name: "{{ include "my-app.fullname" . }}-test-connection"
labels:
{{- include "my-app.labels" . | nindent 4 }}
annotations:
"helm.sh/hook": test
"helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
spec:
restartPolicy: Never
containers:
- name: test
image: curlimages/curl:latest
command:
- /bin/sh
- -c
- |
set -e
BASE_URL="http://{{ include "my-app.fullname" . }}:{{ .Values.service.port }}"
# Health check
curl -sf "$BASE_URL/health" | grep -q '"status":"ok"'
echo "Health check passed"
# Version endpoint
VERSION=$(curl -sf "$BASE_URL/version" | jq -r .version)
if [ -z "$VERSION" ]; then
echo "FAIL: /version returned empty"
exit 1
fi
echo "Version endpoint returned: $VERSION"Running ct Locally
# Test all changed charts (detects changes vs target branch)
ct lint --config ct.yaml
ct install --config ct.yaml
<span class="hljs-comment"># Test a specific chart
ct lint --charts charts/my-app --config ct.yaml
ct install --charts charts/my-app --config ct.yamlkind-Based Integration Testing
For more complex integration tests than ct provides:
#!/usr/bin/env bash
<span class="hljs-comment"># scripts/integration-test.sh
<span class="hljs-built_in">set -e
CLUSTER_NAME=<span class="hljs-string">"helm-integration-test"
NAMESPACE=<span class="hljs-string">"helm-test-$(date +%s)"
<span class="hljs-function">cleanup() {
kubectl delete namespace <span class="hljs-string">"$NAMESPACE" --ignore-not-found
kind delete cluster --name <span class="hljs-string">"$CLUSTER_NAME" 2>/dev/null <span class="hljs-pipe">|| <span class="hljs-literal">true
}
<span class="hljs-built_in">trap cleanup EXIT
<span class="hljs-comment"># Create cluster
kind create cluster --name <span class="hljs-string">"$CLUSTER_NAME"
<span class="hljs-comment"># Install prerequisites
helm repo add bitnami https://charts.bitnami.com/bitnami
helm repo update
helm install postgresql bitnami/postgresql \
--namespace <span class="hljs-string">"$NAMESPACE" \
--create-namespace \
--<span class="hljs-built_in">set auth.password=testpassword \
--<span class="hljs-built_in">wait
<span class="hljs-comment"># Install the chart under test
helm install my-release charts/my-app \
--namespace <span class="hljs-string">"$NAMESPACE" \
--values charts/my-app/ci/integration-values.yaml \
--<span class="hljs-built_in">set postgresql.host=postgresql \
--<span class="hljs-built_in">set postgresql.password=testpassword \
--<span class="hljs-built_in">wait \
--<span class="hljs-built_in">timeout 300s
<span class="hljs-comment"># Run helm tests
helm <span class="hljs-built_in">test my-release --namespace <span class="hljs-string">"$NAMESPACE" --<span class="hljs-built_in">timeout 120s
<span class="hljs-comment"># Additional assertions
POD_COUNT=$(kubectl get pods -n <span class="hljs-string">"$NAMESPACE" -l app.kubernetes.io/name=my-app --no-headers <span class="hljs-pipe">| <span class="hljs-built_in">wc -l)
<span class="hljs-keyword">if [ <span class="hljs-string">"$POD_COUNT" -lt 1 ]; <span class="hljs-keyword">then
<span class="hljs-built_in">echo <span class="hljs-string">"FAIL: No pods found for my-app"
<span class="hljs-built_in">exit 1
<span class="hljs-keyword">fi
READY=$(kubectl get pods -n <span class="hljs-string">"$NAMESPACE" -l app.kubernetes.io/name=my-app \
-o jsonpath=<span class="hljs-string">'{.items[*].status.conditions[?(@.type=="Ready")].status}')
<span class="hljs-keyword">if [[ <span class="hljs-string">"$READY" != *<span class="hljs-string">"True"* ]]; <span class="hljs-keyword">then
<span class="hljs-built_in">echo <span class="hljs-string">"FAIL: Pods are not Ready"
kubectl describe pods -n <span class="hljs-string">"$NAMESPACE" -l app.kubernetes.io/name=my-app
<span class="hljs-built_in">exit 1
<span class="hljs-keyword">fi
<span class="hljs-built_in">echo <span class="hljs-string">"Integration tests passed"CI Pipeline
# .github/workflows/helm-tests.yaml
name: Helm Chart Tests
on:
push:
branches: [main]
pull_request:
paths:
- 'charts/**'
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # ct needs full git history for diff
- name: Set up Helm
uses: azure/setup-helm@v4
- name: Set up chart-testing
uses: helm/chart-testing-action@v2
- name: Run lint
run: ct lint --config ct.yaml
unittest:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Helm
uses: azure/setup-helm@v4
- name: Install helm-unittest plugin
run: helm plugin install https://github.com/helm-unittest/helm-unittest
- name: Run unit tests
run: |
for chart in charts/*/; do
if [ -d "$chart/tests" ]; then
echo "Testing $chart"
helm unittest "$chart" --strict
fi
done
install:
runs-on: ubuntu-latest
needs: [lint, unittest]
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up kind
uses: helm/kind-action@v1
- name: Set up Helm
uses: azure/setup-helm@v4
- name: Set up chart-testing
uses: helm/chart-testing-action@v2
- name: Run install tests
run: ct install --config ct.yamlCommon Issues and How to Debug Them
helm template succeeds but kubectl apply fails. The manifest is valid YAML but fails Kubernetes server-side validation. Run kubectl apply --dry-run=server to catch admission webhook rejections:
helm template my-release charts/my-app \
--values charts/my-app/ci/production-values.yaml | \
kubectl apply --dry-run=server -f -ct install fails with "timed out waiting for the condition". The chart installed but pods never became Ready. Debug with:
kubectl describe pods -n ct-test-my-app
kubectl logs -n ct-test-my-app -l app.kubernetes.io/name=my-app --previoushelm unittest snapshot test fails after dependency bump. The subchart changed its rendered output. Review with helm unittest --update-snapshot, then compare with git to understand exactly what changed.
Chart version not incremented (ct check). ct requires you to bump Chart.yaml's version field when you change chart files. This enforces semantic versioning. Bypass with --skip-version-check only for non-functional changes like comment edits.
Conclusion
A complete Helm chart testing strategy layers helm lint for fast static checks, helm-unittest for value-driven logic testing with snapshot regression protection, chart-testing (ct) for automated lint and install tests in PR pipelines, and kind-based integration tests for cluster-level behavior.
The helm-unittest plugin gives you the most value per test written — a 20-line test file covering 10 value combinations catches more bugs than a full kind-based install test that only verifies pods are Running.
HelpMeTest can monitor your platform engineering pipelines automatically — sign up free