Helm Chart Testing: ct, helm unittest, and Integration Testing Strategies

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 --strict

helm 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">done

helm-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-unittest

Writing 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: 512Mi

Testing 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: 75

Testing 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: 0

Running 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 --strict

Snapshot 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-snapshot

The 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:

  1. Detecting changed charts in a PR (using git diff)
  2. Running helm lint with all values files in ci/
  3. Installing the chart into a real cluster namespace
  4. Running helm test for each installed release
  5. 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 changes

Writing 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.yaml

kind-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.yaml

Common 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 --previous

helm 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

Read more