Go
テスト
Go4Day 2

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

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

筆者アカウント: Twitter, GitHub

追記

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

はじめに

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

main.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:

var buffer *bytes.Buffer

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

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

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

例: 加算を行うプログラム

例として、まず次のような足し算を行う関数と、それを標準出力に表示する関数をもつ main.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パッケージを使って適当に書くと次のようになると思います。

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 という結果が表示されることをテストしたいのですが、このままだとコンソールに結果が表示されるだけで、コード上の値としてこれを得ることはできません。

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

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() を用いると、インテグレーションテストは次のように書けます。

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:

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:

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:

参考記事