Goで標準出力をテストする方法


この記事は[Go4 Advent Calendar 2017](https://qiita.com/advent-calendar/2017/go4)の2日目の記事です。
現在業務で携わっているLinuxプログラミングについて書く予定でしたが、あまり関係のないテストについて書いてみます。
なお11月からGoを触りはじめたばかりなので、ツッコミなどあればどんどんコメントいただければ幸いです。

筆者アカウント: [Twitter](https://twitter.com/kami_zh), [GitHub](https://github.com/kami-zh)

## 追記

この記事の内容をパッケージ化しました。
よかったらあわせて見ていただければと思います。

- https://github.com/kami-zh/go-capturer

## はじめに

`fmt.Print()` などの出力をテストしたい場合、次のように `fmt.Fprint()` を用いて `io.Writer` を指定する形で文字列を出力し、通常実行時はこれに `os.Stdout` をセットしつつテストでは `*bytes.Buffer` に差し替えることで、その内容を検証することができます。

`main.go`:

```go
var writer io.Writer

func init() {
	writer = os.Stdout
}

func fprint(a ...interface{}) {
	fmt.Fprint(writer, a...)
}
```

次のように `io.Writer` として `*bytes.Buffer` をセットすると、文字列が出力された際に `buffer.String()` をとおして参照することができます。

`main_test.go`:

```go
var buffer *bytes.Buffer

func init() {
	buffer = &bytes.Buffer{}
	writer = buffer
}
```

こういった実装ができればよいのですが、 `fmt.Print()` を使用した（かつ自分の裁量による変更が難しい）既存のコードに対するテストを書く場合や、テスト実行時に外部パッケージが `os.Stdout` あるいは `os.Stderr` に出力してしまう場合など、出力をキャプチャまたは抑制したいケースがあるかもしれません。

これは `os.Stdout` や `os.Stderr` に別途 `*os.File` 型の値をセットすることで実現できるのですが、ここでは例をまじえてまとめたいと思います。

## 例: 加算を行うプログラム
例として、まず次のような足し算を行う関数と、それを標準出力に表示する関数をもつ `main.go` があるとします。

```go
package main

import (
	"flag"
	"fmt"
	"os"
)

const (
	ExitCodeOK = iota
	ExitCodeError
)

func main() {
	os.Exit(run(os.Args[1:]))
}

func sum(x, y int) int {
	return x + y
}

func run(args []string) int {
	var x, y int

	flags := flag.NewFlagSet("sum", flag.ContinueOnError)
	flags.IntVar(&x, "x", 0, "Value for x")
	flags.IntVar(&y, "y", 0, "Value for y")
	flags.Parse(args)

	fmt.Printf("%d + %d = %d\n", x, y, sum(x, y))

	return ExitCodeOK
}
```

コマンドライン引数のパースにflagパッケージを用いていますが、ここではさらに `flag.NewFlagSet()` により新しいパーサを作成しています。
これにより `flags.Parse()` にコマンドライン引数を渡せるようになり、テストが書きやすくなります。

このコードを実行すると、次のような結果が得られます。

```
$ go run main.go -x 2 -y 3
2 + 3 = 5
```

## テストコード
この `main.go` に対するテストコード `main_test.go` の実装方針として、次の2つについて書くとよさそうです。

1. ユニットテスト: `sum()` の返り値をテストする
2. インテグレーションテスト: `run()` の出力をテストする

それぞれについて書きます。

### ユニットテスト
ユニットテストは、標準のtestingパッケージを使って適当に書くと次のようになると思います。

```go
package main

import "testing"

func TestSum(t *testing.T) {
	if sum(2, 3) != 5 {
		t.Errorf("sum(2, 3) = %d, want 5", sum(2, 3))
	}
}
```

### インテグレーションテスト
この小さなコードでインテグレーションテストといっていいか分かりませんが、「 `sum()` した結果を `fmt.Printf()` する」という統合的な処理をテストする場合を考えます。

コマンドライン引数として `-x 2 -y 3` を渡した際に `2 + 3 = 5` という結果が表示されることをテストしたいのですが、このままだとコンソールに結果が表示されるだけで、コード上の値としてこれを得ることはできません。

この標準出力自体をテストしたい場合、どうすればよいか……ということですが、私は次のようなヘルパを書いて対応しています。

```go
package main

import (
	"bytes"
	"io"
	"os"
)

func captureStdout(f func()) string {
	r, w, err := os.Pipe()
	if err != nil {
		panic(err)
	}

	stdout := os.Stdout
	os.Stdout = w

	f()

	os.Stdout = stdout
	w.Close()

	var buf bytes.Buffer
	io.Copy(&buf, r)

	return buf.String()
}
```

`os.Pipe()` の行は入出力のパイプで、 `w` に書き込まれたものを `r` をとおして取得できます。
`fmt.Printf` の出力先を一時的に `os.Stdout` から `w` に変更することで、表示結果を値として取得することができます。

注意すべき点として、このコードはスレッドセーフではないことと、 `os.Stderr` は依然として表示されてしまうのでキャプチャしたい場合はこれについても処理する必要があります。

この `captureOutput()` を用いると、インテグレーションテストは次のように書けます。

```go
package main

import "testing"

func TestRun(t *testing.T) {
	var code int

	out := captureStdout(func() {
		code = run([]string{"-x", "2", "-y", "3"})
	})

	if code != ExitCodeOK {
		t.Errorf("Unexpected exit code: %d", code)
	}

	if out != "2 + 3 = 5\n" {
		t.Errorf("Unexpected output: %s", out)
	}
}
```

標準出力としてコンソール上に表示されていた文字列が変数 `out` に格納されるため、これに対する評価式を書くことでテストとすることができます。

## io.Writerで出力先を制御する
はじめに書いた `io.Writer` を渡す方法の場合、次のような感じになると思います。
`fmt.Fprintf()` に `io.Writer` を渡すようにし、 `main.go` では `os.Stdout` をセットしつつ `main_test.go` では別の値を渡すことで、その内容をテストします。

`main.go`:

```go
var writer io.Writer

func init() {
	writer = os.Stdout
}

// snip

func run(args []string) {
	// snip

	fprintf("%d + %d = %d", x, y, sum(x, y))

	// snip
}

func fprintf(format string, a ...interface{}) {
	fmt.Fprintf(writer, format, a...)
}
```

`main_test.go`:

```go
package main

import (
	"bytes"
	"testing"
)

var buffer *bytes.Buffer

func init() {
	buffer = &bytes.Buffer{}
	writer = buffer
}

func TestRun(t *testing.T) {
	buffer.Reset()

	code := run([]string{"-x", "2", "-y", "3"})

	if code != ExitCodeOK {
		t.Errorf("Unexpected exit code: %d", code)
	}

	if buffer.String() != "2 + 3 = 5\n" {
		t.Errorf("Unexpected output: %s", buffer.String())
	}
}
```

注意すべき点として、 `buffer` はクリアしない限り追記されるので、出力内容を検証するテストケースでははじめに `buffer.Reset()` でバッファをクリアする必要があります。

## おわりに
以上は私が標準出力をテストすることになったときに行った方法ですが、他によいやり方などありましたらぜひコメントなどでご教示いただければと思います :pray:

## 参考記事
* [Go言語でテストしやすいコマンドラインツールをつくる](http://deeeet.com/writing/2014/12/18/golang-cli-test/)
* [Capture STDOUT in Go](http://craigwickesser.com/2015/01/capture-stdout-in-go/)
* [testify/suite_test.go](https://github.com/stretchr/testify/blob/3104bf5483d3fba84f24933949f0533f4467880b/suite/suite_test.go#L247-L268)
* [golangでIOへのテストを行う](https://ota42y.com/blog/2015/04/01/go-io-test/)