Testing Helm Charts: ct, helm unittest, and CI Validation

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

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

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

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

An 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: nginx

Run 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: false

Lint all charts:

ct lint --config ct.yaml --all

Or lint only charts changed since the last commit to main:

ct lint --config ct.yaml

ct 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-test

For 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 }}/ready

Run with:

helm test myapp -n test-namespace

Testing 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
          done

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

Read more