Edited at

Go で外部コマンドをユニットテストする

More than 3 years have passed since last update.

envjson というコマンドを作る上で得られた知見として、外部コマンドをユニットテストする方法について紹介します。

テスティングフレームワークとしてはとりあえず標準の testing を使っていますが、これは他の何でもいいと思います。

(私自身はこれ以外のフレームワークはまだ使ったことがないんですが)


TL;DR

os/execexec.Command() でコマンドを実行し、標準入出力として bytes.Buffer を差し込んでおくことでテストが可能。


exec.Cmd()

標準ライブラリ "os/exec" に含まれる exec.Command() 関数は外部コマンドを実行するための関数です。

より正確には、exec.Command() 関数で exec.Cmd 構造体を返し、Run() メソッドを実行することで外部コマンドが実行されます。

実行する前に exec.Cmd 構造体が持つプロパティをいろいろと差し替えることで、コマンドの入出力を差し替えることが可能です。


テスト対象のコマンド

パイプから入力を受け取り、フィルタするいかにも UNIX 的なコマンドラインツールのことを考えます。

例えば、標準入力を受け取って、それを 2 回出力する double コマンドについて考えてみます。


main.go

package main

import (
"io/ioutil"
"os"
)

func main() {
buf, err := ioutil.ReadAll(os.Stdin)
if err != nil {
panic(err)
}

os.Stdout.Write(buf)
os.Stdout.Write(buf)
}


今回はこれをテストするコードを用意します。

なお、今回は外部コマンドを直接実行することでテストするので、あらかじめ go build して double を executable としてコンパイルしておく必要があります。

$ go build -o double

$ echo foo | ./double
foo
foo

このように、標準入力として受け取った foo という文字列を 2 回出力しています。


標準入出力を差し替える

exec.Cmd 構造体は Stdio, Stdout, Stderr というプロパティを持っており、それらにストリームを渡すことで、標準入出力を差し替えて実行することができます。

以下は標準入力として "foo\n" を渡し、標準出力として実行元プロセスの標準出力を渡しています。


main_test.go

package main

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

func TestDouble(t *testing.T) {
cmd := exec.Command("./double")
cmd.Stdin = bytes.NewBufferString("foo\n")
cmd.Stdout = os.Stdout

err := cmd.Run()
if err != nil {
t.Fatal("failed to execute ./double\n")
}
}


ここで go test を実行すると以下のような出力が得られます。

$ go test

foo
foo
PASS
ok github.com/yuya-takeyama/double 0.054s

文字列 "foo\n"./double に渡すことでそれが 2 回、標準出力に出力されました。

ここで標準入力には bytes.Buffer 構造体を使用しています。

これは Ruby や Python における StringIO オブジェクトのようなもので、ファイルのスタブとして使うのに便利です。

詳しくは I/O を伴うテストには bytes.Buffer が便利 を参照してください。


外部コマンドの出力をテストする

これをさらに一歩進めれば、「標準入力として "foo\n" を渡せば標準出力に "foo\nfoo\n" が出力される」といったユニットテストができます。


main_test.go

package main

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

func TestDouble(t *testing.T) {
cmd := exec.Command("./double")
cmd.Stdin = bytes.NewBufferString("foo\n")
stdout := new(bytes.Buffer)
cmd.Stdout = stdout

err := cmd.Run()
if err != nil {
t.Fatal("failed to execute ./double\n")
}

if stdout.String() != "foo\nfoo\n" {
t.Fatal("not matched")
}
}


変数 stdout に空の bytes.Buffer 構造体をセットしています。

cmd.Run() によってコマンドを実行すると、その出力は stdout 変数に書き込まれることになります。

コマンドの実行後、bytes.BufferString() メソッドで中身を取り出し、期待される出力と比較することでテストを行っています。

bytes.Buffer は内部的には []byte でデータを保持しますが、このように string として取り出すことができます。

Bytes() メソッドで []byte を受け取ることもできるので、必要に応じて使い分けると良いでしょう。