Golang exec command Unit Test

# はじめに
Golang のユニットテストでシステムコマンドを叩く mock を書くときに標準出力のテストは簡単に解説記事を見つけられましたが、標準入力や引数のチェックをどうすれば良いか困って試行錯誤した内容です。

まずは例題的に golang の `exec.Command` で `ls` と `ssh` を叩くケースを考えます。

```
package main

import (
  "fmt"
  "os/exec"
  "strconv"
  "strings"
)

func ls() (string, error) {
  cmd := exec.Command("ls")
  out, err := cmd.CombinedOutput()
  if err != nil {
    fmt.Printf(string(out))
    return string(out), err
  }
  return string(out), nil
}

func ssh(p int, in string) (string, error) {
  cmd := exec.Command("ssh", "root@localhost", "-p", strconv.Itoa(p))
  cmd.Stdin = strings.NewReader(in)
  out, err := cmd.CombinedOutput()
  if err != nil {
    fmt.Printf(string(out))
    return string(out), err
  }
  return string(out), nil
}

func main() {
  r, _ := ls()
  fmt.Print(r)
  p := 32768
  in := "touch hoge\ntouch piyo\n"
  ssh(p, in)
}
```

`func ls()` は単に `ls` を実行して実行結果を返す関数で、`func ssh(int, string)` はホストマシンで起動している sshd コンテナ（テスト用に docker で用意しました）の任意のポートに ssh 接続して任意のコマンドを実行して結果を返す関数です。

## 実行結果

```
$ go run exec.go
exec.go
exec_test.go
$ ssh root@localhost -p 32768 ls
hoge
piyo
```

# 標準出力のテスト

まずは標準出力のみをチェックすれば良さそうな `ls()` のテストについて検討します。特にロジックはないので、本来ユニットテストを書く意味もないですが、ここではあくまで例としてユニットテストを書いていきます。これについては少し調べれば参考になる[記事](https://qiita.com/hnw/items/d1a89267c2da7e4317ee)を見つけることができます。

上記の記事を参考にして本体コードを修正してテストコードを書きました。

```
package main

import (
  "fmt"
  "os/exec"
  "strconv"
  "strings"
)

var execCommand = exec.Command

func ls() (string, error) {
  cmd := execCommand("ls")
  out, err := cmd.CombinedOutput()
  if err != nil {
    fmt.Printf(string(out))
    return string(out), err
  }
  return string(out), nil
}

func ssh(p int, in string) (string, error) {
  cmd := execCommand("ssh", "root@localhost", "-p", strconv.Itoa(p))
  cmd.Stdin = strings.NewReader(in)
  out, err := cmd.CombinedOutput()
  if err != nil {
    fmt.Printf(string(out))
    return string(out), err
  }
  return string(out), nil
}

func main() {
  r, _ := ls()
  fmt.Print(r)
  p := 32768
  in := "touch hoge\ntouch piyo\n"
  ssh(p, in)
}
```

下記がテストコード。

```
package main

import (
  "fmt"
  "os"
  "os/exec"
  "testing"
)

const stub = "test_file\ntest_file2\n"

func fakeExecCommand(command string, args ...string) *exec.Cmd {
  cs := []string{"-test.run=TestHelperProcess", "--", command}
  cs = append(cs, args...)
  cmd := exec.Command(os.Args[0], cs...)
  cmd.Env = []string{"GO_WANT_HELPER_PROCESS=1"}
  return cmd
}

func TestHelperProcess(t *testing.T) {
  if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" {
    return
  }
  fmt.Fprint(os.Stdout, stub)
  os.Exit(0)
}

func TestLs(t *testing.T) {
  execCommand = fakeExecCommand
  defer func() { execCommand = exec.Command }()
  out, err := ls()
  if err != nil {
    t.Fatal(out)
  }
  if out != stub {
    t.Fatalf("Error: want %q, got %q", stub, out)
  }
}
```

本体コードの方では `exec.Command` をグローバル変数 `execCommand` に置き換えて、テストの方でこの変数を更にテスト用の関数 `fakeExecCommand()` に置き換えています。 `fakeExecCommand()` 内で、実行中のテストバイナリ自身を `exec.Command` で更に実行して、その際に `-test.run=TestHelperProcess` を付けて `TestHelperProcess()` のみを呼ぶという離れ業をしています。その際に、環境変数にフラグ `GO_WANT_HELPER_PROCESS=1` を立てることで、`fakeExecCommand()` から呼びされた時以外は何もしないようになっています。

さて、ここからが本題です。では `ssh()` のようなコマンドをテストする際に引数のチェックや標準入力のチェックはどのようにすれば良いでしょうか？

# 標準入力や引数のテスト

本体コード側の修正は不要で、テストコードを下記のように書き換えました。

```
package main

import (
  "fmt"
  "io/ioutil"
  "os"
  "os/exec"
  "strings"
  "testing"
)

const stub = "test_file\ntest_file2\n"

var testCase string

func fakeExecCommand(command string, args ...string) *exec.Cmd {
  cs := []string{"-test.run=TestHelperProcess", "--", command}
  cs = append(cs, args...)
  cmd := exec.Command(os.Args[0], cs...)
  tc := "TEST_CASE=" + testCase
  cmd.Env = []string{"GO_WANT_HELPER_PROCESS=1", tc}
  return cmd
}
func TestHelperProcess(t *testing.T) {
  if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" {
    return
  }
  defer os.Exit(0)
  args := os.Args
  for len(args) > 0 {
    if args[0] == "--" {
      args = args[1:]
      break
    }
    args = args[1:]
  }
  if len(args) == 0 {
    fmt.Fprintf(os.Stderr, "No command\n")
    os.Exit(2)
  }
  switch os.Getenv("TEST_CASE") {
  case "case1":
    fmt.Fprint(os.Stdout, stub)
  case "case2":
    e := "ssh root@localhost -p 22"
    if s := strings.Join(args, " "); s != e {
      fmt.Fprintf(os.Stderr, "Error: want %q, got %q", e, s)
      os.Exit(1)
    }
    b, err := ioutil.ReadAll(os.Stdin)
    if err != nil {
      fmt.Fprintf(os.Stderr, "Error: %v\n", err)
      os.Exit(1)
    }
    e = "touch aaa\ntouch bbb\n"
    if s := string(b); s != e {
      fmt.Fprintf(os.Stderr, "Error: Read %q, want %q", s, e)
      os.Exit(1)
    }
  }
}

func TestLs(t *testing.T) {
  testCase = "case1"
  execCommand = fakeExecCommand
  defer func() { execCommand = exec.Command }()
  out, err := ls()
  if err != nil {
    t.Fatal(out)
  }
  if out != stub {
    t.Fatalf("Error: want %q, got %q", stub, out)
  }
}

func TestSsh(t *testing.T) {
  testCase = "case2"
  execCommand = fakeExecCommand
  defer func() { execCommand = exec.Command }()
  out, err := ssh(22, "touch aaa\ntouch bbb\n")
  if err != nil {
    t.Fatal(out)
  }
}
```

テストを複数書いていくのですが、テストごとに `fakeExecCommand()` と `TestHelperProcess()` を用意するのも面倒なので、環境変数のフラグを活用しました。`testCase` というグローバル変数を用意して、各テストごとにテスト番号を `string` で与えて `GO_WANT_HELPER_PROCESS` と一緒に環境変数として渡して `TestHelperProcess()` 内でその値に応じてテストを分岐させています。ここでは `ssh()` のテストである `TestSsh()` は `testCase = "case2"` としています。

## 引数のチェック
`exec.Command()` に与えれるはずだったシステムコマンドと引数は `fakeExecCommand()` にそのまま引数として与えられて、`TestHelperProcess()` 呼び出し時のコマンドにそのまま与えられています。それを抽出してチェックするだけで良いです。テストコード上では `"ssh root@localhost -p 22"` が与えられていることをチェックして、もし異なれば `fmt.Fprintf(os.Stderr, "")` で標準出力エラーとしてコマンドを実行したプログラムが受け取れるようにしています。

## 標準入力のチェック
本体コード側で `cmd.Stdin` によって入力された文字列は `os.Stdin` でテスト側で受け取ることができます。`b, err := ioutil.ReadAll(os.Stdin)` で全ての入力を受け取り、想定通りのものかチェックして、想定外のものであれば `fmt.Fprintf(os.Stderr, "")` で標準出力エラーとしてコマンドを実行したプログラムが受け取れるようにしています。

このテクニックは[Goのソースコード](https://github.com/golang/go/blob/master/src/os/exec/exec_test.go)上で見つけました。

# 終わり
下記の通り、全てのテストが通ることを確認できました。

```
$ go test -v
=== RUN   TestHelperProcess
--- PASS: TestHelperProcess (0.00s)
=== RUN   TestLs
--- PASS: TestLs (0.00s)
=== RUN   TestSsh
--- PASS: TestSsh (0.00s)
PASS
ok  	_/tmp/test	0.012s
```

試しに本体コードの `ssh()` で下記のように `p` をインクリメントしてみます。

```
func ssh(p int, in string) (string, error) {
  p++
  cmd := execCommand("ssh", "root@localhost", "-p", strconv.Itoa(p))
```

テストを実行するとちゃんとエラーになります

```
$ go test -v
=== RUN   TestHelperProcess
--- PASS: TestHelperProcess (0.00s)
=== RUN   TestLs
--- PASS: TestLs (0.00s)
=== RUN   TestSsh
Error: want "ssh root@localhost -p 22", got "ssh root@localhost -p 23"--- FAIL: TestSsh (0.00s)
	exec_test.go:83: Error: want "ssh root@localhost -p 22", got "ssh root@localhost -p 23"
FAIL
exit status 1
FAIL	_/tmp/test	0.012s
```

# 参考
https://qiita.com/hnw/items/d1a89267c2da7e4317ee
https://github.com/golang/go/blob/master/src/os/exec/exec_test.go