Go 標準ライブラリを参考に可読性の高いテストコードを書く。
GDG Greater Kwansai Padawan Organizer のたくてぃん (@taku_ting) です。
GDG Kwansai の Advent Calendar 2024 で先週に引き続き私から Go 関連のお話。
今日はテストコードについて。
Go でのテストコードの書き方に悩んだ際に標準ライブラリを読み、真似て書いてみた所感をお伝えします。
後半は読むのが大変になってかとも思いますが 🙇 、
テストコードの書き方やメンテナンスに悩まれている方の参考になれば嬉しいです。
[1]
「Go に入っては Go に従え」この言葉にインスピレーションを受け、標準ライブラリのテストコードを参考にしてみます。
テストケースの羅列
url.Parse()
)
テストの実行 (
url.Parse()
及び url.URL{}.String()
)
テストの実行 (
このコードから私が学んだこと
テストを一度にやらない
-
url.Parse()
のテスト -
url.URL{}.String()
のテスト
2つのテスト項目を一度にカバーせず、2つのテスト関数に分けて行っている。
テストケースの羅列で動作を説明する
この数行を読むだけで、
-
"http://www.google.com/file%20one%26two"
をパースするとどうなるか - パースで得た
url.URL{}
のurl.URL{}.String()
の戻り値がどうなるか
が完璧にとは言わずとも、処理の概要や入出力を簡単に理解できます。
DI (interface を使用した単体テスト)
※ Dependency Injection (依存性注入)
標準ライブラリのコードリーディングとは少し外れますが、実際のテストコードの解説の前に Go での DI について簡単に説明しておきます。
簡単な例: 標準出力をテスト
変数を書き換えてテスト (イマイチな例)
func main() {
fmt.Println("Hello world!")
}
func Test_main(t *testing.T) {
t.Run("print hello", func(t *testing.T) {
want := "Hello world!\n"
originalStdout := os.Stdout
defer func() { // テスト終了時に書き換えた os パッケージの変数をもとに戻す
os.Stdout = originalStdout
}()
r, w, _ := os.Pipe()
os.Stdout = w // os パッケージの変数を書き換え、出力先を切り替える
main()
w.Close()
buf := &bytes.Buffer{}
buf.ReadFrom(r) // 書き換えた出力先から結果を持ってくる
got := buf.String()
if got != want {
t.Errorf("main() = %v, want %v", got, want)
}
})
}
このコード。
テストすることは出来ているのですが、os.Stdout
を書き換えるなどテストのための無理な実装が多く感じます。
改善してみると。 ↓
「書き込み機構」 を DI してテスト
func main() {
PrintHello(os.Stdout) // os.Stdout を出力先に指定
}
func PrintHello(out io.Writer) {
fmt.Fprintln(out, "Hello world!")
}
io.Writer
(書き込み処理 (Write
関数) の実装を要求する interface)
func TestPrintHello(t *testing.T) {
want := "Hello world!\n"
out := &bytes.Buffer{}
PrintHello(out) // *bytes.Buffer は io.Writer を実装しているため引数に与えられる
got := out.String()
if got != want {
t.Errorf("RunVersion() = %v, want %v", got, want)
}
}
io.Writer
を引数に追加することで、標準出力には実際に出力せずにメモリ上に用意したバッファに出力先を切り替えた書き込みで擬似的に出力をテストします。
こちらは比較的スマートな実装に見えると思います。
struct 経由で DI したパターン
func main() {
greeter := NewGreeter(os.Stdout) // os.Stdout を出力先に指定
greeter.PrintHello()
}
type Greeter struct {
out io.Writer
}
func NewGreeter(out io.Writer) Greeter {
return Greeter{out}
}
func (g *Greeter) PrintHello() {
fmt.Fprintln(g.out, "Hello world!")
}
さて、ではもう少し現実的なコードを見ていきましょう。
ここからは私が実際に書いたコードで解説していきます。
実際のプロジェクトで書いたテストコード
単純な URL 短縮サービスです。
GET リクエストを処理する関数のテストを解説します。
server/handler_get.go - handler{}.HandleGet()
まずはテストしたいそもそものコード。
具体的には
- GET リクエストされた URL にリダイレクト先が登録されていたらリダイレクト
- GET リクエストされた URL にリダイレクト先が登録されており、URL に
?edit
が指定されていたら編集ページを開く - GET リクエストされた URL にリダイレクト先が登録されていなければ登録ページを開く
などが処理として存在します。
依存対象を DI している箇所
handler{}
(HTTP 関連の処理) は
-
usecase.Handler
(ビジネスルールの処理郡) -
uiprovider.Provider
(UI構築の処理郡)
に依存しています。
テストコード
ケース1: リクエストされた URL にリダイレクト先が登録されていたらリダイレクト
ケース2: リクエストされた URL にリダイレクト先が登録されていなければ登録ページを開く
?edit
が指定されていたら編集ページを開く
ケース3: GET リクエストされた URL にリダイレクト先が登録されており、URL に
テスト実行部
テストコード全体
流れの要約
-
req := httptest.NewRequest("GET", tt.in.reqURL, &bytes.Buffer{})
- テスト用の疑似リクエストを作成
- ※
httptest
パッケージはテスト用の標準パッケージです。
-
usecase := new(MockUsecase)
と以降の数行-
usecase
パッケージusecase.Handler
を DI 用にモック - モックの振る舞いをテスト用に仮定
- テストケースの
usecaseBehavior
で指定された振る舞いをさせる
- テストケースの
-
-
ui := new(MockUI)
と以降の十数行-
uiprovider
パッケージuiprovider.Provider
を DI 用にモック - モックの振る舞いをテスト用に仮定
- モックの実行記録の検証処理を事前定義 (defer は関数の return 時に実行される)
- 311 - 341 行目
- テストケースの
uiExpectCall
で指定された実行回数、呼び出し時の引数をテスト
-
-
h := handler{Dependencies{usecase, ui}}
- モックした依存先 struct を DI
-
h.HandleGet(rw, req)
- テスト対象の関数を実行
-
assert.Equal(t, tt.out.status, rw.Code)
- HTTP ステータスコードをテスト
-
assert.Equal(t, tt.out.location, rw.Header().Get("Location"))
- リダイレクトの場合、リダイレクト先 URL のテスト
- (3) で事前定義 (defer) した UI 構築処理の呼び出しをテストが実行される
まとめ
- テストの適度な粒度
- テストの可読性
- 掻い摘んで読めるテーブルテストを
- 適切に DI し責務を分割
あとがき
ここまで読んで頂いてありがとうございます。 (大感謝!!)
お疲れ様でした。🎉
後半紹介した実際のテストコードも、まだまだ改善点があるかと思いますが参考にしていただければ嬉しいです!
また、こういうトピックですので質問・提案・指摘などありましたら気軽にコメントして頂けると嬉しいです〜
語りきれなかった部分
テスト例: graceful シャットダウンのテスト
serve_test.go - Test_gracefulServe
- TCPポートの Listen とサーバの起動はモックして起動不要にし、並列テスト対応
- シャットダウンシグナルの受け取りも
context.Context
を介しているため擬似的に再現してテスト - シャットダウン時の永続データの書き込み処理もモックして呼び出しのみテスト
テスト例: ファイル書き込みでのデータ永続化処理
- 永続データ用の csv ファイルの扱い
- ファイルの読み書きを
io.ReadWriter
で DI すればテスト用の仮ファイルを扱わなくてよく出来るがやるか迷ってやっていない。 (一度やってみて冗長にならないか見て判断したいな〜)
src
Go 標準ライブラリ net/url
を参考にテストを書いたプロジェクト
Discussion