Golang でスマートに標準出力テストを行う

commits3 min read読了の目安(約3500字

Golang で標準出力テストの方法の調査

最近Golangで標準出力をテストする際にどのような方法を取れば良いのか情報も少なく、しばらく迷走していたので、ここで一旦メモとしてまとめておく。

標準出力をテストする方法を模索した結果

今回調査した方法は主に以下の2つ

  • os.Pipe() を駆使して出力先を切り替えて値をキャプチャする
  • Testable Examples を利用する

os.Pipe() を駆使して出力先を切り替えて値をキャプチャする

まず初めに、標準出力を受け取る関数を定義して、出力先を切り替えて値をキャプチャする方法が妥当であろうと考え、以下のような共通関数を定義して呼び出すことにしました。

func PickStdout(t *testing.T, fnc func()) string {
	t.Helper()
	backup := os.Stdout
	defer func() {
		os.Stdout = backup
	}()
	r, w, err := os.Pipe()
	if err != nil {
		t.Fatalf("fail pipe: %v", err)
	}
	os.Stdout = w
	fnc()
	w.Close()
	var buffer bytes.Buffer
	if n, err := buffer.ReadFrom(r); err != nil {
		t.Fatalf("fail read buf: %v - number: %v", err, n)
	}
	s := buffer.String()
	return s[:len(s)-1]
}

この PickStdout 関数の冒頭で t.Helper() を呼ぶと $ go test の結果に関して失敗の箇所を具体的に示してくれるので、本来のテストに原因があるのか、この関数自身に問題があるのか、原因の切り分けを行うことができます。

os パッケージの os.Stdout には標準出力が格納されているので、まず本来の値をバックアップとして、 変数 backup に格納します。

os.Pipe() で reader と writer を生成します。
そして 標準出力の出力先を os.Stdout から writer に切り替えます。

r, w, err := os.Pipe()
if err != nil {
	t.Fatalf("fail pipe: %v", err)
}
os.Stdout = w

バグに繋がるので、きちんとエラーを受けることを忘れないようにしましょう。

引数から受け取った関数を呼び出します。

fnc()

その後 writer を close します。

w.Close()

次に書き込んだ出力を読み取ります、読み取りのためのバッファを定義します。

var buffer bytes.Buffer

stdin と stdout はどちらも bytes.Buffer 構造体を生成しています。
この構造体を変数bufferとして宣言します。

buffer に reader で読み取った値を格納します。
bytes.Buffer 構造体から標準出力の値を引っ張ってきます。

if n, err := buffer.ReadFrom(r); err != nil {
    t.Fatalf("fail read buf: %v - number: %v", err, n)
}

バグに繋がるので、きちんとエラーを受けることを忘れないようにしましょう。

取得した値はbyteデータなので String() で文字列に変換し、変数 s に格納します。
ここで、末尾に余計な改行が追記されていることが確認できると思います。

fmt.Print(s)

Pipe を使用した際に buffer 末尾に改行が追加される問題の対処のため返り値のスライスの末尾を除去します。

s[:len(s)-1]

最後にこれは遅延評価関数として定義しているが、バックアップしていた標準出力を元に戻すということをここで行なっています。

defer func() {
    os.Stdout = orgStdout
}()

この方法をとるメリットとしては、大量のテストデータを使ってテストする際に効果を発揮するのではないでしょうか。
ある程度自由度が高いことが挙げられると思います。

ちなみに playground でもなんと test を記述、検証できます。
この例を検証してみたのでここで動作を確認できます。

PickStdout↓

https://play.golang.org/p/PbR8hlGJv-T

Testable Examples を利用する

とにかく手軽にテストができます。

これは testing パッケージに含まれている機能です。
実行例をそのままテストコード内に記述できるので大変便利です。

ただいくつか記述に制約があります。

  • 関数名の先頭に必ず Example という文字列を含めなければならない
  • 期待する出力データを表現するには // Output: からコメントとして記述する必要がある

以下記述の簡単な例です。

func printTest() {
	for i := 0; i < 3; i++ {
		fmt.Println(test + string(i))
	}
}
func ExamplePrint() {
	printTest()
	// Output:
	// test0
	// test1
	// test2
}

実際に playground で実行してみるとその手軽さが実感できると思います。
この例も playground で検証しています。

Testable Examples↓

https://play.golang.org/p/aMheW3AxTsA

また同じ要領で準不同な結果に対してもマッチさせることができる Unordered output という機能もあります。

Unordered output↓

https://play.golang.org/p/BbOresk_Szy

このように一見万能に見える Testable Examples ですが、検証したいデータが大量にある&&頻繁にデータを更新するような環境 では使い勝手あまり良くないというデメリットがあると思います。
また、1関数内に1 // Output: しか記述できないので、あまり複雑なことはできません。
シンプルゆえに使い所を意識する必要があると思います。

今回の場合は同じprintTest関数に対してのテストでしたがこの場合は明らかに Testable Examples を使うべき状況であるということが分かると思います。

PickStdout↓

https://play.golang.org/p/PbR8hlGJv-T

Testable Examples↓

https://play.golang.org/p/aMheW3AxTsA

最後に

Golangは強力なテストライブラリが標準パッケージで提供されているのでついつい簡単なコードに対しても test を書きたくなるような不思議な魅力がありますよね。

今回は簡単な調査でしたが、今後も継続してより Go らしい test の書き方を模索していきたいと思います。

※間違い、誤植等、発見されましたらご指摘、ご指南いただけると幸いです。

参考文献

「Golang で標準出力をテストする」https://oden77.hatenablog.com/entry/2020/01/31/233532
「I/O を伴うテストには bytes.Buffer が便利」 https://qiita.com/yuya_takeyama/items/c4211fa77488cb6915ec
みんなのGo言語【現場で使える実践テクニック】 松木 雅幸 他 技術評論社