🏗️

Go 標準ライブラリを参考に可読性の高いテストコードを書く。

2024/12/12に公開

GDG Greater Kwansai Padawan Organizer のたくてぃん (@taku_ting) です。
GDG Kwansai の Advent Calendar 2024 で先週に引き続き私から Go 関連のお話。

今日はテストコードについて。
Go でのテストコードの書き方に悩んだ際に標準ライブラリを読み、真似て書いてみた所感をお伝えします。

後半は読むのが大変になってかとも思いますが 🙇 、
テストコードの書き方やメンテナンスに悩まれている方の参考になれば嬉しいです。

「Go に入っては Go に従え」[1]

この言葉にインスピレーションを受け、標準ライブラリのテストコードを参考にしてみます。

テストケースの羅列

https://github.com/golang/go/blob/go1.23.4/src/net/url/url_test.go#L20-L68

テストの実行 (url.Parse())

https://github.com/golang/go/blob/go1.23.4/src/net/url/url_test.go#L657-L668

テストの実行 (url.Parse() 及び url.URL{}.String())

https://github.com/golang/go/blob/go1.23.4/src/net/url/url_test.go#L769-L784

このコードから私が学んだこと

テストを一度にやらない

  • url.Parse() のテスト
  • url.URL{}.String() のテスト

2つのテスト項目を一度にカバーせず、2つのテスト関数に分けて行っている。

テストケースの羅列で動作を説明する

https://github.com/golang/go/blob/go1.23.4/src/net/url/url_test.go#L20-L24
https://github.com/golang/go/blob/go1.23.4/src/net/url/url_test.go#L46-L56

この数行を読むだけで、

  • "http://www.google.com/file%20one%26two" をパースするとどうなるか
  • パースで得た url.URL{}url.URL{}.String() の戻り値がどうなるか

が完璧にとは言わずとも、処理の概要や入出力を簡単に理解できます。

DI (interface を使用した単体テスト)

※ Dependency Injection (依存性注入)

標準ライブラリのコードリーディングとは少し外れますが、実際のテストコードの解説の前に Go での DI について簡単に説明しておきます。

簡単な例: 標準出力をテスト

変数を書き換えてテスト (イマイチな例)

main.go
func main() {
    fmt.Println("Hello world!")
}
main_test.go
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 してテスト

main.go
func main() {
    PrintHello(os.Stdout) // os.Stdout を出力先に指定
}
hello.go
func PrintHello(out io.Writer) {
	fmt.Fprintln(out, "Hello world!")
}

io.Writer (書き込み処理 (Write 関数) の実装を要求する interface)
https://github.com/golang/go/blob/go1.23.4/src/io/io.go#L99-L101

hello_test.go
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!")
}

さて、ではもう少し現実的なコードを見ていきましょう。

ここからは私が実際に書いたコードで解説していきます。

実際のプロジェクトで書いたテストコード

https://github.com/tingtt/urlshortener

単純な URL 短縮サービスです。

GET リクエストを処理する関数のテストを解説します。
server/handler_get.go - handler{}.HandleGet()

まずはテストしたいそもそものコード。

https://github.com/tingtt/urlshortener/blob/e744785/server/handler_get.go#L14-L100

具体的には

  • GET リクエストされた URL にリダイレクト先が登録されていたらリダイレクト
  • GET リクエストされた URL にリダイレクト先が登録されており、URL に ?edit が指定されていたら編集ページを開く
  • GET リクエストされた URL にリダイレクト先が登録されていなければ登録ページを開く

などが処理として存在します。

依存対象を DI している箇所

handler{} (HTTP 関連の処理) は

  • usecase.Handler (ビジネスルールの処理郡)
  • uiprovider.Provider (UI構築の処理郡)

に依存しています。

https://github.com/tingtt/urlshortener/blob/e744785/server/handler.go#L12-L19
https://github.com/tingtt/urlshortener/blob/e744785/server/serve.go#L14-L17

テストコード

ケース1: リクエストされた URL にリダイレクト先が登録されていたらリダイレクト

https://github.com/tingtt/urlshortener/blob/e744785/server/handler_get_test.go#L68-L75

ケース2: リクエストされた URL にリダイレクト先が登録されていなければ登録ページを開く

https://github.com/tingtt/urlshortener/blob/e744785/server/handler_get_test.go#L76-L127

ケース3: GET リクエストされた URL にリダイレクト先が登録されており、URL に ?edit が指定されていたら編集ページを開く

https://github.com/tingtt/urlshortener/blob/e744785/server/handler_get_test.go#L170-L221

テスト実行部

テストコード全体
https://github.com/tingtt/urlshortener/blob/e744785/server/handler_get_test.go#L276-L352

流れの要約

  1. req := httptest.NewRequest("GET", tt.in.reqURL, &bytes.Buffer{})
    • テスト用の疑似リクエストを作成
    • httptest パッケージはテスト用の標準パッケージです。
  2. usecase := new(MockUsecase) と以降の数行
    • usecase パッケージ usecase.Handler を DI 用にモック
    • モックの振る舞いをテスト用に仮定
      • テストケースの usecaseBehavior で指定された振る舞いをさせる
  3. ui := new(MockUI) と以降の十数行
    • uiprovider パッケージ uiprovider.Provider を DI 用にモック
    • モックの振る舞いをテスト用に仮定
    • モックの実行記録の検証処理を事前定義 (defer は関数の return 時に実行される)
      • 311 - 341 行目
      • テストケースの uiExpectCall で指定された実行回数、呼び出し時の引数をテスト
  4. h := handler{Dependencies{usecase, ui}}
    • モックした依存先 struct を DI
  5. h.HandleGet(rw, req)
    • テスト対象の関数を実行
  6. assert.Equal(t, tt.out.status, rw.Code)
    • HTTP ステータスコードをテスト
  7. assert.Equal(t, tt.out.location, rw.Header().Get("Location"))
    • リダイレクトの場合、リダイレクト先 URL のテスト
  8. (3) で事前定義 (defer) した UI 構築処理の呼び出しをテストが実行される

まとめ

  • テストの適度な粒度
  • テストの可読性
    • 掻い摘んで読めるテーブルテストを
  • 適切に DI し責務を分割

あとがき

ここまで読んで頂いてありがとうございます。 (大感謝!!)
お疲れ様でした。🎉

後半紹介した実際のテストコードも、まだまだ改善点があるかと思いますが参考にしていただければ嬉しいです!

また、こういうトピックですので質問・提案・指摘などありましたら気軽にコメントして頂けると嬉しいです〜

語りきれなかった部分

テスト例: graceful シャットダウンのテスト

serve_test.go - Test_gracefulServe

  • TCPポートの Listen とサーバの起動はモックして起動不要にし、並列テスト対応
  • シャットダウンシグナルの受け取りも context.Context を介しているため擬似的に再現してテスト
  • シャットダウン時の永続データの書き込み処理もモックして呼び出しのみテスト

テスト例: ファイル書き込みでのデータ永続化処理

registry_test.go

  • 永続データ用の csv ファイルの扱い
  • ファイルの読み書きを io.ReadWriter で DI すればテスト用の仮ファイルを扱わなくてよく出来るがやるか迷ってやっていない。 (一度やってみて冗長にならないか見て判断したいな〜)

src


Go 標準ライブラリ net/url を参考にテストを書いたプロジェクト

https://github.com/tingtt/urlshortener

脚注
  1. 鵜飼文敏氏「Goに入ってはGoに従え」可読性のあるコードにするために - Go Conference 2014 Autumnレポート ↩︎

Discussion