t.Cleanup()で並列に動かすことのできるテーブルドリブンテストを書く

22 min読了の目安(約13600字TECH技術記事

TL;DR

Goで並列に動くハンドラのテストを書くときに、起動したサーバーは t.Cleanup() を使うといい感じに書くことが出来る。普段通り defer を使うと変なところでハマるので注意しましょう。

Goで並列に動くテーブルドリブンテスト

最近は副業でGoを書いており、テストを書くときにはよくテーブルドリブンテストを書いています。
テストの数が多くなってくるとテストを動かすだけでも長い時間がかかるので、t.Parallel()を使い、(DB等の同時にアクセスすると不整合が起きたりする場合などを除いては)できるだけ並列にテストを動かしています。

テーブルドリブンテストを並列で行うということについては、テストを変更してエラーが起きた際にテストコード起因なのか、それとも並列に動かしていることによって起因したものなのかがわかりづらいという論点があると思いますが、そこは考えずに今回は進めていきます。

A, Bという2つのテスト用関数がある時、A, Bを並列にテストするというのはあると思いますが、この中でテーブルドリブンテストを動かす際には、テーブルの情報によって作られたサブテストも並列に動かしたくなると思います。

テストデータをfor文で回して1回ずつテストするのではなく、あらかじめテーブルの情報からサブテストを作成しておき、並列でサブテストを全て回すということです。


func TestFunctionA(t *testing.T){
    t.Parallel() // TestFunctionA を並列に動かす

    type testCase struct {
        name string
        title string
        expectStatusCode int
    }
    
    testCases := map[string]*testCase{
        "Yurine-sanはアクセス可能": &testCase {
            name: "Yurine",
            title: "DROPKICK ON MY DEVIL",
            expectStatusCode: http.StatusOK,
        },
        "Medusa-sanはアクセス可能": &testCase {
            name: "Medusa",
            title: "DROPKICK ON MY DEVIL",
            expectStatusCode: http.StatusOK,
        },
        "zyashin-changはアクセスできない": &testCase {
            name: "Zyashin",
            title: "DROPKICK ON MY DEVIL",
            expectStatusCode: http.StatusBadRequest,
        },
    }
    
    for testName, tc := range testCases {
        tc := tc
        t.Run(testName, func(t *testing.T){
            t.Parallel()
            t.Log(tc)
        })
    } 
}

func TestFunctionB(t *testing.T){
    t.Parallel() // TestFunctionB を並列に動かす

    type testCase struct {
        name string
        title string
        expectStatusCode int
    }
    
    testCases := map[string]*testCase{
        "Rin-sanはアクセス可能": &testCase {
            name: "Rin",
            title: "Yurucamp",
            expectStatusCode: http.StatusOK,
        },
        "Ena-sanはアクセス可能": &testCase {
            name: "Ena",
            title: "Yurucamp",
            expectStatusCode: http.StatusOK,
        },
        "Nadeshiko-changはアクセスできない": &testCase {
            name: "Nadeshiko",
            title: "Yurucamp",
            expectStatusCode: http.StatusBadRequest,
        },
    }
    
    for testName, tc := range testCases {
        tc := tc
        t.Run(testName, func(t *testing.T){
            t.Parallel()
            t.Log(tc)
        })
    }  
}

このコードについては、特に何も意識せずに動くはずです。

実際に動くコード

=== RUN   TestFunctionA
=== PAUSE TestFunctionA
=== RUN   TestFunctionB
=== PAUSE TestFunctionB
=== CONT  TestFunctionA
=== RUN   TestFunctionA/zyashin-changはアクセスできない
=== PAUSE TestFunctionA/zyashin-changはアクセスできない
=== RUN   TestFunctionA/Yurine-sanはアクセス可能
=== PAUSE TestFunctionA/Yurine-sanはアクセス可能
=== RUN   TestFunctionA/Medusa-sanはアクセス可能
=== PAUSE TestFunctionA/Medusa-sanはアクセス可能
=== CONT  TestFunctionB
=== RUN   TestFunctionB/Rin-sanはアクセス可能
=== PAUSE TestFunctionB/Rin-sanはアクセス可能
=== RUN   TestFunctionB/Ena-sanはアクセス可能
=== PAUSE TestFunctionB/Ena-sanはアクセス可能
=== RUN   TestFunctionB/Nadeshiko-changはアクセスできない
=== PAUSE TestFunctionB/Nadeshiko-changはアクセスできない
=== CONT  TestFunctionB/Rin-sanはアクセス可能
    TestFunctionB/Rin-sanはアクセス可能: prog.go:75: &{Rin Yurucamp 200}
=== CONT  TestFunctionB/Nadeshiko-changはアクセスできない
    TestFunctionB/Nadeshiko-changはアクセスできない: prog.go:75: &{Nadeshiko Yurucamp 400}
=== CONT  TestFunctionA/Medusa-sanはアクセス可能
    TestFunctionA/Medusa-sanはアクセス可能: prog.go:39: &{Medusa DROPKICK ON MY DEVIL 200}
=== CONT  TestFunctionA/Yurine-sanはアクセス可能
    TestFunctionA/Yurine-sanはアクセス可能: prog.go:39: &{Yurine DROPKICK ON MY DEVIL 200}
=== CONT  TestFunctionB/Ena-sanはアクセス可能
    TestFunctionB/Ena-sanはアクセス可能: prog.go:75: &{Ena Yurucamp 200}
--- PASS: TestFunctionB (0.00s)
    --- PASS: TestFunctionB/Rin-sanはアクセス可能 (0.00s)
    --- PASS: TestFunctionB/Nadeshiko-changはアクセスできない (0.00s)
    --- PASS: TestFunctionB/Ena-sanはアクセス可能 (0.00s)
=== CONT  TestFunctionA/zyashin-changはアクセスできない
    TestFunctionA/zyashin-changはアクセスできない: prog.go:39: &{zyashin DROPKICK ON MY DEVIL 400}
--- PASS: TestFunctionA (0.00s)
    --- PASS: TestFunctionA/Medusa-sanはアクセス可能 (0.00s)
    --- PASS: TestFunctionA/Yurine-sanはアクセス可能 (0.00s)
    --- PASS: TestFunctionA/zyashin-changはアクセスできない (0.00s)
PASS

HTTPハンドラのテスト

勘が良い方ならテストケース等から「これはHTTPハンドラ周りのテストだな?」ということがわかると思います。

テスト手順を書くとするなら、以下のような手順になりそうです。

  • テストをするハンドラを書く
  • テストの中でリクエストを待つようなサーバーを起動して待ち受ける
  • for文の中で起動しておいたサーバーに向かってリクエストを投げつける
  • responseに入っているステータスコードやBodyを使ってassertする

この通りに書いてみましょう。なお、今回の論点はサーバー周りの実装に対するものではないため、ある程度簡易的な実装とします。

テストをするハンドラを書く

実装については今回論点ではないため、ある程度簡素なものにします。

type Anime struct {
    Title string `json:"title"`
    Name string `json:"name"`
}

func getTestHandler() http.HandlerFunc {
    return func(w http.ResponseWriter, req *http.Request) {
        if req.Method != http.MethodPost {
            w.WriteHeader(http.StatusNotImplemented)
            return
        }

        b, err := ioutil.ReadAll(req.Body)
        if err != nil {
            w.WriteHeader(http.StatusInternalServerError)
            return
        }

        var anime Anime
        err = json.Unmarshal(b, &anime)
        if err != nil {
            w.WriteHeader(http.StatusInternalServerError)
            return
        }

        if anime.Title == "DROPKICK ON MY DEVIL" {
            if anime.Name == "Zyashin" {
                w.WriteHeader(http.StatusUnauthorized)
            } else {
                w.WriteHeader(http.StatusOK)
                w.Write([]byte(fmt.Sprintf("Hello %v San!", anime.Name)))
            }
        } else {
            w.WriteHeader(http.StatusBadRequest)
        }
        return
    }
}

getTestHandler() を呼べばハンドラが返ってきます。これをテストごとに動かせば良いはずです。

func TestFunctionA(t *testing.T) {
	t.Parallel() // TestFunctionA を並列に動かす

	type testCase struct {
		name             string
		title            string
		expectStatusCode int
	}

	testCases := map[string]*testCase{
		"Yurine-sanはアクセス可能": &testCase{
			name:             "Yurine",
			title:            "DROPKICK ON MY DEVIL",
			expectStatusCode: http.StatusOK,
		},
		"Medusa-sanはアクセス可能": &testCase{
			name:             "Medusa",
			title:            "DROPKICK ON MY DEVIL",
			expectStatusCode: http.StatusOK,
		},
		"zyashin-changはアクセスできない": &testCase{
			name:             "Zyashin",
			title:            "DROPKICK ON MY DEVIL",
			expectStatusCode: http.StatusUnauthorized,
		},
	}

	ts := httptest.NewServer(getTestHandler())
	defer ts.Close()

	for testName, tc := range testCases {
		tc := tc
		t.Run(testName, func(t *testing.T) {
			t.Parallel()

			var u bytes.Buffer
			u.WriteString(string(ts.URL))

			anime := &Anime{Title: tc.title, Name: tc.name}
			b, err := json.Marshal(anime)
			if err != nil {
				t.Fatalf("request json create failed: %v", err)
			}

			client := http.DefaultClient
			defer client.CloseIdleConnections()

			req, err := http.NewRequest(http.MethodPost, u.String(), bytes.NewBuffer(b))
			if err != nil {
				t.Fatalf("error occured at create new request : %v", err)
			}

			res, err := client.Do(req)
			if err != nil {
				t.Fatalf("test api request error: %v", err)
			}

			defer res.Body.Close()

			body, err := ioutil.ReadAll(res.Body)
			if err != nil {
				t.Fatalf("response read failed: %v", err)
			}

			if tc.expectStatusCode != res.StatusCode {
				t.Fatalf("unexpected response status code: want %v, but got %v, response body: %v", tc.expectStatusCode, res.StatusCode, string(body))
			}

			var resdata Anime
			err = json.NewDecoder(res.Body).Decode(&resdata)
			if reflect.DeepEqual(anime, &resdata) {
				t.Fatalf("unexpected server response want: %v but got %v, response body: %v", anime, &resdata, string(body))
			}
		})
	}
}

https://play.golang.org/p/ZWGY5JsTjvL

これで動きそうですが、実は上手く動きません。

=== RUN   TestFunctionA
=== PAUSE TestFunctionA
=== CONT  TestFunctionA
=== RUN   TestFunctionA/Medusa-sanはアクセス可能
=== PAUSE TestFunctionA/Medusa-sanはアクセス可能
=== RUN   TestFunctionA/zyashin-changはアクセスできない
=== PAUSE TestFunctionA/zyashin-changはアクセスできない
=== RUN   TestFunctionA/Yurine-sanはアクセス可能
=== PAUSE TestFunctionA/Yurine-sanはアクセス可能
=== CONT  TestFunctionA/Medusa-sanはアクセス可能
=== CONT  TestFunctionA/Yurine-sanはアクセス可能
=== CONT  TestFunctionA/zyashin-changはアクセスできない
    prog.go:108: test api request error: Post "http://127.0.0.1:30426": dial tcp 127.0.0.1:30426: connect: connection refused
=== CONT  TestFunctionA/Yurine-sanはアクセス可能
    prog.go:108: test api request error: Post "http://127.0.0.1:30426": dial tcp 127.0.0.1:30426: connect: connection refused
=== CONT  TestFunctionA/Medusa-sanはアクセス可能
    prog.go:108: test api request error: Post "http://127.0.0.1:30426": dial tcp 127.0.0.1:30426: connect: connection refused
--- FAIL: TestFunctionA (0.00s)
    --- FAIL: TestFunctionA/zyashin-changはアクセスできない (0.00s)
    --- FAIL: TestFunctionA/Yurine-sanはアクセス可能 (0.00s)
    --- FAIL: TestFunctionA/Medusa-sanはアクセス可能 (0.00s)
FAIL

全部 connection refused エラーになります。リクエストが上手く出来ていないようです。

並列に動かそうとしたテストでリクエストが上手くいかない原因を調査する

一度並列に動かすのをやめてみましょう。

テーブルからサブテストを生成する

	for testName, tc := range testCases {
		//tc := tc
		t.Run(testName, func(t *testing.T) {
			//t.Parallel()

この部分をコメントアウトします。

=== RUN   TestFunctionA
=== PAUSE TestFunctionA
=== CONT  TestFunctionA
=== RUN   TestFunctionA/Yurine-sanはアクセス可能
=== RUN   TestFunctionA/Medusa-sanはアクセス可能
=== RUN   TestFunctionA/zyashin-changはアクセスできない
--- PASS: TestFunctionA (0.00s)
    --- PASS: TestFunctionA/Yurine-sanはアクセス可能 (0.00s)
    --- PASS: TestFunctionA/Medusa-sanはアクセス可能 (0.00s)
    --- PASS: TestFunctionA/zyashin-changはアクセスできない (0.00s)
PASS
ok  	command-line-arguments	0.004s

並列にテストをしなくなるといい感じに動いているみたいです。

並列にしたいけれど、並列にすると何故か connection refused になるという状況です。

connection refusedの原因として、一番に考えられるのはサーバーが立ち上がっていないという状況です。
どうやら

	ts := httptest.NewServer(getTestHandler())
	defer ts.Close()

ここで起動したサーバーが死んでいるようで、deferあたりが怪しいですね。

どうもサブテストが回る前にdeferが実行され、サーバーが落ちているという状況みたいです。

というわけで簡単なテストのためのテストを書きました。

package main

import (
	"log"
	"testing"
)

func TestDefer(t *testing.T) {
	tcs := map[string]string{
		"a": "あ",
		"b": "い",
		"c": "う",
	}

	defer log.Printf("えお")

	for tn, tc := range tcs {
		tc := tc
		t.Run(tn, func(t *testing.T) {
			t.Parallel()
			log.Println(tc)
		})
	}
}

https://play.golang.org/p/VY0h5gl86zX

ここで問題、上のようなparallelな環境下で、deferはどのタイミングで呼ばれるでしょうか?

Goのdeferは呼び出し元の関数がreturnするまで実行されません。普通に考えれば出力としては

あ
い
う
えお

となりそうです。これは defer が最後の最後に実行されるということからも自然であると言えます。

しかし、実際の出力はこうでした

=== RUN   TestDefer
=== RUN   TestDefer/a
=== PAUSE TestDefer/a
=== RUN   TestDefer/b
=== PAUSE TestDefer/b
=== RUN   TestDefer/c
=== PAUSE TestDefer/c
2009/11/10 23:00:00 えお
=== CONT  TestDefer/a
2009/11/10 23:00:00 あ
=== CONT  TestDefer/b
2009/11/10 23:00:00 い
=== CONT  TestDefer/c
2009/11/10 23:00:00 う
--- PASS: TestDefer (0.00s)
    --- PASS: TestDefer/a (0.00s)
    --- PASS: TestDefer/b (0.00s)
    --- PASS: TestDefer/c (0.00s)

??????????????????

どうもdeferがサブテストに入る前に実行されているらしい。調べていると、同じようなことが issueに上がっているのが確認できました。

サブテストが実行される前にdeferが呼ばれてしまう

https://github.com/golang/go/issues/17791

作成者は同様なテストを書いていて、deferが先に呼ばれている問題に遭遇していた模様。
その中でなされていた会話の一部で

t.Parallel is making the t.Run itself a parallel subtest of TestBug. There is no parallel subtest of the t.Run subtest. TestBug will not complete until the parallel test completes. But TestBug's body has to finish because that is what triggers running the parallel tests in the first place.

とある。サブテストが起動する前にdeferが呼ばれるのはどうも仕様らしい。

deferも上手く使えないみたいだし、どうしようと思っていたら大学の先輩が書いた記事がヒットした。どうやら t.Cleanup() を使うことで解消することができるようです。

https://syfm.hatenablog.com/entry/2020/05/17/161842

https://godoc.org/testing#T.Cleanup

Cleanup registers a function to be called when the test and all its subtests complete. Cleanup functions will be called in last added, first called order.

t.Cleanup() はサブテストが終わってからLIFOで呼び出されるらしい。この周りはdeferと同じ挙動をしているようです。

書き換えてみました。

https://play.golang.org/p/e_K7M5VqJJI

package main

import (
	"log"
	"testing"
)

func TestDefer(t *testing.T) {
	tcs := map[string]string{
		"a": "あ",
		"b": "い",
		"c": "う",
	}

	t.Cleanup(func() {
		log.Printf("えお")
	})

	for tn, tc := range tcs {
		tc := tc
		t.Run(tn, func(t *testing.T) {
			t.Parallel()
			log.Println(tc)
		})
	}
}

=== RUN   TestDefer
=== RUN   TestDefer/a
=== PAUSE TestDefer/a
=== RUN   TestDefer/b
=== PAUSE TestDefer/b
=== RUN   TestDefer/c
=== PAUSE TestDefer/c
=== CONT  TestDefer/a
=== CONT  TestDefer/b
=== CONT  TestDefer/c
2009/11/10 23:00:00 あ
2009/11/10 23:00:00 い
2009/11/10 23:00:00 う
2009/11/10 23:00:00 えお
--- PASS: TestDefer (0.00s)
    --- PASS: TestDefer/a (0.00s)
    --- PASS: TestDefer/b (0.00s)
    --- PASS: TestDefer/c (0.00s)
PASS

確かにdeferのときとは違い、サブテストが終了してから えお が出力されており、期待していた挙動をしています。

これを元に、上のe2eテストっぽいものを書き換えてみましょう。

https://play.golang.org/p/-buH40moEg4

=== RUN   TestFunctionA
=== PAUSE TestFunctionA
=== CONT  TestFunctionA
=== RUN   TestFunctionA/Yurine-sanはアクセス可能
=== PAUSE TestFunctionA/Yurine-sanはアクセス可能
=== RUN   TestFunctionA/Medusa-sanはアクセス可能
=== PAUSE TestFunctionA/Medusa-sanはアクセス可能
=== RUN   TestFunctionA/zyashin-changはアクセスできない
=== PAUSE TestFunctionA/zyashin-changはアクセスできない
=== CONT  TestFunctionA/Yurine-sanはアクセス可能
=== CONT  TestFunctionA/zyashin-changはアクセスできない
=== CONT  TestFunctionA/Medusa-sanはアクセス可能
--- PASS: TestFunctionA (0.00s)
    --- PASS: TestFunctionA/Yurine-sanはアクセス可能 (0.00s)
    --- PASS: TestFunctionA/Medusa-sanはアクセス可能 (0.00s)
    --- PASS: TestFunctionA/zyashin-changはアクセスできない (0.00s)
PASS

t.Cleanup()は1.14から入った機能で、割りと新しめのものとなっているがかゆいところに手が届く感じでとてもいいと思いました。