🎁

goでコマンドライン引数(flag)周りの処理をテストする

2021/02/17に公開

Go を勉強し始めて、go で CLI ツールを実装する際に、flag パッケージを利用したコマンドライン引数周りのテストを行うことがありました。
この記事では画像ファイルの変換処理を例に、処理の実装からテストの実装までの流れを備忘録的にまとめておきます。

手元の環境は、下記の通りです。

  • OS: macOS Catalina 10.15.4
  • Go: 1.15.7
❯❯❯ go version
go version go1.15.7 darwin/amd64

コマンドライン引数のセット

init 関数( init() )内でコマンドライン引数周りの初期化処理を書きます。

Effective Go では init 関数の使用場面について下記のように記載されています。
https://golang.org/doc/effective_go#init

Besides initializations that cannot be expressed as declarations, a common use of init functions is to verify or repair correctness of the program state before real execution begins.
(init関数の一般的な使用法は、宣言として表現できない初期化に加えて、実際の処理の実行が始まる前にプログラムの状態の正しさを検証または修復することです。)

init 関数の中では、 flag.StringVar() を実行して、コマンドラインで利用するフラグと値を格納する変数を紐付けます。
(画像ファイルの変換処理では、変換前と後のフォーマット+画像ファイルが格納されているフォルダのパスが必要なので、それらの変数を定義します。)
https://golang.org/pkg/flag/#StringVar


var (
	imgFormat          string
	convertedImgFormat string
	dirPath            string
)

func init() {
    flag.StringVar(&imgFormat, "fmt", "jpg", "the format of image files (jpg/jpeg/png/gif)")
    flag.StringVar(&convertedImgFormat, "outfmt", "png", "the format of converted image files(jpg/jpeg/png/gif)")
    flag.StringVar(&dirPath, "dir", ".", "filepath")
}

変数の定義後に flag.Parse() を実行すると、コマンドライン引数の値が変数に格納されます。
(parse 以前は、定義された変数に flag.StringVar で定義しているデフォルト値が格納されています。)
https://golang.org/pkg/flag/#Parse

func main() {
    if err := validateArgs(); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
    ...
}

ここでの注意点として、init 関数内では flag.Parse() を呼ばないようにする必要があります。

パッケージの初期化処理中に、 flag.Parse() を呼び出すとテストが失敗する可能性があるためです。
https://tip.golang.org/doc/go1.13#testing

packages that call flag.Parse during package initialization may cause tests to fail.

go test -v -cover
flag provided but not defined: -test.timeout
Usage of /var/folders/4h/5hpnhh9n6858yc9w42jx6nl00000gn/T/go-build263838406/b001/taxin.test:

コマンドライン引数のValidation

コマンドライン引数を検証するために、 validateArgs() という関数を定義しています。

validateArgs() では、コマンドライン引数に対して下記を確認しています。

  • 指定されたフォルダの有無
  • 画像ファイルのフォーマット(正しいフォーマットか、変換処理に対応しているフォーマットかどうか)
func validateArgs() error {
    flag.Parse()
    if _, err := os.Stat(dirPath); err != nil {
        return errors.New("Error: Doesn't exists the directory that you specified")
    }
    if !isValidFileFormat(imgFormat) || !isValidFileFormat(convertedImgFormat) {
        return errors.New("Error: Invalid or Unsupported file format")
    }
    return nil
}

func validateFileFormat(passedImgFormat string) bool {
    for _, f := range []string{"jpg", "jpeg", "png", "gif"} {
        if f == passedImgFormat {
            return true
        }
    }
    return false
}

Validation処理に対するテスト

Validation の処理が正常通りに実装されているかを確認するために、 validateArgs() に対するテストを書きます。

ここでは、正常系・異常系の両方について確認したいため、エラーの発生を想定しているかどうかを errRaisedFlag (bool)で渡しておきます。
(厳密には「想定しているエラーが発生しているか」も確認する必要がありますが、今回は省略しています。)

validateArgs() で返された error をエラー発生の有無(bool)に変換した上で、 errRaisedFlag と比較します。

func errExists(t *testing.T, err error) bool {
    t.Helper()
    if err != nil {
        return true
    }
    return false
}

func TestValidateArgs(t *testing.T) {
    imgDirDataTests := []struct {
        caseName           string
        imgFormat          string
        convertedImgFormat string
        dirPath            string
        errRaisedFlag      bool
    }{
        {"case1", "png", "jpg", "testdata", false},
        {"case2", "jpg", "svg", "testdata", true},
        {"case3", "png", "jpg", "testdatahoge", true},
        {"case4", "jpg", "gif", "testdata", false},
    }

    for _, tt := range imgDirDataTests {
        t.Run(tt.caseName, func(t *testing.T) {
            flag.CommandLine.Set("fmt", tt.imgFormat)
            flag.CommandLine.Set("outfmt", tt.convertedImgFormat)
            flag.CommandLine.Set("dir", tt.dirPath)

            err := validateArgs()
            if errRaised(t, err) != tt.errRaisedFlag {
                t.Errorf("error: %#v", err)
            }
        })
    }
}

テスト時には、任意の値をコマンドライン引数としてセットするために flag.CommandLine.Set("<cli_option>", <variable>) を利用します。
https://golang.org/pkg/flag/#Set

正常系・異常系も含めた一連のテストを実行すると、下記のようになります。

go test ./... -v -cover
=== RUN   TestValidateArgs
=== RUN   TestValidateArgs/case1
=== RUN   TestValidateArgs/case2
=== RUN   TestValidateArgs/case3
=== RUN   TestValidateArgs/case4
--- PASS: TestValidateArgs (0.00s)
    --- PASS: TestValidateArgs/case1 (0.00s)
    --- PASS: TestValidateArgs/case2 (0.00s)
    --- PASS: TestValidateArgs/case3 (0.00s)
    --- PASS: TestValidateArgs/case4 (0.00s)
PASS

set() は flag パッケージの top-level functions ですが、 flag.CommandLine 経由からでも呼べるようです。
https://golang.org/pkg/flag/#pkg-variables

CommandLine is the default set of command-line flags, parsed from os.Args. The top-level functions such as BoolVar, Arg, and so on are wrappers for the methods of CommandLine.

終わりに

コマンドライン引数(flag)周りの処理の実装からテストまでをまとめてみました。
より良い実装案などあれば、コメントいただけると嬉しいです。

Discussion