Building trust in charts through layered testing: unit/integration/E2E strategy, helm test with test pods, helm-unittest with assertions and snapshots, kubeconform & conftest manifest validation, and full CI automation with chart-testing (ct).

After episode 13, where we covered schema validation — the first line of defense ensuring the values entering a chart are correctly typed and complete — in this episode we cover the next layer of defense: chart testing. If a schema answers "are the provided values valid?", testing answers a deeper question: "does this chart, with valid values, produce correct manifests that actually work in the cluster?"
Why does this matter? Imagine publishing a chart to more than just one team — ten teams in your company use your chart, or the chart is installed on thousands of clusters like popular charts on Artifact Hub. Every time you change a template or add a new key in values.yaml, there are thousands of installations that could break. Without testing, a regression won't be discovered until a user reports "our release broke after upgrading the chart." With automated testing that runs every time the chart changes, mistakes like an unrendered template, wrong YAML indentation, or an accidentally removed key are caught before the chart is published.
The problem solved in this episode isn't just "run helm lint and call it done." Lint only checks static rules. We need four layers: unit testing to ensure templates render the correct output under various conditions, integration testing to ensure the release actually works in the cluster, manifest validation to ensure the output matches Kubernetes schemas, and CI automation so all layers run automatically on every change. Let's dissect them one by one.
Before diving into tools, it's important to build a mental model of four types of testing that complement each other:
Unit testing — tests template rendering in isolation: for a given set of input values, does the template render the expected manifest? This is the fastest layer, needs no cluster, and catches the majority of chart bugs (template typos, wrong conditionals, helpers returning unexpected values). The tool is the helm-unittest plugin.
Integration testing — installs the chart for real into a cluster and verifies the release actually works: pods ready, service responding, the application accessible. The tool is helm test with test pods that execute assertions inside the cluster. This needs a cluster, is slower, but provides the most real-world assurance.
End-to-end testing — runs complete scenarios from outside the chart: deploy the chart, then perform real actions (for example, making an HTTP request to the application and checking the response). This is often combined with integration testing in practice, where the test pod makes requests to the service.
Validation testing — checks the rendered output (manifest YAML) against Kubernetes schemas and organizational policies, without a cluster. Tools like kubeconform, kubeval, and conftest read the manifests rendered by helm template and validate them against Kubernetes API schemas.
These four layers form a pyramid: many fast unit tests at the bottom, fewer slow integration/E2E tests at the top. You don't need all of them for a simple chart, but a widely used chart needs at least unit testing + helm lint + manifest validation, plus integration testing if possible.
helm test — Verifying the Release Inside the Clusterhelm test is Helm's built-in feature for running test pods against an already-installed release. Test pods are essentially special pods marked with the helm.sh/hook: test annotation in the chart's templates. When you run helm test <release>, Helm creates those pods, runs them, and waits for their completion status.
apiVersion: v1
kind: Pod
metadata:
name: "{{ include "mychart.fullname" . }}-test-connection"
labels:
{{- include "mychart.labels" . | nindent 4 }}
annotations:
"helm.sh/hook": test
spec:
containers:
- name: wget
image: busybox
command: ['wget', '--spider', '--timeout=5', 'http://{{ include "mychart.fullname" . }}:{{ .Values.service.port }}']
restartPolicy: NeverThe test pod above makes a spider HTTP request (without downloading the body) to the application's Service, confirming the service responds. The core logic: exit code 0 means the test passes, non-zero means failure. That's why the command inside the pod must genuinely check the desired condition — not just "run and finish."
NAME READY STATUS ERRORS
myapp-test-connection 1/1 Completed 0If a test fails, Helm displays the error and you can view the test pod logs for debugging:
helm test my-release --logsThe --logs flag (or -l) prints the logs of all test pods — very useful when a test fails and you need to see why. Also note that helm test only works on an already-installed release (status deployed); running it on a non-existent release errors out. And because test pods use the test hook annotation, they're not counted as part of the release's normal manifests — helm get manifest doesn't show them.
Note
Because test pods use the test hook, they behave like other hooks: the pod is created when helm test is invoked and deleted afterward according to the hook-delete-policy (default: hook-succeeded). Each helm test invocation creates a new pod instance, so tests are idempotent and safe to run repeatedly.
helm-unittestIntegration tests with helm test need a cluster and are slow. For fast iteration while developing a chart, you need unit tests that run in milliseconds without a cluster. The helm-unittest plugin answers that need: it renders chart templates with given values and compares the results against assertions.
Usage is simple: install the plugin, then write test files tests/*_test.yaml inside the chart.
helm plugin install https://github.com/helm-unittest/helm-unittest.git
helm unittest mychartThe helm-unittest test file structure consists of a suite containing several sets (scenarios). Each set defines the values used and the template under test, followed by assertions. Here's an example:
suite: test deployment
templates:
- templates/deployment.yaml
tests:
- it: should render default replicas
set:
replicaCount: 3
asserts:
- isKind:
of: Deployment
- equal:
path: spec.replicas
value: 3The assertion above checks two things: the rendered result is a Deployment resource, and spec.replicas equals 3 when replicaCount is set to 3. helm-unittest provides dozens of assertions: equal, notEqual, exists, notExists, isKind, contains, matchRegex, isSubset, and many more. There's also matchSnapshot for snapshot testing — matching the rendered output against a stored snapshot, so unexpected output changes are detected immediately.
- it: should match snapshot
set:
image:
repository: nginx
tag: 1.27.0
asserts:
- matchSnapshot: {}Snapshot testing is very useful for catching subtle regressions — like YAML indentation changes, a suddenly missing key, or a changed format — that specific assertions might miss. When you intentionally change the output, update the snapshot with the --update-snapshot flag.
Tip
Combine helm-unittest with --with-subcharts to test a chart along with its subcharts, and -f to select specific test files. Run unit tests on every template change — this is the fastest layer at catching bugs and the cheapest to run continuously.
kubeconform, conftest, and kube-scoreOnce the templates render YAML, the next step is ensuring the output matches Kubernetes resource schemas and organizational policies. The following three tools complement each other.
kubeconform (the successor to kubeval) validates YAML manifests against Kubernetes' official OpenAPI schemas. It renders the chart first with helm template, then checks every manifest. This catches issues like unknown fields on a given resource version or a wrong API version. It's "type checking" for manifests.
helm template my-release ./mychart | kubeconform -summarymyapp/templates/service.yaml - Service apps/v1beta1 Service.spec.ports: ...conftest validates manifests against OPA (Open Policy Agent) policies written in Rego. Unlike kubeconform, which checks "is the structure valid," conftest checks "does the manifest comply with organizational policy" — for example, "images must always use a pinned tag, never latest", "containers must have resource limits", or "privileged containers are forbidden." It's a compliance gate before the chart is published.
helm template my-release ./mychart | conftest test --policy ./policyFAIL - myapp/templates/deployment.yaml - containers[0].image must not use 'latest' tagkube-score provides a score for security and reliability best practices: resource limits, liveness/readiness probes, securityContext, and the like. Its score gives direction for improvement without being absolute like a policy.
Important
For stable output in pipelines, get in the habit of adding --include-crds when rendering a chart that has CRDs, and note that helm template renders without depending on the cluster. This lets manifest validation run fully in CI without cluster access.
helm lint and Best Practices Checkshelm lint is Helm's built-in static checker that validates chart structure: whether Chart.yaml is valid, whether required files exist, and whether there are problems in values.yaml and the templates. It checks a set of rules — including resource naming conventions, presence of metadata, and format compliance. This is the most basic check that must always pass before any further step.
==> Linting ./mychart
[INFO] Chart.yaml: icon is recommended
1 chart(s) linted, 0 chart(s) failedHowever, it's important to understand its limits: helm lint does not render templates and doesn't validate output against Kubernetes schemas. It can pass even when the chart produces invalid manifests. So lint is only the shallowest layer — it must be paired with unit tests and manifest validation, not replace them. For stricter rules, you can add chart-testing (ct), which we'll cover next, or custom rules via an external linter.
chart-testing (ct)All of the above testing loses its value if it isn't automated. That's where chart-testing (often called ct) comes in: a tool from the Helm community that orchestrates the entire chart testing pipeline for CI. It's typically run in GitHub Actions or GitLab CI.
ct's main functions:
ct identifies which charts changed relative to the target branch and only tests the changed charts. This avoids wasting time testing all charts every time.helm lint, helm template, and helm install (into a cluster — often kind or k3s in CI) for the changed charts.Chart.yaml — preventing publishing a chart with an unchanged version to the registry.The ct config is written in a YAML file, usually ct.yaml at the chart repo root:
remote: origin
target-branch: main
chart-dirs:
- charts
validate-maintainers: false
helm-extra-args: --timeout 600sAn example usage in GitHub Actions — setting up a kind cluster, installing ct, then running lint and install:
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: helm/chart-testing-action@v2.6.1
- name: Run chart-testing (lint)
run: ct lint --config ct.yaml
- uses: helm/kind-action@v1.10.0
- name: Run chart-testing (install)
run: ct install --config ct.yamlct install is the heaviest step: it actually installs the chart into a kind cluster and runs helm test for every chart. This combines integration testing with the CI pipeline. To run test pods, make sure chart-testing is run with --all if you want to test all charts, or leave the default, which only tests the charts changed in the PR.
Warning
ct install needs a Kubernetes cluster running in CI. The most common path is helm/kind-action (as above) or a k3s runner. Without a cluster, use only ct lint, and run helm template + kubeconform as cluster-free validation instead.
Let's assemble all the layers into a sensible order for a production-grade chart. This flow can be run locally during development and repeated automatically in CI:
helm lint ./mychart
helm template my-release ./mychart --include-crds | kubeconform -summary
helm template my-release ./mychart | conftest test --policy ./policy
helm unittest ./mychart
helm install my-release ./mychart --wait --atomic
helm test my-releaseThe order above follows the pyramid: starting from the cheap and fast (lint, template+validation, unit tests) to the expensive and slow (install and test in the cluster). If any early layer fails, the pipeline stops first — there's no point installing a chart that already failed validation into the cluster.
In CI, the whole sequence is wrapped in ct lint (for lint + template) and ct install (for install + test), with automatic version checks. The result: every PR that touches a chart triggers a full validation, and only charts that pass can be published.
Note
Start simple: for an internal chart, helm lint + helm-unittest + kubeconform already provide strong assurance at low cost. Add helm test and ct install once your chart starts being used across teams or is published. Don't let pipeline perfection block you from starting — any active layer is better than none.
In this episode we covered how to build trust in charts with layered testing: a four-layer strategy (unit, integration, E2E, and manifest validation); helm test verifying releases with test pods inside the cluster; helm-unittest quickly rendering and checking templates with assertions and snapshots; manifest validation with kubeconform, OPA policies with conftest, and best practice scoring with kube-score; helm lint as the basic check; and full CI automation using chart-testing (ct), which detects changed charts, lints, installs into a cluster, and runs tests. Combined with the schema validation from episode 13, your charts are safe from both value errors and output errors.
In the next episode, episode 15, we cover chart documentation — the README.md that becomes a contract between teams, NOTES.txt guiding users after installation, tidy values documentation, and automation with helm-docs. A tested chart without documentation is like working code no one can read — both are needed for your chart to be genuinely adopted. Keep your spirits up!