Goでユニットテストの書き方② 実践編
ユニットテストで守るべきルール
ふつうのユニットテストのための7つのルール - ブログなんだよもん
ここに良くまとまっているのだが、ユニットテストは特定の条件に依存せず、何度やっても同じ結果になるよう作ることが望ましい。
その上でよく問題になることとして、
- ファイルの読み書きを伴うプログラムのテスト
- ログが標準出力に出てしまう
- 例外を出すことをテスト
- 設定ファイルの設定値に依存するテスト
- モックやスタブを使ったテスト
- テストデータの投入/破棄
自分の経験としては上記の対処に頭を悩ませてきた。
今回はそういう事柄の解決をGoのユニットテストでどうやったら良いか考えてみる。
ファイルを読み取るプログラムのテスト
ファイルを読み取る場合は文字列のファイルライクオブジェクトを使って対処。
Goの場合、strings.NewReaderを使えば良い。(バイナリファイルの場合bytes.NewReader)
[unittest/fileread.go]
package unittest import ( "encoding/csv" "io" ) type Entry map[string]string // ファイルを処理するプログラム func CSVToMaps(in io.Reader) (xs []Entry, err error) { var r *csv.Reader r = csv.NewReader(in) var names []string var entry Entry for { record, err := r.Read() if err == io.EOF { break } if err != nil { return nil, err } if names == nil { // 最初の行 names = record } else { entry = Entry{} for i := 0; i < len(names); i++ { entry[names[i]] = record[i] } xs = append(xs, entry) } } return }
[unittest/fileread_test.go]
package unittest import ( "reflect" "strings" "testing" ) // ファイルを入力に受け付けるプログラムのテスト: strings.NewReader/bytes.NewReaderで対処 func TestCSVToMaps(t *testing.T) { fp := strings.NewReader("" + "id,name,age\n" + "1,foo,18\n" + "2,bar,\n" + "3,,5") xs, err := CSVToMaps(fp) if err != nil { t.Errorf("ファイルの読み込みに失敗しました: %v", err) } expected := []Entry{ {"id": "1", "name": "foo", "age": "18"}, {"id": "2", "name": "bar", "age": ""}, {"id": "3", "name": "", "age": "5"}, } if !reflect.DeepEqual(xs, expected) { t.Errorf("結果が期待値と一致しません: %v != %v", xs, expected) } }
ファイルを書き出すプログラムのテスト
ファイルを書き出す場合はテンポラリファイルを使って対処。Goの場合、ioutil.TempFileを使えば良い。
テンポラリファイルは最後に消すので、deferを使ってos.Remove(tmp.Name())を仕掛けておく。
なお、この方法はローカルのファイルを書き出す場合に有効である。S3とか、外部の出力先に書き出す場合は別の方法を採る必要がある。
[unittest/filewrite.go]
package unittest import ( "encoding/csv" "os" ) // ローカルのファイルを出力するプログラム (S3とかに出力する場合はこの方法ではダメ) func WriteCSV(names []string, xs []Entry, filepath string) { out, _ := os.OpenFile(filepath, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644) defer out.Close() w := csv.NewWriter(out) defer w.Flush() w.Write(names) values := make([]string, len(names)) for _, x := range xs { for i := 0; i < len(names); i++ { values[i] = x[names[i]] } w.Write(values) } }
[unittest/filewrite_test.go]
package unittest import ( "io/ioutil" "os" "testing" ) // ファイルを出力するプログラムのテスト: ioutil.TempFileで対処 func TestWriteCSV(t *testing.T) { tmp, _ := ioutil.TempFile("", "_") defer func() { os.Remove(tmp.Name()) }() data := []Entry{ {"id": "1", "name": "foo", "age": "18"}, {"id": "2", "name": "bar", "age": ""}, {"id": "3", "name": "", "age": "5"}, } names := []string{"id", "name", "age"} WriteCSV(names, data, tmp.Name()) in, _ := os.Open(tmp.Name()) defer in.Close() bs, err := ioutil.ReadAll(in) if err != nil { t.Errorf("処理に失敗しました: %v", err) } result := string(bs) expected := ("" + "id,name,age\n" + "1,foo,18\n" + "2,bar,\n" + "3,,5\n") if result != expected { t.Errorf("結果が期待通りではありませんでした: %v != %v", result, expected) } }
ログを出力するプログラムのテスト
これには悩んだ。そもそもGoは敢えて貧弱な標準ロギングライブラリを設けているらしい。
ログを出力するプログラムのテストは事前にロギングを無効化することにした。
方法としては、ioutil.Discardが/dev/nullに向いた出力先として用意されているので、log.SetOutput(ioutil.Discard)した。
同じ方法は以下の記事でも紹介されていた。
[unittest/logging.go]
package unittest import ( "log" ) // ログを出力するプログラム func DoSomethingWithLog(x int) (ok bool) { if x%2 == 0 { log.Printf("偶数は受け付けません: %vが入力されました", x) return false } return true }
[unittest/logging_test.go]
package unittest import ( "io/ioutil" "log" "os" "testing" ) // ログを出力するプログラムのテスト: logモジュールで出力先を潰してしまう(!) func TestDoSomethingWithLog(t *testing.T) { setUp := func() { log.SetOutput(ioutil.Discard) // /dev/nullに出力 } tearDown := func() { log.SetOutput(os.Stdout) // 出力先を戻す } func() { setUp() defer tearDown() var ok bool ok = DoSomethingWithLog(3) if !ok { t.Errorf("結果が期待通りではありませんでした: %v != true", ok) } ok = DoSomethingWithLog(2) if ok { t.Errorf("結果が期待通りではありませんでした: %v != false", ok) } }() // DoSomethingWithLog(2) // 当然、無名関数の外で呼び出したら標準出力にログが出力される }
とりあえずここまで。他のテーマは随時更新で。