Helm chart testing: lint, unit tests, and ct in CI
Published: 2026-03-26
Untested Helm charts break in production in ways that are hard to debug: a missing required field, a label mismatch between Service and Deployment, a value that renders as null because of a typo in the values file. Three tools cover most of this: helm lint, helm-unittest, and chart-testing.
helm lint
The built-in linter catches syntax errors and required field violations:
bashhelm lint charts/my-service
helm lint charts/my-service -f charts/my-service/values-dev.yaml
Always lint with each environment values file, not just defaults. A value optional in values.yaml might be required in values-dev.yaml.
CI:
yamllint:
stage: test
image: alpine/helm:3.16
script:
- |
for chart in charts/*/; do
helm lint "$chart"
helm lint "$chart" -f "${chart}values-dev.yaml" || true
done
helm-unittest
helm-unittest renders templates with given values and asserts on the output. It catches logic errors that lint doesn't — wrong replicas, missing labels, incorrect resource names.
Install:
bashhelm plugin install https://github.com/helm-unittest/helm-unittest
Write tests in charts/my-service/tests/:
yaml# charts/my-service/tests/deployment_test.yaml
suite: deployment
templates:
- deployment.yaml
tests:
- it: should have 3 replicas in production
asserts:
- equal:
path: spec.replicas
value: 3
- it: should have 1 replica in dev
set:
replicaCount: 1
asserts:
- equal:
path: spec.replicas
value: 1
- it: should not allow privilege escalation
asserts:
- equal:
path: spec.template.spec.containers[0].securityContext.allowPrivilegeEscalation
value: false
- it: should have CPU resource requests
asserts:
- isNotEmpty:
path: spec.template.spec.containers[0].resources.requests.cpu
- it: should set correct service selector
templates:
- service.yaml
asserts:
- equal:
path: spec.selector.app
value: my-service
Run:
bashhelm unittest charts/my-service
chart-testing (ct)
ct automates lint and install testing for changed charts in a monorepo. Detects changed charts, lints them, and optionally installs in a real cluster:
yaml# ct.yaml at repo root
target-branch: main
chart-dirs:
- charts
check-version-increment: true
bash# Lint only changed charts
ct lint --config ct.yaml
# Install changed charts (needs a running cluster)
ct install --config ct.yaml
check-version-increment: true fails if you change chart templates without bumping Chart.yaml version. Enforces versioning discipline.
Stub values for ct install
ct install uses values from ci/ subdirectory inside the chart:
charts/my-service/
ci/
default-values.yaml # minimal values for CI install
dev-values.yaml # dev environment values
Multiple files in ci/ run multiple installs — one per file. Tests the chart with different value combinations.
Full GitLab CI pipeline
yamlstages:
- lint
- test
- package
helm-lint:
stage: lint
image: alpine/helm:3.16
script:
- helm lint charts/my-service
helm-unittest:
stage: test
image: alpine/helm:3.16
before_script:
- helm plugin install https://github.com/helm-unittest/helm-unittest
script:
- helm unittest charts/my-service
ct-lint:
stage: lint
image: quay.io/helmpack/chart-testing:latest
script:
- ct lint --config ct.yaml --charts charts/my-service
helm-package:
stage: package
image: alpine/helm:3.16
script:
- helm package charts/my-service --destination dist/
artifacts:
paths:
- dist/*.tgz
only:
- main
What to test and what not to
Test:
- Replicas set correctly per environment
- Required secrets/configmaps referenced
- SecurityContext fields (allowPrivilegeEscalation, readOnlyRootFilesystem)
- Service selector matches Deployment labels
- Resource requests/limits defined
Skip:
- Kubernetes internals (that's k8s's job)
- Runtime behavior (use integration tests)
- Values that are trivially passed through
Common issues
helm-unittest plugin install in CI takes too long: Pre-bake the plugin into a custom image with alpine/helm:3.16 as base and RUN helm plugin install ....
ct lint fails on unchanged charts: Use --charts flag to target specific charts rather than scanning the whole repo for changes.