ioのテストをうまくやりたい by ことね@_ktnyt at Go言語で語りたい! #1
自己紹介 ことね(板谷美玲)@_ktnyt LAPRAS株式会社 Webエンジニア io.Readerをすこれの人 趣味 プログラミング、ドライブ、音楽
io.Reader/io.Writer、好きだけどテストは面倒。
Morph: JSONを構造体リテラルに変換 { } "null": null, "bool": true, "number": 3.1415, "string": "foo", "array": [ null, true, 3.1415, "foo" ] map[string]any{ "null": nil, "bool": true, "number": 3.1415, "string": "foo", "array": []any{ nil, true, 3.1415, "foo", }, }
Writerに書き込んで文字列結合
func WriteJoined(w io.Writer, ss []string, sep string) (int, error) {
if len(ss) == 0 {
return 0, nil
}
head, tail := ss[0], ss[1:]
n, err := io.WriteString(w, head)
if err != nil {
return n, err
}
if len(tail) > 0 {
m, err := io.WriteString(w, sep)
n += m
if err != nil {
return n, err
}
}
}
m, err := WriteJoined(w, tail, sep)
return n + m, err
正常系のテスト
func TestJoinWriter(t *testing.T) {
buf := &bytes.Buffer{}
in := []string{"foo", "bar", "baz"}
sep := ","
exp := strings.Join(in, sep)
n, err := WriteJoined(buf, in, sep)
if n != len(exp) || err != nil {
t.Errorf(
"WriteJoined(...) = %d, %v: wanted %d, nil",
n, err, len(exp),
)
}
}
got := buf.String()
if got != exp {
t.Errorf(
"unexpected bytes written (-exp, +got): %v",
diff.LineDiff(exp, got).Join(),
)
}
正常系以外は?
func WriteJoined(w io.Writer, ss []string, sep string) (int, error) {
if len(ss) == 0 {
return 0, nil
}
head, tail := ss[0], ss[1:]
n, err := io.WriteString(w, head)
if err != nil {
return n, err //
}
どうやってテストする?
if len(tail) > 0 {
m, err := io.WriteString(w, sep)
n += m
if err != nil {
return n, err //
}
}
どうやってテストする?
}
m, err := WriteJoined(w, tail, sep)
return n + m, err
とりあえず専用のWriterを作る var errOnWriteCall = errors.New("write call error") type ErrOnCallWriter struct { w io.Writer call int count int } func NewErrOnWriteCall(w io.Writer, call int) *ErrOnCallWriter { return &ErrOnCallWriter{w, call, 0} } func (w *ErrOnCallWriter) Write(p []byte) (int, error) { w.count++ if w.count == w.call { return 0, errOnWriteCall } return w.w.Write(p) }
要素の書き込みでエラーを上げるテスト
func TestJoinWriterElementError(t *testing.T) {
buf := &bytes.Buffer{}
w := NewErrOnWriteCall(buf, 1)
in := []string{"foo", "bar", "baz"}
sep := ","
exp := ""
n, err := WriteJoined(w, in, sep)
if n != len(exp) || !errors.Is(err, errOnWriteCall) {
t.Errorf(
"WriteJoined(...) = %d, %v: wanted %d, %v",
n, err, len(exp), errOnWriteCall,
)
}
}
got := buf.String()
if got != exp {
t.Errorf(
"unexpected bytes written (-exp, +got): %v",
diff.LineDiff(exp, got).Join(),
)
}
区切り文字で書き込みでエラーを上げるテスト
func TestJoinWriterSepError(t *testing.T) {
buf := &bytes.Buffer{}
w := NewErrOnWriteCall(buf, 2)
in := []string{"foo", "bar", "baz"}
sep := ","
exp := "foo"
n, err := WriteJoined(w, in, sep)
if n != len(exp) || !errors.Is(err, errOnWriteCall) {
t.Errorf(
"WriteJoined(...) = %d, %v: wanted %d, %v",
n, err, len(exp), errOnWriteCall,
)
}
}
got := buf.String()
if got != exp {
t.Errorf(
"unexpected bytes written (-exp, +got): %v",
diff.LineDiff(exp, got).Join(),
)
}
構造が見えてきた ここまで来るとテーブルテストが書けそう。 同時に 「mバイト書き込んだらエラーも便利そう」 という欲が出てくる。
汎用化の思考 実現したいこと n回目の呼び出しでエラーを返す mバイト書き込んだらエラーを返す 最初にぱっと思いつくやり方 ErrOnCallAndBytesWriter(w io.Writer, n int, err1 error, m int, err2 error) 圧倒的コレジャナイ感 もっといいやり方があるはず。
Not 汎用 but 抽象 できないといけないこと メソッドを持つオブジェクトを簡単につくれる。 → io.Writer を関数型でモックする。 Write(p []byte) (int, error) // Writer represents a mocked Write call. type Writer func(p []byte) (int, error) // Write simulates a Write call by using the provided mock function. func (write Writer) Write(p []byte) (int, error) { return write(p) }
これでいける?
n回目の呼び出しでエラーを返す func CallCountWriter(write func(i int, p []byte) (int, error)) Writer { i := 0 return func(p []byte) (int, error) { i++ return write(i, p) } } func ErrOnCallWriter(count int, err error) Writer { return CallCountWriter(func(i int, p []byte) (int, error) { if count == i { return 0, err } return len(p), nil }) }
mバイト書き込んだらエラーを返す func ByteCountWriter(write func(i int, p []byte) (int, error)) Writer { i := 0 return func(p []byte) (int, error) { n, err := write(i, p) i += len(p) return n, err } } func ErrOnByteWriter(count int, err error) Writer { return ByteCountWriter(func(i int, p []byte) (int, error) { n := count - i switch { case len(p) < n: return len(p), nil case 0 < n: return n, err default: return 0, err } }) }
良さそうだけど、なんで? メソッド vs. 関数 メソッドはインライン定義できないけど関数は無名関数でインライン定義できる。 →この違いが使い勝手に大きく影響する。 例:テーブルテストに直書きできる。
やったことの本質 Write関数をio.Writerとして使えるようにした。
io.Readerの場合はもう一歩 io.Writerは書き込まなくても挙動的におかしくない(呼び出し元には基本的に影響がない*) がio.Readerは書き込みしないといけないという違いがある。 →io.ReaderをWrapする仕組みを作ってあげるひと手間が不可欠。 * Tips: Writerで書き込み結果がほしい場合はio.MultiWriterを使う。
n回目のReadでエラーを返す(一部抜粋) type ReadMocker struct { r io.Reader } func NewReadMocker(r io.Reader) ReadMocker { return ReadMocker{r} } func (mock ReadMocker) ErrOnCall(count int, err error) Reader { return CallCountReader(func(i int, p []byte) (int, error) { n, rerr := mock.r.Read(p) if i == count { return n, err } return n, rerr }) }
iomockでらくらくioモック 任意のタイミングでエラーを返すio.Reader/io.Writerが作れる。 コードの物理的カバレッジも論理的カバレッジも上げやすく。 コンビネータが如くつなげればかなり複雑なテストも書けるはず。
Happy io Life!