feat: add `helmfile unittest` command for helm-unittest integration (#2400)

Adds a new `helmfile unittest` command that integrates the helm-unittest
plugin, allowing users to define unit test paths per release and run them
via helmfile.

Closes #2376

Signed-off-by: Aditya Menon <amenon@canarytechnologies.com>
This commit is contained in:
Aditya Menon 2026-02-16 07:15:10 +05:30 committed by GitHub
parent 503c397810
commit 0129681222
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 981 additions and 13 deletions

View File

@ -99,6 +99,7 @@ func NewRootCmd(globalConfig *config.GlobalOptions) (*cobra.Command, error) {
NewLintCmd(globalImpl),
NewWriteValuesCmd(globalImpl),
NewTestCmd(globalImpl),
NewUnittestCmd(globalImpl),
NewTemplateCmd(globalImpl),
NewSyncCmd(globalImpl),
NewDiffCmd(globalImpl),

46
cmd/unittest.go Normal file
View File

@ -0,0 +1,46 @@
package cmd
import (
"github.com/spf13/cobra"
"github.com/helmfile/helmfile/pkg/app"
"github.com/helmfile/helmfile/pkg/config"
)
// NewUnittestCmd returns unittest subcmd
func NewUnittestCmd(globalCfg *config.GlobalImpl) *cobra.Command {
unittestOptions := config.NewUnittestOptions()
cmd := &cobra.Command{
Use: "unittest",
Short: "Unit test charts from state file using helm-unittest plugin",
RunE: func(cmd *cobra.Command, args []string) error {
unittestImpl := config.NewUnittestImpl(globalCfg, unittestOptions)
err := config.NewCLIConfigImpl(unittestImpl.GlobalImpl)
if err != nil {
return err
}
if err := unittestImpl.ValidateConfig(); err != nil {
return err
}
a := app.New(unittestImpl)
return toCLIError(unittestImpl.GlobalImpl, a.Unittest(unittestImpl))
},
}
f := cmd.Flags()
f.IntVar(&unittestOptions.Concurrency, "concurrency", 0, "maximum number of concurrent helm processes to run, 0 is unlimited")
f.StringVar(&globalCfg.GlobalOptions.Args, "args", "", "pass args to helm exec")
f.StringArrayVar(&unittestOptions.Set, "set", nil, "additional values to be merged into the helm command --set flag")
f.StringArrayVar(&unittestOptions.Values, "values", nil, "additional value files to be merged into the helm command --values flag")
f.BoolVar(&unittestOptions.FailFast, "fail-fast", false, "fail fast on the first test failure")
f.BoolVar(&unittestOptions.Color, "color", false, "enforce colored output even when stdout is not a tty (ignored on Helm 4 due to flag parsing issues)")
f.BoolVar(&unittestOptions.DebugPlugin, "debug-plugin", false, "enable verbose output from the helm-unittest plugin")
f.BoolVar(&unittestOptions.SkipNeeds, "skip-needs", true, `do not automatically include releases from the target release's "needs" when --selector/-l flag is provided. Does nothing when --selector/-l flag is not provided. Defaults to true when --include-needs or --include-transitive-needs is not provided`)
f.BoolVar(&unittestOptions.IncludeNeeds, "include-needs", false, `automatically include releases from the target release's "needs" when --selector/-l flag is provided. Does nothing when --selector/-l flag is not provided`)
f.BoolVar(&unittestOptions.IncludeTransitiveNeeds, "include-transitive-needs", false, `like --include-needs, but also includes transitive needs (needs of needs). Does nothing when --selector/-l flag is not provided. Overrides exclusions of other selectors and conditions.`)
return cmd
}

View File

@ -380,6 +380,11 @@ releases:
- "version"
# syncReleaseLabels is a list of labels to be added to the release when syncing.
syncReleaseLabels: false
# unitTests is a list of test file or directory paths for helm-unittest integration.
# When specified, `helmfile unittest` will run `helm unittest` with the merged values and these test paths.
# Requires the helm-unittest plugin: https://github.com/helm-unittest/helm-unittest
unitTests:
- tests/vault
# Local chart example
@ -619,6 +624,7 @@ Available Commands:
sync Sync releases defined in state file
template Template releases defined in state file
test Test charts from state file (helm test)
unittest Unit test charts from state file using helm-unittest plugin
version Print the CLI version
write-values Write values files for releases. Similar to `helmfile template`, write values files instead of manifests.
@ -787,6 +793,64 @@ Use `--cleanup` to delete pods upon completion.
The `helmfile lint` sub-command runs a `helm lint` across all of the charts/releases defined in the manifest. Non local charts will be fetched into a temporary folder which will be deleted once the task is completed.
### unittest
The `helmfile unittest` sub-command runs `helm unittest` (from the [helm-unittest plugin](https://github.com/helm-unittest/helm-unittest)) on releases that have `unitTests` defined. It automatically generates the final merged values files for each release and passes them to `helm unittest`.
This requires the `helm-unittest` plugin to be installed. You can install it with:
```bash
helm plugin install https://github.com/helm-unittest/helm-unittest
```
Releases without `unitTests` defined are skipped. Non-local charts will be fetched into a temporary folder which will be deleted once the task is completed.
Example helmfile configuration:
```yaml
releases:
- name: my-app
chart: ./charts/my-app
values:
- values.yaml
unitTests:
- tests
```
The `unitTests` paths are relative to the chart directory and follow helm-unittest conventions.
If a path does not contain glob characters, it is treated as a directory and `/*_test.yaml` is appended automatically.
You can also specify explicit glob patterns (e.g., `tests/**/*_test.yaml`).
Running `helmfile unittest` will:
1. Merge all values files defined for the release
2. Run `helm unittest ./charts/my-app --values <merged-values> --file tests/*_test.yaml`
You can pass additional flags:
```bash
# Run with additional values
helmfile unittest --values extra-values.yaml
# Run with --set overrides
helmfile unittest --set key=value
# Target specific releases
helmfile unittest --selector name=my-app
# Fail fast on first test failure
helmfile unittest --fail-fast
# Enable colored output (Helm 3 only; ignored on Helm 4 due to flag parsing issues)
helmfile unittest --color
# Enable verbose plugin output
helmfile unittest --debug-plugin
# Pass extra arguments to helm unittest
helmfile unittest --args "--strict"
```
### fetch
The `helmfile fetch` sub-command downloads or copies local charts to a local directory for debug purpose. The local directory

View File

@ -344,6 +344,47 @@ func (a *App) Lint(c LintConfigProvider) error {
return nil
}
func (a *App) Unittest(c UnittestConfigProvider) error {
var deferredUnittestErrors []error
err := a.ForEachState(func(run *Run) (ok bool, errs []error) {
var unittestErrs []error
// helm unittest needs local charts, so force download
prepErr := run.withPreparedCharts("unittest", state.ChartPrepareOptions{
ForceDownload: true,
SkipRepos: c.SkipRefresh() || c.SkipDeps(),
SkipRefresh: c.SkipRefresh(),
SkipDeps: c.SkipDeps(),
SkipCleanup: c.SkipCleanup(),
Concurrency: c.Concurrency(),
IncludeTransitiveNeeds: c.IncludeTransitiveNeeds(),
}, func() {
ok, unittestErrs, errs = a.unittest(run, c)
})
if prepErr != nil {
errs = append(errs, prepErr)
}
if len(unittestErrs) > 0 {
deferredUnittestErrors = append(deferredUnittestErrors, unittestErrs...)
}
return
}, c.IncludeTransitiveNeeds())
if err != nil {
return err
}
if len(deferredUnittestErrors) > 0 {
return &MultiError{Errors: deferredUnittestErrors}
}
return nil
}
func (a *App) Fetch(c FetchConfigProvider) error {
return a.ForEachState(func(run *Run) (ok bool, errs []error) {
prepErr := run.withPreparedCharts("pull", state.ChartPrepareOptions{
@ -1909,6 +1950,45 @@ func (a *App) lint(r *Run, c LintConfigProvider) (bool, []error, []error) {
return ok, deferredLintErrs, errs
}
func (a *App) unittest(r *Run, c UnittestConfigProvider) (bool, []error, []error) {
var deferredUnittestErrs []error
ok, errs := a.withNeeds(r, c, false, func(st *state.HelmState) []error {
helm := r.helm
args := GetArgs(c.Args(), st)
// Reset the extra args if already set, not to break `helm fetch` by adding the args intended for `unittest`
helm.SetExtraArgs()
if len(args) > 0 {
helm.SetExtraArgs(args...)
}
opts := &state.UnittestOpts{
Set: c.Set(),
SkipCleanup: c.SkipCleanup(),
FailFast: c.FailFast(),
Color: c.Color(),
DebugPlugin: c.DebugPlugin(),
}
unittestErrs := st.UnittestReleases(helm, c.Values(), args, c.Concurrency(), opts)
if len(unittestErrs) == 1 {
if err, ok := unittestErrs[0].(helmexec.ExitError); ok {
if err.Code > 0 {
deferredUnittestErrs = append(deferredUnittestErrs, err)
return nil
}
}
}
return unittestErrs
})
return ok, deferredUnittestErrs, errs
}
func (a *App) status(r *Run, c StatusesConfigProvider) (bool, []error) {
st := r.state
helm := r.helm

View File

@ -2354,6 +2354,8 @@ type applyConfig struct {
suppressDiff bool
noColor bool
color bool
failFast bool
debugPlugin bool
context int
diffOutput string
concurrency int
@ -2476,6 +2478,14 @@ func (a applyConfig) Color() bool {
return a.color
}
func (a applyConfig) FailFast() bool {
return a.failFast
}
func (a applyConfig) DebugPlugin() bool {
return a.debugPlugin
}
func (a applyConfig) NoColor() bool {
return a.noColor
}
@ -2710,6 +2720,9 @@ func (helm *mockHelmExec) Fetch(chart string, flags ...string) error {
func (helm *mockHelmExec) Lint(name, chart string, flags ...string) error {
return nil
}
func (helm *mockHelmExec) Unittest(name, chart string, flags ...string) error {
return nil
}
func (helm *mockHelmExec) IsHelm3() bool {
return !exectest.IsHelm4Enabled()
}

View File

@ -0,0 +1,256 @@
package app
import (
"os"
"path/filepath"
"strings"
"sync"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/helmfile/vals"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"github.com/helmfile/helmfile/pkg/exectest"
ffs "github.com/helmfile/helmfile/pkg/filesystem"
"github.com/helmfile/helmfile/pkg/helmexec"
)
func TestUnittest(t *testing.T) {
type fields struct {
skipNeeds bool
includeNeeds bool
includeTransitiveNeeds bool
failFast bool
color bool
debugPlugin bool
}
type testcase struct {
fields fields
ns string
error string
selectors []string
unittested []exectest.Release
}
check := func(t *testing.T, tc testcase) {
t.Helper()
wantUnittests := tc.unittested
var helm = &exectest.Helm{
FailOnUnexpectedList: true,
FailOnUnexpectedDiff: true,
Helm4: exectest.IsHelm4Enabled(),
Helm3: !exectest.IsHelm4Enabled(),
DiffMutex: &sync.Mutex{},
ChartsMutex: &sync.Mutex{},
ReleasesMutex: &sync.Mutex{},
}
bs := runWithLogCapture(t, "debug", func(t *testing.T, logger *zap.SugaredLogger) {
t.Helper()
valsRuntime, err := vals.New(vals.Options{CacheSize: 32})
if err != nil {
t.Errorf("unexpected error creating vals runtime: %v", err)
}
files := map[string]string{
"/path/to/helmfile.yaml": `
releases:
- name: logging
chart: incubator/raw
namespace: kube-system
unitTests:
- tests/logging
- name: kubernetes-external-secrets
chart: incubator/raw
namespace: kube-system
needs:
- kube-system/logging
unitTests:
- tests/secrets
- name: external-secrets
chart: incubator/raw
namespace: default
labels:
app: test
needs:
- kube-system/kubernetes-external-secrets
unitTests:
- tests/external
- name: my-release
chart: incubator/raw
namespace: default
labels:
app: test
needs:
- default/external-secrets
unitTests:
- tests/myrelease
- name: no-tests
chart: incubator/raw
namespace: default
`,
}
app := appWithFs(&App{
OverrideHelmBinary: DefaultHelmBinary,
fs: ffs.DefaultFileSystem(),
OverrideKubeContext: "default",
DisableKubeVersionAutoDetection: true,
Env: "default",
Logger: logger,
helms: map[helmKey]helmexec.Interface{
createHelmKey("helm", "default"): helm,
},
valsRuntime: valsRuntime,
}, files)
if tc.ns != "" {
app.Namespace = tc.ns
}
if tc.selectors != nil {
app.Selectors = tc.selectors
}
unittestErr := app.Unittest(applyConfig{
concurrency: 1,
logger: logger,
skipNeeds: tc.fields.skipNeeds,
includeNeeds: tc.fields.includeNeeds,
includeTransitiveNeeds: tc.fields.includeTransitiveNeeds,
failFast: tc.fields.failFast,
color: tc.fields.color,
debugPlugin: tc.fields.debugPlugin,
})
var gotErr string
if unittestErr != nil {
gotErr = unittestErr.Error()
}
if d := cmp.Diff(tc.error, gotErr); d != "" {
t.Fatalf("unexpected error: want (-), got (+): %s", d)
}
require.Equal(t, wantUnittests, helm.Unittested)
})
testNameComponents := strings.Split(t.Name(), "/")
testBaseName := strings.ToLower(
strings.ReplaceAll(
testNameComponents[len(testNameComponents)-1],
" ",
"_",
),
)
wantLogFileDir := filepath.Join("testdata", "app_unittest_test")
snapshotName := testBaseName
if exectest.IsHelm4Enabled() {
if _, err := os.Stat(filepath.Join(wantLogFileDir, testBaseName+"_helm4")); err == nil {
snapshotName = testBaseName + "_helm4"
}
}
wantLogFile := filepath.Join(wantLogFileDir, snapshotName)
wantLogData, err := os.ReadFile(wantLogFile)
updateLogFile := err != nil
wantLog := string(wantLogData)
gotLog := bs.String()
if updateLogFile {
if err := os.MkdirAll(wantLogFileDir, 0755); err != nil {
t.Fatalf("unable to create directory %q: %v", wantLogFileDir, err)
}
if err := os.WriteFile(wantLogFile, bs.Bytes(), 0644); err != nil {
t.Fatalf("unable to update unittest log snapshot: %v", err)
}
}
assert.Equal(t, wantLog, gotLog)
}
t.Run("unittest all releases with unitTests", func(t *testing.T) {
check(t, testcase{
unittested: []exectest.Release{
{Name: "logging", Flags: []string{"--namespace", "kube-system", "--file", "tests/logging/*_test.yaml"}},
{Name: "kubernetes-external-secrets", Flags: []string{"--namespace", "kube-system", "--file", "tests/secrets/*_test.yaml"}},
{Name: "external-secrets", Flags: []string{"--namespace", "default", "--file", "tests/external/*_test.yaml"}},
{Name: "my-release", Flags: []string{"--namespace", "default", "--file", "tests/myrelease/*_test.yaml"}},
},
})
})
t.Run("with dedicated flags", func(t *testing.T) {
// --color is skipped on Helm 4 due to flag parsing issues
expectedFlags := []string{"--namespace", "kube-system", "--failfast"}
if !exectest.IsHelm4Enabled() {
expectedFlags = append(expectedFlags, "--color")
}
expectedFlags = append(expectedFlags, "--debugPlugin", "--file", "tests/logging/*_test.yaml")
check(t, testcase{
fields: fields{
failFast: true,
color: true,
debugPlugin: true,
},
selectors: []string{"name=logging"},
unittested: []exectest.Release{
{Name: "logging", Flags: expectedFlags},
},
})
})
t.Run("skip-needs", func(t *testing.T) {
check(t, testcase{
fields: fields{
skipNeeds: true,
},
selectors: []string{"app=test"},
unittested: []exectest.Release{
{Name: "external-secrets", Flags: []string{"--namespace", "default", "--file", "tests/external/*_test.yaml"}},
{Name: "my-release", Flags: []string{"--namespace", "default", "--file", "tests/myrelease/*_test.yaml"}},
},
})
})
t.Run("include-needs", func(t *testing.T) {
check(t, testcase{
fields: fields{
skipNeeds: false,
includeNeeds: true,
},
selectors: []string{"app=test"},
unittested: []exectest.Release{
{Name: "logging", Flags: []string{"--namespace", "kube-system", "--file", "tests/logging/*_test.yaml"}},
{Name: "kubernetes-external-secrets", Flags: []string{"--namespace", "kube-system", "--file", "tests/secrets/*_test.yaml"}},
{Name: "external-secrets", Flags: []string{"--namespace", "default", "--file", "tests/external/*_test.yaml"}},
{Name: "my-release", Flags: []string{"--namespace", "default", "--file", "tests/myrelease/*_test.yaml"}},
},
})
})
t.Run("release without unitTests is skipped", func(t *testing.T) {
check(t, testcase{
selectors: []string{"name=no-tests"},
unittested: nil,
})
})
t.Run("bad selector", func(t *testing.T) {
check(t, testcase{
selectors: []string{"app=test_non_existent"},
unittested: nil,
error: "err: no releases found that matches specified selector(app=test_non_existent) and environment(default), in any helmfile",
})
})
}

View File

@ -207,6 +207,23 @@ type LintConfigProvider interface {
concurrencyConfig
}
type UnittestConfigProvider interface {
Args() string
Values() []string
Set() []string
FailFast() bool
Color() bool
DebugPlugin() bool
SkipDeps() bool
SkipRefresh() bool
SkipCleanup() bool
DAGConfig
concurrencyConfig
}
type FetchConfigProvider interface {
SkipDeps() bool
SkipRefresh() bool

View File

@ -18,13 +18,14 @@ import (
)
const (
HelmRequiredVersion = "v3.18.6" // Minimum required version (supports Helm 3.x and 4.x)
HelmDiffRecommendedVersion = "v3.14.1"
HelmRecommendedVersion = "v4.1.0" // Recommended to use latest Helm 4
HelmSecretsRecommendedVersion = "v4.7.4" // v4.7.0+ works with both Helm 3 (single plugin) and Helm 4 (split plugin architecture)
HelmGitRecommendedVersion = "v1.3.0"
HelmS3RecommendedVersion = "v0.16.3"
HelmInstallCommand = "https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3" // Default to Helm 3 script for compatibility
HelmRequiredVersion = "v3.18.6" // Minimum required version (supports Helm 3.x and 4.x)
HelmDiffRecommendedVersion = "v3.14.1"
HelmRecommendedVersion = "v4.1.0" // Recommended to use latest Helm 4
HelmSecretsRecommendedVersion = "v4.7.4" // v4.7.0+ works with both Helm 3 (single plugin) and Helm 4 (split plugin architecture)
HelmGitRecommendedVersion = "v1.3.0"
HelmS3RecommendedVersion = "v0.16.3"
HelmUnittestRecommendedVersion = "v1.0.3"
HelmInstallCommand = "https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3" // Default to Helm 3 script for compatibility
)
var (
@ -54,6 +55,11 @@ var (
version: HelmGitRecommendedVersion,
repo: "https://github.com/aslafy-z/helm-git.git",
},
{
name: "unittest",
version: HelmUnittestRecommendedVersion,
repo: "https://github.com/helm-unittest/helm-unittest",
},
}
)

View File

@ -0,0 +1,6 @@
processing file "helmfile.yaml" in directory "."
changing working directory to "/path/to"
merged environment: &{default map[] map[] map[]}
0 release(s) matching app=test_non_existent found in helmfile.yaml
changing working directory back to "/path/to"

View File

@ -0,0 +1,17 @@
processing file "helmfile.yaml" in directory "."
changing working directory to "/path/to"
merged environment: &{default map[] map[] map[]}
2 release(s) matching app=test found in helmfile.yaml
processing 4 groups of releases in this order:
GROUP RELEASES
1 default/kube-system/logging
2 default/kube-system/kubernetes-external-secrets
3 default/default/external-secrets
4 default/default/my-release
processing releases in group 1/4: default/kube-system/logging
processing releases in group 2/4: default/kube-system/kubernetes-external-secrets
processing releases in group 3/4: default/default/external-secrets
processing releases in group 4/4: default/default/my-release
changing working directory back to "/path/to"

View File

@ -0,0 +1,11 @@
processing file "helmfile.yaml" in directory "."
changing working directory to "/path/to"
merged environment: &{default map[] map[] map[]}
1 release(s) matching name=no-tests found in helmfile.yaml
processing 1 groups of releases in this order:
GROUP RELEASES
1 default/default/no-tests
processing releases in group 1/1: default/default/no-tests
changing working directory back to "/path/to"

View File

@ -0,0 +1,13 @@
processing file "helmfile.yaml" in directory "."
changing working directory to "/path/to"
merged environment: &{default map[] map[] map[]}
2 release(s) matching app=test found in helmfile.yaml
processing 2 groups of releases in this order:
GROUP RELEASES
1 default/default/external-secrets
2 default/default/my-release
processing releases in group 1/2: default/default/external-secrets
processing releases in group 2/2: default/default/my-release
changing working directory back to "/path/to"

View File

@ -0,0 +1,17 @@
processing file "helmfile.yaml" in directory "."
changing working directory to "/path/to"
merged environment: &{default map[] map[] map[]}
5 release(s) found in helmfile.yaml
processing 4 groups of releases in this order:
GROUP RELEASES
1 default/kube-system/logging, default/default/no-tests
2 default/kube-system/kubernetes-external-secrets
3 default/default/external-secrets
4 default/default/my-release
processing releases in group 1/4: default/kube-system/logging, default/default/no-tests
processing releases in group 2/4: default/kube-system/kubernetes-external-secrets
processing releases in group 3/4: default/default/external-secrets
processing releases in group 4/4: default/default/my-release
changing working directory back to "/path/to"

View File

@ -0,0 +1,11 @@
processing file "helmfile.yaml" in directory "."
changing working directory to "/path/to"
merged environment: &{default map[] map[] map[]}
1 release(s) matching name=logging found in helmfile.yaml
processing 1 groups of releases in this order:
GROUP RELEASES
1 default/kube-system/logging
processing releases in group 1/1: default/kube-system/logging
changing working directory back to "/path/to"

View File

@ -0,0 +1,13 @@
processing file "helmfile.yaml" in directory "."
changing working directory to "/path/to"
merged environment: &{default map[] map[] map[]}
1 release(s) matching name=logging found in helmfile.yaml
processing 1 groups of releases in this order:
GROUP RELEASES
1 default/kube-system/logging
processing releases in group 1/1: default/kube-system/logging
warn: --color flag is not supported with Helm 4 due to flag parsing issues, ignoring
changing working directory back to "/path/to"

101
pkg/config/unittest.go Normal file
View File

@ -0,0 +1,101 @@
package config
// UnittestOptions is the options for the unittest command
type UnittestOptions struct {
// Concurrency is the maximum number of concurrent helm processes to run, 0 is unlimited
Concurrency int
// Set is the set flags to pass to helm unittest
Set []string
// Values is the values flags to pass to helm unittest
Values []string
// FailFast causes helm-unittest to quit immediately when a test fails
FailFast bool
// Color enforces colored output even when stdout is not a tty
Color bool
// DebugPlugin enables verbose output from the helm-unittest plugin
DebugPlugin bool
// SkipNeeds is the skip needs flag
SkipNeeds bool
// IncludeNeeds is the include needs flag
IncludeNeeds bool
// IncludeTransitiveNeeds is the include transitive needs flag
IncludeTransitiveNeeds bool
}
// NewUnittestOptions creates a new UnittestOptions
func NewUnittestOptions() *UnittestOptions {
return &UnittestOptions{}
}
// UnittestImpl is impl for UnittestOptions
type UnittestImpl struct {
*GlobalImpl
*UnittestOptions
}
// NewUnittestImpl creates a new UnittestImpl
func NewUnittestImpl(g *GlobalImpl, u *UnittestOptions) *UnittestImpl {
return &UnittestImpl{
GlobalImpl: g,
UnittestOptions: u,
}
}
// Concurrency returns the concurrency
func (u *UnittestImpl) Concurrency() int {
return u.UnittestOptions.Concurrency
}
// Set returns the Set
func (u *UnittestImpl) Set() []string {
return u.UnittestOptions.Set
}
// Values returns the Values
func (u *UnittestImpl) Values() []string {
return u.UnittestOptions.Values
}
// FailFast returns the fail fast flag
func (u *UnittestImpl) FailFast() bool {
return u.UnittestOptions.FailFast
}
// Color returns the color flag
func (u *UnittestImpl) Color() bool {
return u.UnittestOptions.Color
}
// DebugPlugin returns the debug plugin flag
func (u *UnittestImpl) DebugPlugin() bool {
return u.UnittestOptions.DebugPlugin
}
// SkipCleanup returns the skip clean up
func (u *UnittestImpl) SkipCleanup() bool {
return false
}
// IncludeNeeds returns the include needs
func (u *UnittestImpl) IncludeNeeds() bool {
return u.UnittestOptions.IncludeNeeds || u.IncludeTransitiveNeeds()
}
// IncludeTransitiveNeeds returns the include transitive needs
func (u *UnittestImpl) IncludeTransitiveNeeds() bool {
return u.UnittestOptions.IncludeTransitiveNeeds
}
// SkipNeeds returns the skip needs
func (u *UnittestImpl) SkipNeeds() bool {
if !u.IncludeNeeds() {
return u.UnittestOptions.SkipNeeds
}
return false
}
// EnforceNeedsAreInstalled returns false for unittest
func (u *UnittestImpl) EnforceNeedsAreInstalled() bool {
return false
}

View File

@ -37,6 +37,7 @@ type Helm struct {
Releases []Release
Deleted []Release
Linted []Release
Unittested []Release
Templated []Release
Lists map[ListKey]string
Diffs map[DiffKey]error
@ -210,6 +211,13 @@ func (helm *Helm) TestRelease(context helmexec.HelmContext, name string, flags .
func (helm *Helm) Fetch(chart string, flags ...string) error {
return nil
}
func (helm *Helm) Unittest(name, chart string, flags ...string) error {
if strings.Contains(name, "error") {
return errors.New("error")
}
helm.Unittested = append(helm.Unittested, Release{Name: name, Flags: flags})
return nil
}
func (helm *Helm) Lint(name, chart string, flags ...string) error {
if strings.Contains(name, "error") {
return errors.New("error")

View File

@ -51,6 +51,8 @@ type execer struct {
decryptedSecretMutex sync.Mutex
decryptedSecrets map[string]*decryptedSecret
writeTempFile func([]byte) (string, error)
unittestPluginOnce sync.Once
unittestPluginErr error
}
func NewLogger(writer io.Writer, logLevel string) *zap.SugaredLogger {
@ -744,6 +746,30 @@ func (helm *execer) Lint(name, chart string, flags ...string) error {
return err
}
func (helm *execer) Unittest(name, chart string, flags ...string) error {
// Check if the helm-unittest plugin is installed (cached across invocations)
helm.unittestPluginOnce.Do(func() {
var pluginsDir string
if helm.IsHelm3() {
pluginsDir = cliv3.New().PluginsDirectory
} else {
pluginsDir = cliv4.New().PluginsDirectory
}
_, err := GetPluginVersion("unittest", pluginsDir)
if err != nil {
helm.unittestPluginErr = fmt.Errorf("helm-unittest plugin is required for `helmfile unittest`. Install it with: helm plugin install https://github.com/helm-unittest/helm-unittest: %w", err)
}
})
if helm.unittestPluginErr != nil {
return helm.unittestPluginErr
}
helm.logger.Infof("Unit testing release=%v, chart=%v", name, chart)
out, err := helm.exec(append([]string{"unittest", chart}, flags...), map[string]string{}, nil)
helm.write(nil, out)
return err
}
func (helm *execer) Fetch(chart string, flags ...string) error {
helm.logger.Infof("Fetching %v", redactedURL(chart))
out, err := helm.exec(append([]string{"fetch", chart}, flags...), map[string]string{}, nil)

View File

@ -28,6 +28,7 @@ type Interface interface {
ChartPull(chart string, path string, flags ...string) error
ChartExport(chart string, path string) error
Lint(name, chart string, flags ...string) error
Unittest(name, chart string, flags ...string) error
ReleaseStatus(context HelmContext, name string, flags ...string) error
DeleteRelease(context HelmContext, name string, flags ...string) error
TestRelease(context HelmContext, name string, flags ...string) error

View File

@ -335,6 +335,10 @@ type ReleaseSpec struct {
// Hooks is a list of extension points paired with operations, that are executed in specific points of the lifecycle of releases defined in helmfile
Hooks []event.Hook `yaml:"hooks,omitempty"`
// UnitTests is a list of test file or directory paths for helm-unittest integration.
// When specified, `helmfile unittest` will run `helm unittest` with the merged values and these test paths.
UnitTests []string `yaml:"unitTests,omitempty"`
// Name is the name of this release
Name string `yaml:"name,omitempty"`
Namespace string `yaml:"namespace,omitempty"`
@ -2180,6 +2184,157 @@ func (st *HelmState) LintReleases(helm helmexec.Interface, additionalValues []st
return nil
}
// UnittestOpts is the options for the unittest command
type UnittestOpts struct {
Set []string
SkipCleanup bool
FailFast bool
Color bool
DebugPlugin bool
}
// UnittestOpt is a functional option for UnittestOpts
type UnittestOpt interface {
Apply(*UnittestOpts)
}
// Apply implements UnittestOpt
func (o *UnittestOpts) Apply(opts *UnittestOpts) {
*opts = *o
}
// UnittestReleases runs helm unittest on each release that has unitTests defined.
// The workerLimit parameter is currently unused but kept for API consistency with
// similar methods (e.g., LintReleases).
func (st *HelmState) UnittestReleases(helm helmexec.Interface, additionalValues []string, args []string, _ int, opt ...UnittestOpt) []error {
opts := &UnittestOpts{}
for _, o := range opt {
o.Apply(opts)
}
helm.SetExtraArgs()
errs := []error{}
if len(args) > 0 {
helm.SetExtraArgs(args...)
}
for i := range st.Releases {
release := st.Releases[i]
if !release.Desired() {
continue
}
if len(release.UnitTests) == 0 {
continue
}
flags, files, err := st.flagsForLint(helm, &release, 0)
if !opts.SkipCleanup {
defer st.removeFiles(files)
}
releaseErr := false
if err != nil {
errs = append(errs, err)
releaseErr = true
}
for _, value := range additionalValues {
valfile, err := filepath.Abs(value)
if err != nil {
errs = append(errs, err)
releaseErr = true
break
}
// Check for any stat error (not just IsNotExist) to also catch
// permission denied, I/O errors, etc. before passing to helm.
// This intentionally differs from LintReleases which only checks IsNotExist.
if _, err := os.Stat(valfile); err != nil {
errs = append(errs, err)
releaseErr = true
break
}
flags = append(flags, "--values", valfile)
}
if releaseErr {
continue
}
if opts.Set != nil {
for _, s := range opts.Set {
flags = append(flags, "--set", s)
}
}
if opts.FailFast {
flags = append(flags, "--failfast")
}
if opts.Color {
// In Helm 4, --color is parsed by Helm itself before reaching the plugin.
// See https://github.com/helmfile/helmfile/issues/2280 for details.
// Skip the flag with a warning since helm-unittest does not currently
// support an env var alternative for colored output.
if helm.IsHelm4() {
st.logger.Warnf("warn: --color flag is not supported with Helm 4 due to flag parsing issues, ignoring\n")
} else {
flags = append(flags, "--color")
}
}
if opts.DebugPlugin {
flags = append(flags, "--debugPlugin")
}
// Add unit test file/directory paths as glob patterns for --file flag.
// Paths are relative to the chart directory (matching helm-unittest conventions).
// If the path has no glob characters and does not look like a YAML file,
// treat it as a directory and append a glob suffix.
// Validate and add unit test file/directory paths.
// Reject absolute paths and paths that escape the chart directory via "..".
for _, testPath := range release.UnitTests {
cleanPath := filepath.Clean(testPath)
if filepath.IsAbs(cleanPath) || cleanPath == ".." || strings.HasPrefix(cleanPath, ".."+string(filepath.Separator)) {
errs = append(errs, fmt.Errorf("release %q: unitTests path %q must be a relative path within the chart directory", release.Name, testPath))
releaseErr = true
break
}
if !strings.ContainsAny(testPath, "*?[") {
lowerPath := strings.ToLower(testPath)
if !strings.HasSuffix(lowerPath, ".yaml") && !strings.HasSuffix(lowerPath, ".yml") {
testPath = strings.TrimRight(testPath, "/") + "/*_test.yaml"
}
}
flags = append(flags, "--file", testPath)
}
if len(errs) == 0 || !opts.FailFast {
if err := helm.Unittest(release.Name, release.ChartPathOrName(), flags...); err != nil {
errs = append(errs, err)
}
}
if _, err := st.TriggerCleanupEvent(&release, "unittest"); err != nil {
st.logger.Warnf("warn: %v\n", err)
}
if opts.FailFast && len(errs) > 0 {
break
}
}
if len(errs) != 0 {
return errs
}
return nil
}
type diffResult struct {
release *ReleaseSpec
err *ReleaseError

View File

@ -38,39 +38,39 @@ func TestGenerateID(t *testing.T) {
run(testcase{
subject: "baseline",
release: ReleaseSpec{Name: "foo", Chart: "incubator/raw"},
want: "foo-values-66f7fd6f7b",
want: "foo-values-6884949b8b",
})
run(testcase{
subject: "different bytes content",
release: ReleaseSpec{Name: "foo", Chart: "incubator/raw"},
data: []byte(`{"k":"v"}`),
want: "foo-values-6664979cd7",
want: "foo-values-58f57b794f",
})
run(testcase{
subject: "different map content",
release: ReleaseSpec{Name: "foo", Chart: "incubator/raw"},
data: map[string]any{"k": "v"},
want: "foo-values-78897dfd49",
want: "foo-values-6b6b884cc9",
})
run(testcase{
subject: "different chart",
release: ReleaseSpec{Name: "foo", Chart: "stable/envoy"},
want: "foo-values-64b7846cb7",
want: "foo-values-85494c4677",
})
run(testcase{
subject: "different name",
release: ReleaseSpec{Name: "bar", Chart: "incubator/raw"},
want: "bar-values-576cb7ddc7",
want: "bar-values-9d65c65f",
})
run(testcase{
subject: "specific ns",
release: ReleaseSpec{Name: "foo", Chart: "incubator/raw", Namespace: "myns"},
want: "myns-foo-values-6c567f54c",
want: "myns-foo-values-84b69bb989",
})
for id, n := range ids {

View File

@ -142,6 +142,10 @@ func (helm *noCallHelmExec) Lint(name, chart string, flags ...string) error {
helm.doPanic()
return nil
}
func (helm *noCallHelmExec) Unittest(name, chart string, flags ...string) error {
helm.doPanic()
return nil
}
func (helm *noCallHelmExec) IsHelm3() bool {
helm.doPanic()
return false

View File

@ -129,6 +129,7 @@ ${kubectl} create namespace ${test_ns} || fail "Could not create namespace ${tes
. ${dir}/test-cases/issue-2309-kube-context-template.sh
. ${dir}/test-cases/issue-2355.sh
. ${dir}/test-cases/issue-2103.sh
. ${dir}/test-cases/unittest.sh
# ALL DONE -----------------------------------------------------------------------------------------------------------

View File

@ -0,0 +1,25 @@
unittest_input_dir="${cases_dir}/unittest/input"
helmfile_real="$(pwd)/${helmfile}"
HELM_UNITTEST_VERSION="${HELM_UNITTEST_VERSION:-1.0.3}"
# Ensure helm-unittest plugin is installed (matching plugin install pattern from run.sh)
info "Ensuring helm-unittest plugin v${HELM_UNITTEST_VERSION} is installed"
${helm} plugin ls | grep "^unittest" || ${helm} plugin install https://github.com/helm-unittest/helm-unittest --version v${HELM_UNITTEST_VERSION} ${PLUGIN_INSTALL_FLAGS} || fail "Could not install helm-unittest plugin"
test_start "helmfile unittest - runs unit tests on releases with unitTests defined"
cd "${unittest_input_dir}"
${helmfile_real} unittest || fail "helmfile unittest should succeed"
cd -
test_pass "helmfile unittest - runs unit tests on releases with unitTests defined"
test_start "helmfile unittest - with selector targeting release without unitTests"
cd "${unittest_input_dir}"
${helmfile_real} -l name=no-tests-app unittest || fail "helmfile unittest should succeed for releases without unitTests (skips them)"
cd -
test_pass "helmfile unittest - with selector targeting release without unitTests"
test_start "helmfile unittest - with selector targeting release with unitTests"
cd "${unittest_input_dir}"
${helmfile_real} -l name=test-app unittest || fail "helmfile unittest should succeed for releases with unitTests"
cd -
test_pass "helmfile unittest - with selector targeting release with unitTests"

View File

@ -0,0 +1,5 @@
apiVersion: v2
name: test-app
description: A test chart for helmfile unittest integration test
type: application
version: 0.1.0

View File

@ -0,0 +1,21 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .Release.Name }}
labels:
app: {{ .Release.Name }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
app: {{ .Release.Name }}
template:
metadata:
labels:
app: {{ .Release.Name }}
spec:
containers:
- name: {{ .Release.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
ports:
- containerPort: {{ .Values.service.port }}

View File

@ -0,0 +1,11 @@
apiVersion: v1
kind: Service
metadata:
name: {{ .Release.Name }}
spec:
type: {{ .Values.service.type }}
ports:
- port: {{ .Values.service.port }}
targetPort: {{ .Values.service.port }}
selector:
app: {{ .Release.Name }}

View File

@ -0,0 +1,20 @@
suite: test deployment
templates:
- templates/deployment.yaml
tests:
- it: should create deployment with correct replicas
set:
replicaCount: 3
asserts:
- equal:
path: spec.replicas
value: 3
- it: should use the correct image
set:
image.repository: nginx
image.tag: "1.21"
asserts:
- equal:
path: spec.template.spec.containers[0].image
value: "nginx:1.21"

View File

@ -0,0 +1,9 @@
replicaCount: 1
image:
repository: nginx
tag: latest
service:
type: ClusterIP
port: 80

View File

@ -0,0 +1,10 @@
releases:
- name: test-app
chart: ./charts/test-app
values:
- ./charts/test-app/values.yaml
unitTests:
- tests
- name: no-tests-app
chart: ./charts/test-app