feat: add helm-unittest integration

This commit adds support for running helm-unittest on releases defined in helmfile.

Features:
- Add unittest command to run helm-unittest on releases
- Add unitTests field to release spec for specifying test directories
- Add helm-unittest plugin to init command
- Support for --values, --fail-fast, --color, --debug-plugin, --unittest-args flags
- Concurrent execution support
- Integration with needs/selectors like other commands

Example usage:
  helmfile unittest -e test
  helmfile unittest --fail-fast --color
  helmfile unittest --values values.yaml

Example helmfile.yaml:
  releases:
  - name: myapp
    chart: ./charts/myapp
    unitTests:
      - tests/

Signed-off-by: yxxhero <aiopsclub@163.com>
This commit is contained in:
yxxhero 2026-01-23 09:08:05 +08:00
parent 7500eef7c6
commit e9069679f6
12 changed files with 352 additions and 0 deletions

View File

@ -105,6 +105,7 @@ func NewRootCmd(globalConfig *config.GlobalOptions) (*cobra.Command, error) {
NewStatusCmd(globalImpl),
NewShowDAGCmd(globalImpl),
NewPrintEnvCmd(globalImpl),
NewUnittestCmd(globalImpl),
extension.NewVersionCobraCmd(
versionOpts...,
),

45
cmd/unittest.go Normal file
View File

@ -0,0 +1,45 @@
package cmd
import (
"github.com/spf13/cobra"
"github.com/helmfile/helmfile/pkg/app"
"github.com/helmfile/helmfile/pkg/config"
)
func NewUnittestCmd(globalCfg *config.GlobalImpl) *cobra.Command {
unittestOptions := config.NewUnittestOptions()
cmd := &cobra.Command{
Use: "unittest",
Short: "Run unit tests for charts",
Long: "Run helm-unittest on releases defined in state file",
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.StringArrayVar(&unittestOptions.Values, "values", nil, "additional value files to be merged into the helm command --values flag")
f.IntVar(&unittestOptions.Concurrency, "concurrency", 0, "maximum number of concurrent helm processes to run, 0 is unlimited")
f.BoolVar(&unittestOptions.FailFast, "fail-fast", false, "fail fast on the first test failure")
f.BoolVar(&unittestOptions.Color, "color", false, "output with color")
f.BoolVar(&unittestOptions.DebugPlugin, "debug-plugin", false, "output plugin debug information")
f.StringArrayVar(&unittestOptions.UnittestArgs, "unittest-args", nil, "additional arguments to pass to helm unittest")
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`)
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

@ -344,6 +344,46 @@ 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
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.IncludeNeeds(),
}, 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 +1949,43 @@ 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, true, func(st *state.HelmState) []error {
helm := r.helm
args := GetArgs(c.Args(), st)
helm.SetExtraArgs()
if len(args) > 0 {
helm.SetExtraArgs(args...)
}
opts := &state.UnittestOpts{
Color: c.Color(),
DebugPlugin: c.DebugPlugin(),
FailFast: c.FailFast(),
UnittestArgs: c.UnittestArgs(),
}
unittestErrs := st.UninttestReleases(helm, c.Values(), c.Concurrency(), true, 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

@ -2643,6 +2643,10 @@ func (helm *mockHelmExec) TemplateRelease(name, chart string, flags ...string) e
return nil
}
func (helm *mockHelmExec) UnittestRelease(context helmexec.HelmContext, name, chart string, flags ...string) error {
return nil
}
func (helm *mockHelmExec) ChartPull(chart string, path string, flags ...string) error {
return nil
}

View File

@ -128,6 +128,25 @@ type SyncConfigProvider interface {
valuesControlMode
}
type UnittestConfigProvider interface {
Args() string
Values() []string
FailFast() bool
Color() bool
DebugPlugin() bool
UnittestArgs() []string
SkipNeeds() bool
IncludeNeeds() bool
IncludeTransitiveNeeds() bool
EnforceNeedsAreInstalled() bool
SkipDeps() bool
SkipRefresh() bool
SkipCleanup() bool
concurrencyConfig
DAGConfig
}
type DiffConfigProvider interface {
Args() string
PostRenderer() string

View File

@ -24,6 +24,7 @@ const (
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"
HelmUnittestVersion = "v0.5.1"
HelmInstallCommand = "https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3" // Default to Helm 3 script for compatibility
)
@ -54,6 +55,11 @@ var (
version: HelmGitRecommendedVersion,
repo: "https://github.com/aslafy-z/helm-git.git",
},
{
name: "unittest",
version: HelmUnittestVersion,
repo: "https://github.com/helm-unittest/helm-unittest.git",
},
}
)

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

@ -0,0 +1,85 @@
package config
type UnittestOptions struct {
Values []string
FailFast bool
Color bool
DebugPlugin bool
UnittestArgs []string
Concurrency int
SkipNeeds bool
IncludeNeeds bool
IncludeTransitiveNeeds bool
}
func NewUnittestOptions() *UnittestOptions {
return &UnittestOptions{}
}
type UnittestImpl struct {
*GlobalImpl
*UnittestOptions
}
func NewUnittestImpl(g *GlobalImpl, t *UnittestOptions) *UnittestImpl {
return &UnittestImpl{
GlobalImpl: g,
UnittestOptions: t,
}
}
func (t *UnittestImpl) Values() []string {
return t.UnittestOptions.Values
}
func (t *UnittestImpl) Concurrency() int {
return t.UnittestOptions.Concurrency
}
func (t *UnittestImpl) FailFast() bool {
return t.UnittestOptions.FailFast
}
func (t *UnittestImpl) Color() bool {
return t.UnittestOptions.Color
}
func (t *UnittestImpl) DebugPlugin() bool {
return t.UnittestOptions.DebugPlugin
}
func (t *UnittestImpl) UnittestArgs() []string {
return t.UnittestOptions.UnittestArgs
}
func (t *UnittestImpl) Args() string {
return ""
}
func (t *UnittestImpl) SkipNeeds() bool {
return t.UnittestOptions.SkipNeeds
}
func (t *UnittestImpl) IncludeNeeds() bool {
return t.UnittestOptions.IncludeNeeds
}
func (t *UnittestImpl) IncludeTransitiveNeeds() bool {
return t.UnittestOptions.IncludeTransitiveNeeds
}
func (t *UnittestImpl) SkipDeps() bool {
return false
}
func (t *UnittestImpl) SkipRefresh() bool {
return false
}
func (t *UnittestImpl) SkipCleanup() bool {
return false
}
func (t *UnittestImpl) EnforceNeedsAreInstalled() bool {
return false
}

View File

@ -224,6 +224,9 @@ func (helm *Helm) TemplateRelease(name, chart string, flags ...string) error {
helm.Templated = append(helm.Templated, Release{Name: name, Flags: flags})
return nil
}
func (helm *Helm) UnittestRelease(context helmexec.HelmContext, name, chart string, flags ...string) error {
return nil
}
func (helm *Helm) ChartPull(chart string, path string, flags ...string) error {
return nil
}

View File

@ -649,6 +649,17 @@ func (helm *execer) TemplateRelease(name string, chart string, flags ...string)
return err
}
func (helm *execer) UnittestRelease(context HelmContext, name, chart string, flags ...string) error {
helm.logger.Infof("Running unittest for release=%v, chart=%v", name, redactedURL(chart))
preArgs := make([]string, 0)
env := make(map[string]string)
var overrideEnableLiveOutput *bool = nil
out, err := helm.exec(append(append(preArgs, "unittest", chart), flags...), env, overrideEnableLiveOutput)
helm.write(context.Writer, out)
return err
}
func (helm *execer) DiffRelease(context HelmContext, name, chart, namespace string, suppressDiff bool, flags ...string) error {
diffMsg := fmt.Sprintf("Comparing release=%v, chart=%v, namespace=%v\n", name, redactedURL(chart), namespace)
if context.Writer != nil && !suppressDiff {

View File

@ -24,6 +24,7 @@ type Interface interface {
SyncRelease(context HelmContext, name, chart, namespace string, flags ...string) error
DiffRelease(context HelmContext, name, chart, namespace string, suppressDiff bool, flags ...string) error
TemplateRelease(name, chart string, flags ...string) error
UnittestRelease(context HelmContext, name, chart string, flags ...string) error
Fetch(chart string, flags ...string) error
ChartPull(chart string, path string, flags ...string) error
ChartExport(chart string, path string) error

View File

@ -442,6 +442,9 @@ type ReleaseSpec struct {
SyncReleaseLabels *bool `yaml:"syncReleaseLabels,omitempty"`
// TakeOwnership is true if the release should take ownership of the resources
TakeOwnership *bool `yaml:"takeOwnership,omitempty"`
// UnitTests is a list of paths to unittest directories for this release
UnitTests []string `yaml:"unitTests,omitempty"`
}
func (r *Inherits) UnmarshalYAML(unmarshal func(any) error) error {
@ -2166,6 +2169,95 @@ func (st *HelmState) LintReleases(helm helmexec.Interface, additionalValues []st
return nil
}
type UnittestOpts struct {
Color bool
DebugPlugin bool
FailFast bool
UnittestArgs []string
}
func (o *UnittestOpts) Apply(opts *UnittestOpts) {
*opts = *o
}
type UnittestOpt interface{ Apply(*UnittestOpts) }
// UninttestReleases wrapper for executing helm unittest on the releases
func (st *HelmState) UninttestReleases(helm helmexec.Interface, additionalValues []string, workerLimit int, triggerCleanupEvents bool, opt ...UnittestOpt) []error {
opts := &UnittestOpts{}
for _, o := range opt {
o.Apply(opts)
}
helm.SetExtraArgs()
errs := []error{}
for i := range st.Releases {
release := st.Releases[i]
if !release.Desired() {
continue
}
if len(release.UnitTests) == 0 {
continue
}
flags, files, err := st.flagsForUnittest(helm, &release, 0)
defer st.removeFiles(files)
if err != nil {
errs = append(errs, err)
}
for _, value := range additionalValues {
valfile, err := filepath.Abs(value)
if err != nil {
errs = append(errs, err)
}
if _, err := os.Stat(valfile); os.IsNotExist(err) {
errs = append(errs, err)
}
flags = append(flags, "--values", valfile)
}
flags = append(flags, opts.UnittestArgs...)
if opts.Color {
flags = append(flags, "--color")
}
if opts.DebugPlugin {
flags = append(flags, "--debug-plugin")
}
if opts.FailFast {
flags = append(flags, "--fail-fast")
}
if len(errs) == 0 {
if err := helm.UnittestRelease(st.createHelmContext(&release, 0), release.Name, release.ChartPathOrName(), flags...); err != nil {
errs = append(errs, err)
}
}
if triggerCleanupEvents {
if _, err := st.TriggerCleanupEvent(&release, "unittest"); err != nil {
st.logger.Warnf("warn: %v\n", err)
}
}
}
if len(errs) != 0 {
return errs
}
return nil
}
type diffResult struct {
release *ReleaseSpec
err *ReleaseError
@ -3601,6 +3693,10 @@ func (st *HelmState) flagsForLint(helm helmexec.Interface, release *ReleaseSpec,
return st.appendHelmXFlags(flags, release), files, nil
}
func (st *HelmState) flagsForUnittest(helm helmexec.Interface, release *ReleaseSpec, workerIndex int) ([]string, []string, error) {
return st.namespaceAndValuesFlags(helm, release, workerIndex)
}
func (st *HelmState) newReleaseTemplateData(release *ReleaseSpec) releaseTemplateData {
vals := st.Values()
templateData := st.createReleaseTemplateData(release, vals)

View File

@ -61,6 +61,10 @@ func (helm *noCallHelmExec) TemplateRelease(name, chart string, flags ...string)
helm.doPanic()
return nil
}
func (helm *noCallHelmExec) UnittestRelease(context helmexec.HelmContext, name, chart string, flags ...string) error {
helm.doPanic()
return nil
}
func (helm *noCallHelmExec) ChartPull(chart string, path string, flags ...string) error {
helm.doPanic()
return nil