Helm — Testing#
Test metrics#
- Test files: 228
- Total Go files: 534
- Ratio (test files / source files): ~43% — healthy coverage density
- Test frameworks: stdlib
testingas the base;github.com/stretchr/testify(assert, require, suite) for assertions; no gomock, ginkgo, or gocheck
Test organization#
Placement#
All 228 test files declare the same package as the code they test (e.g., package action, package cmd, package driver). No _test external package suffix is used anywhere. This gives tests full access to unexported symbols — a deliberate choice given that much of Helm’s interesting behavior lives in unexported helpers.
Helper packages#
Helm ships two purpose-built internal test helper packages and one public fake:
internal/test/ — Golden file assertion framework
AssertGoldenString(t, actual, filename)andAssertGoldenFile(t, actualFile, expectedFile)compare strings to reference files intestdata/--updateflag (var updateGolden = flag.Bool("update", ...)) regenerates golden files in-place, eliminating manual golden file maintenance- Normalizes CRLF → LF so tests are portable across Windows/Linux CI
internal/test/ensure/ — Environment isolation helpers
HelmHome(t *testing.T)sets XDG and Helm-specific env vars tot.TempDir(), giving each test a hermetic Helm home directory that cleans up automaticallyTempFile(t, name, data)creates a scoped temp file viat.TempDir()— no manual cleanup needed
pkg/kube/fake/ — Fake Kubernetes client (public, usable by external code)
PrintingKubeClient— implementskube.Interfaceentirely in-process; all operations succeed and serialize their input to anio.Writer(typicallyio.Discardor abytes.Buffer)FailingKubeClient— embedsPrintingKubeClientand adds per-method error fields (CreateError,DeleteError,WaitError, …); tests inject specific failures without mocking a whole interface- Both types assert interface satisfaction at compile time:
var _ kube.Interface = &FailingKubeClient{}
pkg/repo/v1/repotest/ — In-process chart repository server
- Wraps
net/http/httptest.NewServer/httptest.NewTLSServerwith chart-serving logic NewTempServer(t, opts...)using functional options (WithTLSConfig,WithMiddleware,WithChartSourceGlob) — the sameWith*idiom used across Helm’s production APIs (see patterns analysis)- Also embeds a real in-process OCI registry (via
github.com/distribution/distribution) for registry integration tests
Fixtures (testdata)#
22 testdata/ directories, one per package. Contents vary:
- Chart tarballs and YAML files for chart-loading tests
- Golden
output/*.txtfiles for CLI output comparison (heavily used inpkg/cmd/testdata/) - RBAC manifests and Kubernetes resource YAML for action-layer tests
- Renderer inputs/outputs for template engine tests
Test patterns#
Table-driven tests#
- Prevalence: Heavy — 568 occurrences of
t.Run,tests :=,tt., ortestCasesin*_test.gofiles - Style: Two variants in use:
- Slice of anonymous struct (dominant):
tests := []struct{ name string; ... }{ {...}, {...} }iterated withfor _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ... }) } - Map-keyed cases (less common, used when order doesn’t matter):
testCases := map[string]struct{ ... }{ "case_name": {...} }— e.g.,pkg/cmd/helpers_test.go:165(dry-run flag strategy tests)
- Slice of anonymous struct (dominant):
- Example:
pkg/action/install_test.gocontains 30+ top-level test functions; most iterate a localtestsslice throught.Run
Golden file testing (CLI layer)#
The pkg/cmd/ package uses a specialized cmdTestCase struct:
type cmdTestCase struct {
name string
cmd string // full Helm CLI invocation as a string
golden string // path to expected output file (relative to testdata/)
wantError bool
rels []*release.Release
repeat int // 0 = run once; >0 = repeat N times for flakiness checks
}runTestCmd(t, tests) parses cmd into args with shellwords.Parse, constructs a root Cobra command wired to in-memory storage + kubefake.PrintingKubeClient, captures output into a bytes.Buffer, and asserts it against the golden file via test.AssertGoldenString. This makes every CLI command test a single-line declaration — adding a new case is as cheap as adding a struct literal. The repeat field is notable: it re-runs a test case N+1 times to confirm stability after historically flaky behavior.
Example (pkg/cmd/install_test.go:52):
{
name: "basic install",
cmd: "install aeneas testdata/testcharts/empty --namespace default",
golden: "output/install.txt",
},Mocking approach#
- Strategy: Manual fakes over interface contracts — no code generation, no gomock or mockery
pkg/kube/fake.FailingKubeClientis the primary dependency substitute for the action layer. Tests inject specific error scenarios by field assignment:cfg.KubeClient = &kubefake.FailingKubeClient{ PrintingKubeClient: kubefake.PrintingKubeClient{Out: io.Discard}, WaitError: errors.New("wait failed"), }- In-memory storage driver (
driver.NewMemory()) replaces Kubernetes secrets/configmaps storage — a real implementation variant, not a mock k8s.io/client-go/kubernetes/fake(from upstream) is used for a few tests needing Kubernetes API server behavior- No HTTP-level mocking (no
httptest.NewRecorderfor most tests) — therepotest.Serveris used instead as a real in-process HTTP server
Integration tests#
- Present: Yes — in the
pkg/registry/package - How: Four test suites (
HTTPRegistryClientTestSuite,TLSRegistryClientTestSuite,InsecureTLSRegistryClientTestSuite,RegistrySuite) each spin up a real in-process OCI registry (github.com/distribution/distribution) usinghttptest.Server, then run login/push/pull/tag operations against it - Framework: testify
suite.Suite— used specifically here because the registry tests needSetupSuite/TearDownSuitelifecycle for the shared server - Separation: No build tags or separate directories — registry suite tests are co-located with unit tests in
pkg/registry/. They are slower but self-contained - No Docker or testcontainers — all dependencies are embedded in-process
Test quality observations#
What’s done well#
- Golden file +
--updateflag: The pattern eliminates the maintenance burden of keeping expected outputs current. Updating all golden files after a formatting change is a singlego test ./... -updateinvocation. cmdTestCaseas declarative DSL: Adding a new CLI test is one struct literal. The test harness handles wiring, execution, and comparison. This scales to 50 test cases per command file without noise.actionConfigFixturecentralizes wiring: The singleactionConfigFixture(t)function inpkg/action/action_test.gois shared across all 23 action test files. Changing how tests are wired (e.g., adding a new capability or registry client) requires changing one place.FailingKubeClienterror injection model: Per-method error fields mean tests can specify exactly which operation fails without implementing a full mock. Unexpectedly elegant for a hand-rolled fake.t.TempDir()andt.Setenv()throughout: Helm has fully adopted the Go 1.14+ cleanup API. No manualdefer os.Remove(tmpDir)scattered through tests.repeatfield incmdTestCase: Explicit mechanism for regression-testing flaky behavior. The intent is documented in the struct comment rather than buried in individual test bodies.- Testify suite scoped to where it adds value: The
suite.Suiteis used only in the registry package, whereSetupSuite/TearDownSuiteis genuinely needed for a shared server. The rest of the codebase doesn’t pay the ceremony cost.
What could improve#
- No
_testpackage boundary: Every test file is in the production package, which gives access to unexported symbols but makes it easy for tests to rely on internal state that shouldn’t be part of the contract. A few packages (especiallypkg/action/) would benefit from an external_testpackage for integration-style tests that should only observe public behavior. pkg/kube/fake.FailingKubeClienterror model is flat: All methods share the sameWaitErrorfield for waiter operations, even when tests need different errors fromWaitvs.WaitWithJobs. TheRecordedWaitOptionsfield (added later) suggests the type is accumulating special cases.- Registry tests not separated from unit tests: The OCI registry suite tests are significantly slower than unit tests (they start real servers) but run unconditionally with
go test. Build tags like//go:build integrationwould let CI separate fast/slow passes. - No benchmarks found: No
Benchmark*functions in test files — surprising for a tool that processes potentially large chart files and Kubernetes manifests.
Patterns worth emulating#
- Golden file testing with
--update— applicable to any project with human-readable output (CLI tools, code generators, report tools). The cost of adoption is oneflag.Booland oneos.WriteFilecall. cmdTestCasedeclarative DSL pattern — wrapping CLI execution in a minimal struct + runner function scales to hundreds of integration-style tests without per-test boilerplate. Works for any Cobra-based CLI.FailingKubeClienterror injection via struct fields — cleaner than generating mocks when the interface has a small, stable method set. Embed the “happy path” fake and override individual methods with error conditions.actionConfigFixture(t)shared constructor — centralizing test wiring prevents test drift and makes the dependency graph of what’s under test explicit.