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