LoginSignup
5
1

More than 5 years have passed since last update.

Golang exec command Unit Test

Last updated at Posted at 2018-05-31

はじめに

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

まずは例題的に golang の exec.Commandlsssh を叩くケースを考えます。

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() のテストについて検討します。特にロジックはないので、本来ユニットテストを書く意味もないですが、ここではあくまで例としてユニットテストを書いていきます。これについては少し調べれば参考になる記事を見つけることができます。

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

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のソースコード上で見つけました。

終わり

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

$ 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

5
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
1