1

Summary:-

I have a cobra-cli based golang app which uses viper for config management. I want to make my commands testable. For this I want to inject dependencies to my commands when they are being added to the root command (which happens in the init() of that command's go file; see mycmd.go below) The problem is that viper configs get loaded in initConfig which is run when each command's Execute method is called.

How can I make the commands testable while also using viper for loading config values?

The long version:-

The app has been created using

cobra-cli init --viper
cobra-cli add mycmd

This creates the bare minimum app with following structure.

.
├── cmd
│   ├── mycmd.go
│   └── root.go
├── go.mod
├── go.sum
├── main.go

The main files are main.go

package main

import "test/cmd"

func main() {
    cmd.Execute()
}

the cmd.Execute resides in the root.go file with following content.

package cmd

import (
    "fmt"
    "os"

    "github.com/spf13/cobra"
    "github.com/spf13/viper"
)

var cfgFile string

// rootCmd represents the base command when called without any subcommands
var rootCmd = &cobra.Command{
    Use:   "test",
    Short: "A brief description of your application",
    Long:  `A longer description...`,
}

// Execute adds all child commands to the root command and sets flags appropriately.
// This is called by main.main(). It only needs to happen once to the rootCmd.
func Execute() {
    err := rootCmd.Execute()
    if err != nil {
        os.Exit(1)
    }
}

func init() {
    cobra.OnInitialize(initConfig)
    rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.test.yaml)")
}

// initConfig reads in config file and ENV variables if set.
func initConfig() {
    if cfgFile != "" {
        // Use config file from the flag.
        viper.SetConfigFile(cfgFile)
    } else {
        // Find home directory.
        home, err := os.UserHomeDir()
        cobra.CheckErr(err)

        // Search config in home directory with name ".test" (without extension).
        viper.AddConfigPath(home)
        viper.SetConfigType("yaml")
        viper.SetConfigName(".test")
    }

    viper.AutomaticEnv() // read in environment variables that match

    // If a config file is found, read it in.
    if err := viper.ReadInConfig(); err == nil {
        fmt.Fprintln(os.Stderr, "Using config file:", viper.ConfigFileUsed())
    }
}

The init function of this root.go file calls cobra.OnInitialize which sets the passed functions to be run when each command's Execute method is called.

This is how mycmd.go looks.

package cmd

import (
    "fmt"

    "github.com/spf13/cobra"
    "github.com/spf13/viper"
)

// mycmdCmd represents the mycmd command
var mycmdCmd = &cobra.Command{
    Use:   "mycmd",
    Short: "A brief description of your command",
    Long:  `A longer description ...`,
    Run: func(cmd *cobra.Command, args []string) {
        // Dependency to inject
        someObj := viper.GetString("name")
        fmt.Println("mycmd called", someObj)
    },
}

func init() {
    rootCmd.AddCommand(mycmdCmd)
}

To make this testable, I wrapped the mycmdCmd into a function which would accept any dependencies needed by that command to work.

package cmd

import (
    "fmt"

    "github.com/spf13/cobra"
    "github.com/spf13/viper"
)

func newMyCmd(someObj string) *cobra.Command {
    // mycmdCmd represents the mycmd command
    var mycmdCmd = &cobra.Command{
        Use:   "mycmd",
        Short: "A brief description of your command",
        Long:  `A longer description ...`,
        Run: func(cmd *cobra.Command, args []string) {
            fmt.Println("mycmd called", someObj)
        },
    }
    return mycmdCmd
}

func init() {
    dependency:=viper.GetString("name")
    rootCmd.AddCommand(newMyCmd(dependency))
}

This way I can write tests and pass mock varibles to newMyCmd. But the problem is when user will run the command (go run ./main.go mycmd --config config.yaml), the rootCmd.AddCommand will be executed as part of init and during that time since viper is not yet initialized, viper.GetString("name") will be empty.

How can I make sure that viper is initialized before addCommand?

abnvanand
  • 203
  • 2
  • 6

1 Answers1

0

I would say that it's not a problem with init() function execution order.

First, multiple init() functions declared in a single file are executed in the order of their declaration, and init() functions declared across multiple files in a package are processed in alphabetical order of the file name(Don't bet on it). So in your example, init() in mycmd.go is prior to that in root.go.

Second, even if order is root.go->mycmd.go, it still would not work. Let's take a look at OnInitialze():

// OnInitialize sets the passed functions to be run when each command's
// Execute method is called.
func OnInitialize(y ...func()) {
    initializers = append(initializers, y...)
}

The functions passed to OnInitialize() are not invoked in the OnInitialize(). They would be invoked before each command's Execute method is called(at least after init() execution).

So if you really need values from Viper in init() of mycmd.go, you should better call initConfig() yourself in init().

FLAGLORD
  • 93
  • 4
  • but calling initConfig() in root.go's init() won't work either because of the order of call of init functions and I don't want to call initConfig in every command's init() function. Is there a better place to call rootCmd.AddCommand(myCommand) ? – abnvanand Jun 28 '23 at 09:25
  • also rootCmd has the flag config which specifies the file from where viper can load the values. So rootCmd must be executed before initConfig so that the variable cfgFile is set.. right? – abnvanand Jun 28 '23 at 09:29
  • @abnvanand You could invoke `rootCmd.AddCommand(newMyCmd(dependency))` in `root.go`. [kubuctl](https://github.com/kubernetes/kubectl/blob/master/pkg/cmd/apply/apply.go#L217-L219) just does something like that. – FLAGLORD Jun 28 '23 at 12:04