KEINOS
@KEINOS (KEINOS)

Are you sure you want to delete the question?

Leaving a resolved question undeleted may help others!

[Golang] import した外部パッケージをモックしてテストする方法を知りたい。できれば testify で。

解決したいこと

Go 言語(以下 Golang)で単体テストをする際に、import した外部パッケージの関数(メソッド)の挙動を変更させたい

問題のアプリは、コマンド作成支援パッケージの「cobra」を使った簡単な Hello, world! の CLI アプリです。

$ cobra init 後、$ cobra add hello でコマンドのファイル(./cmd/root.go, ./cmd/hello.go)を作成し、テスト・ファイル(./cmd/root_test.go, ./cmd/hello_test.go)を追加作成しました。

単体テストには testing とフレームワークの testify を使っています。

発生している問題

./cmd/root.go./cmd/hello.go のうち、hello.gohello_test.go のテストでカバレッジは網羅できたのですが、root.go のテスト root_test.go で、たどりつけない(網羅できない)箇所があります。

以下の cobra&cobra) 経由の rootCmd.Execute() の挙動を変えることができず、最後のカバレッジ率を上げられずに困っています。

./cmd/root.go
package cmd

import (
    "fmt"
    "os"

    "github.com/spf13/cobra"
)

var rootCmd = &cobra.Command{
    Use:   "Hello-Cobra",
    Short: "This is a sample CLI app.",
    Long:  "This is a sample of how to use cobra package and unit-testing.",
}

...

func Execute() {
    var err = rootCmd.Execute() // ← テスト時、このメソッドの戻り値を nil か -1 で返して動作確認したい
    if err != nil {
        // テスト時ここまでたどりつけない
        fmt.Println(err)
        os.Exit(1)
    }
}

...
カバレッジ情報
$ go test -v -coverprofile coverage.out ./cmd && go tool cover -func=coverage.out
=== RUN   Test_sayHelloWorld
--- PASS: Test_sayHelloWorld (0.00s)
=== RUN   Test_initConfig
--- PASS: Test_initConfig (0.00s)
PASS
coverage: 54.5% of statements
ok      github.com/KEINOS/Hello-Cobra/cmd       0.006s  coverage: 54.5% of statements
github.com/KEINOS/Hello-Cobra/cmd/hello.go:19:  init            100.0%
github.com/KEINOS/Hello-Cobra/cmd/hello.go:23:  sayHelloWorld   100.0%
github.com/KEINOS/Hello-Cobra/cmd/root.go:18:   Execute         0.0%     // ← これが上げられない
github.com/KEINOS/Hello-Cobra/cmd/root.go:26:   init            100.0%
github.com/KEINOS/Hello-Cobra/cmd/root.go:32:   initConfig      100.0%
total:                                          (statements)    60.0%

状況情報

  • 質問者 Go 歴: 浅いです。スライスと配列の違いがやっと理解出来て、パッケージをふいんきで使っている程度です。
  • OS: Alpine Linux 3.12.0 @ Docker
go.mod
module github.com/KEINOS/Hello-Cobra

go 1.15

require (
    github.com/stretchr/testify v1.6.1     // テストで Assert を使うのに利用
    github.com/spf13/cobra v1.1.0          // コマンドの作成と管理をするのに利用
    github.com/mitchellh/go-homedir v1.1.0 // cobra が設定ファイルのパスを検知するのに必要
    github.com/spf13/viper v1.7.0          // cobra が設定ファイルを読み込むのに必要
)

ソースコード

ディレクトリ構成
.
├── cmd
│   ├── hello.go // ...... hello コマンド本体
│   ├── hello_test.go
│   ├── root.go // ....... コマンド管理のルート・コマンド
│   └── root_test.go
├── go.mod
├── go.sum
└── main.go // ........... root.go を呼び出すだけの本体
main.go
package main

import "github.com/KEINOS/Hello-Cobra/cmd"

func main() {
    cmd.Execute() // ./cmd/root.go の Execute() が呼ばれる
}

cmd/hello.gocmd/root.go のうち、hello.go のカバレッジは網羅しているため、問題のコードは root_test.go になります。

【OK】 な方のコード(hello.go とそのテストのソースコード)

hello.go
package cmd

import (
    "fmt"

    "github.com/spf13/cobra"
)

// helloCmd represents the hello command
var helloCmd = &cobra.Command{
    Use:   "hello",
    Short: "Says hello to the world.",
    Long:  "It simply displays Hello, world!",
    Run: func(cmd *cobra.Command, args []string) {
        sayHelloWorld()
    },
}

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

func sayHelloWorld() {
    fmt.Println("Hello, world!")
}

hello_test.go
package cmd

import (
    "bytes"
    "os"
    "strings"
    "testing"

    "github.com/stretchr/testify/assert"
)

func Test_sayHelloWorld(t *testing.T) {
    var expect = "Hello, world!"

    // 標準出力を受け取る準備
    var buf bytes.Buffer
    var tmpStdout = os.Stdout
    var fpRead, fpWrite, _ = os.Pipe() // Pipe のファイルポインタ作成
    // defer で標準出力を遅延実行
    defer func() {
        os.Stdout = tmpStdout
    }()
    os.Stdout = fpWrite

    // 実行
    sayHelloWorld()
    fpWrite.Close()      // クローズしないと永遠に読み込み待ち状態になる
    buf.ReadFrom(fpRead) // 出力結果を取得
    var actual = strings.TrimRight(buf.String(), "\n")

    // 診断
    assert.Equal(t, expect, actual)
}

【NG】 な方のコード(root.go とそのテストのソースコード)

root.go
package cmd

import (
    "fmt"
    "os"

    "github.com/spf13/cobra"
)

var cfgFile string

var rootCmd = &cobra.Command{
    Use:   "Hello-Cobra",
    Short: "This is a sample CLI app.",
    Long:  "This is a sample of how to use cobra package and unit-testing.",
}

func Execute() {
    var err = rootCmd.Execute() // ← 問題の箇所
    if err != nil {
        fmt.Println(err)
        os.Exit(1)
    }
}

func init() {
    cobra.OnInitialize(initConfig)
    rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.Hello-Cobra.yaml)")
    rootCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle")
}

func initConfig() {
    // TODO: 設定ファイルからの読み込み
    // 後日実装なので hello と同じ状態にしている
    fmt.Println("Read configuration.")
}

root_test.go
package cmd

import (
    "bytes"
    "os"
    "strings"
    "testing"

    "github.com/stretchr/testify/assert"
)

func Test_initConfig(t *testing.T) {
    var expect = "Read configuration."

    // 標準出力を受け取る準備
    var buf bytes.Buffer
    var tmpStdout = os.Stdout
    var fpRead, fpWrite, _ = os.Pipe() // Pipe のファイルポインタ作成
    // defer で標準出力を遅延実行
    defer func() {
        os.Stdout = tmpStdout
    }()
    os.Stdout = fpWrite

    // 実行
    initConfig()
    fpWrite.Close()      // クローズしないと永遠に読み込み待ち状態になる
    buf.ReadFrom(fpRead) // 出力結果を取得
    var actual = strings.TrimRight(buf.String(), "\n")

    // 診断
    assert.Equal(t, expect, actual)
}

func TestExecute(t *testing.T) {
    // ここの作り方がわからない
}

自分で試したこと

root_test.go
...

func TestExecute(t *testing.T) {
    // false の場合がテスト出来ていないので、50% しか得られない
    Execute()
}

...

おそらく、rootCmd.Execute() のモックを作成して挙動を変更するのだと思います。

「"golang" testify mock cobra "rootCmd.Execute()"」でググってみたり、以下のサイトを見てみたのですが、どうやら同じパッケージ内の関数に対してのモックの場合のようで、よくわかりませんでした。

それとも root.goExecute() 自体をモックするものなのでしょうか。その場合は、rootCmd.Execute() の挙動が異なった場合のテストにならない気がするのですが。。。よくわかってません。

よろしくお願いいたします。

0

3Answer

Comments

  1. @KEINOS

    Questioner

    ちょうど、この記事も見つけて試していたのですが、カバレッジは変わりませんでした。。。

    よく読んで見たら、(コメントにもあるように)これはテストの仕方を説明しているだけのようで、テスト内に書かれた同名の関数をテストしているため、ターゲットとなる関数自体を網羅していないのでカバレッジは変わらないのは当然のことでした。とほほ。

    もっと高度なモックのフレームワークならできるのでしょうか。

とりあえず、Cobra を使ったカバレッジ 100% のシンプルな Hello, world! が作れたので、クローズします。

今、改めて質問を読み返すと、わかりづらい質問ですね。

ポイントは、github.com/kami-zh/go-capturerと言う「標準出力をキャプチャする」と言う、神さまのようなパッケージを使ったことです。

./main.go./cmd/root.go のテストも、各々のテストで main()Execute() を実行した結果をキャプチャすることで、網羅することができました。

実は cobra標準出力系のラッパーcobra.command 内に持っています。自作した各コマンド内で mycmd.Println("hoge") のように使うと、cobra を経由して標準出力されます。

出力先は、.SetOutput() で切り替えられるようなので、テスト時に対象となる自作コマンドに使って出力先をバッファにするなどの方法もありそうです。

0Like

Your answer might help someone💌