はじめに
メルペイ エキスパートチームのtenntennです。
メルカリグループでは、毎週金曜日にGo Fridayという社内勉強会を開催しています。
毎週やっているとそれなりに知見が溜まってくるので、定期的に”こぼれ話”としてブログを書こうという話になりました。
今回の記事では、先日のGo Fridayで話題にあがった非公開な機能を使ったテストについて扱いたいと思います。
なお、Goにおけるテストの手法やテストしやすいコードの書き方については、GopherCon 2017でも発表があったmitchellhさんの”Advanced Testing with Go”(スライド/動画)が参考になります。テーブル駆動テストやテストヘルパーなど非常に勉強になるので、まだ見たことのない方はぜひスライドや動画をご覧ください。
TL;DR
- Goのテストではテスト対象とテストコードを別パッケージにできる
- 別パッケージにすると非公開な機能(パッケージ内にしか公開されていないメソッドや変数など)にアクセスできない
- テスト対象と同じパッケージの
export_test.go
というファイル経由で非公開な機能をテストコードにだけ公開するパターンがある
テストコードのパッケージ
Goでは基本的には1つのディレクトリ内のコードは、1つパッケージで構成されている必要があります。
しかし、テストの場合は例外で、次のようにmypkg
パッケージのテストコードはmypkg_test
でも問題ありません。
package mypkg func Hoge() string { return "hoge" }
package mypkg_test import ( "testing" "mypkg" ) func TestHoge(t *testing.T) { if s := mypkg.Hoge(); s != "hoge" { t.Error("want hoge, got", s) } }
もちろん、テスト対象のコードとテストコードのパッケージは同じにすることは可能です。
それでもテスト対象とテストコードのパッケージを別にする利点はどこにあるのでしょうか。
考えられる利点としては、パッケージを分けることによって、テスト対象とテストコードが疎結合にすることができるという点が挙げられます。
疎結合にすることによって、テストコードはあくまでもテスト対象のパッケージのユーザという立場でテストを書くことができます。
そうすることで、テスト対象パッケージを利用する側の視点から見ることができ、「利用しやすいAPIにするには?」ということを考えながらコードを書くことができます。
このような理由から、私はテスト対象とテストコードのパッケージを分けることができるのであれば、積極的に分けるべきだと考えています。
非公開(unexported)な機能のテスト
テスト対象とテストコードのパッケージは分けたほうがよいという話をしました。
しかし、どうしてもパッケージを一緒にしたい場合があります。
非公開な変数の値を変えたり、非公開なメソッドを呼び出したりする場合です。
実はgo build
とgo test
では、ビルド対象のファイルが違います。
go build
では、_test.go
のサフィックスを持つテストファイルはビルド対象から外されます。
そのため、export_test.go
などの名前をつけ、パッケージ名をテスト対象と同じにすることでテストの時のみにアクセスできる変数や関数を作ることができます。
例えば、次のような構成のパッケージを考えます。
mypkg ├── mypkg.go ├── mypkg_test.go └── export_test.go
mypkg.go
には、次のようにmaxValue
という定数があった場合に、テストのときだけこの値を参照したいとします。
// mypkg.go package mypkg const maxValue = 100
export_test.go
で次のように定義することで、maxValue
はExportMaxValue
としてテストコードに公開されます。
// export_test.go package mypkg // テスト対象と同じパッケージ const ExportMaxValue = maxValue
テストコードでは次のように公開された定数を参照することができます。
// mypkg_test.go package mypkg_test // テスト対象とは別のパッケージ import ( "testing" "mypkg" ) func TestMypkg(t *testing.T) { // maxValueの代わりにExportMaxValueを参照する if doSomething() > mypkg.ExportMaxValue { t.Error("Error") } }
export_test.go
はgo test
の際にしかビルドされないため、通常のビルドにはExportMaxValue
は含まれず、テストコード以外からは参照できません。
このようにexport_test.go
で公開する箇所を限定することで、想定外の使い方をされることを防ぐことがきます。
このexport_test.go
を使ったパターンは、実際に標準パッケージであるnet/http
パッケージやreflect
パッケージで用いられています。
なお、export_test.go
というファイル名のexport
の部分には特に決まりはありませんが、標準に習ってexport_test.go
という名前にしておく方が分かりやすいでしょう。
それでは、このexport_test.go
を用いた非公開な機能を使ったテストの面白いパターンを見ていこうと思います。
非公開な変数の値を変更する
テストの際に変数の値を変えたくなることがあります。
例えば、次のようにサーバのURLが設定したあった場合を考えます。
// mypkg.go package mypkg var baseURL = "https://example.com/api/v2"
このURLをテストのときだけ変えたいとします。
export_test.go
に次のように書くことでSetBaseURL
関数を使えばbaseURL
を変更することができるようになります。
SetBaseURL
関数は、引数で受け取ったURLをbaseURL
に設定します。
そして、SetBaseURL
は戻り値としてbaseURL
を元に戻す関数を返します。
// export_test.go package mypkg func SetBaseURL(s string) (resetFunc func()) { var tmp string tmp, baseURL = baseURL, s return func() { baseURL = tmp } }
次のようにテストの前にdefer SetBaseURL("http://localhost:8080/")()
のように呼び出しておくことで、
テスト時にはbaseURL
を"http://localhost:8080"
に変えておき、テスト関数が終了する直前にbaseURL
を戻すということができます。
// mypkg_test.go package mypkg_test import ( "testing" "mypkg" ) func TestClient(t *testing.T) { // SetBaseURLで返ってきた関数をdeferで呼び出す defer mypkg.SetBaseURL("http://localshot:8080")() // 以下にテストコード }
非公開なメソッドの呼び出し
次のようなテスト対象コードがあった場合に、Counter
型のreset
メソッドをテストで呼び出したいという場合を考えます。
// mypkg.go package mypkg type Counter struct { n int } func (c *Counter) Count() { c.n++ } func (c *Counter) reset() { c.n = 0 }
メソッドは、次のようにメソッドバリューとして変数に入れることができます。
func main() { var c Counter var reset func() = c.reset reset() }
また、次のようにレシーバを束縛していなくても変数に入れることができます。
その際、変数の型はfunc (c *Counter)
のように、レシーバが第1引数になります。
なお、引数があるメソッドの場合は、レシーバが第1引数に、第1引数が第2引数に、のように1つずつずれる形になります。
func main() { var reset func(c *Counter) = (*Counter).reset reset(&c) // レシーバが束縛されてないので第1引数で指定する }
さて、これを踏まえて次のようにexport_test.go
に書くことでメソッドを公開することができます。
// export_test.go package mypkg var ExportCounterReset = (*Counter).reset
第1引数に*Counter
型の値を指定することで呼び出すことができます。
非公開なフィールドにアクセスする
テストで非公開なフィールドにアクセス場合はどうすれば良いでしょうか?
export_test.go
はテスト対象のパッケージと同じパッケージであるため、次のようにメソッドを追加することができます。
// export_test.go package mypkg func (c *Counter) ExportN() int { return c.n }
フィールドの値を変えたい場合は、次のようにセッターを作ればよいでしょう。
// export_test.go package mypkg func (c *Counter) ExportSetN(n int) { c.n = n }
非公開な型を使用する
テスト対象のパッケージに次のような型が定義してあり、この型をテストで使用したいとします。
// mypkg.go
package mypkg
type response struct {
Vaue string json:"value"
}
Go1.9から入った型エイリアスの機能を使うと型に別名をつけることが可能です。
次のように、エイリアス名を大文字から始めることでテストコードへ公開することが可能です。
// export_test.go package mypkg type ExportResponse = response
こうすることでresponse
型とExportResponse
型は完全に同じ型として扱われるため、キャストする必要なく引数や変数に入れることができます。
もちろん、フィールドやメソッドについても、元の型と同じように利用できます。
例: クライアントライブラリのテスト
ここまで非公開な機能を使ったテストの手法を説明してきました。
最後に、具体的な例を紹介したいと思います。
例えば、次のようなクライアントライブラリを作成しているとします。
Client
型がありそのGet
メソッドを呼ぶとサーバにリクエストが飛びます。
リクエストパラメータとしてn
を渡していて、レスポンスはJSONで受け取っています。
なお、長くなるので細かなエラーハンドリングはサボっていますが、本来ならステータスのチェックなどをするべきでしょう。
// client.go
package mypkg
import (
"encoding/json"
"net/http"
"net/url"
"strconv"
)
var baseURL = "https://example.com/api/v2"
type Client struct {
// fields
HTTPClient *http.Client
}
func (cli *Client) httpClient() *http.Client {
if cli.HTTPClient != nil {
return cli.HTTPClient
}
return http.DefaultClient
}
type getResponse struct {
Value string json:"value"
}
func (cli *Client) Get(n int) (string, error) {
v := url.Values{}
v.Set("n", strconv.Itoa(n))
requestURL := baseURL + "/get?" + v.Encode()
resp, err := cli.httpClient().Get(requestURL)
if err != nil {
return "", err
}
defer resp.Body.Close()
var gr getResponse
dec := json.NewDecoder(resp.Body)
if err := dec.Decode(&gr); err != nil {
return "", err
}
return gr.Value, nil
}
さて、このClient
型のテストを作っていきましょう。
次のようにnet/http/httptest
パッケージを使ってモックサーバを立てて、そこに期待したリクエストが来るかどうかチェックしてみたいと思います。
// client_test.go
package mypkg_test
import (
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"mypkg"
)
func TestGet(t *testing.T) {
cases := map[string]struct {
n int
hasError bool
}{
"100": {n: 100},
"200": {n: 200},
}
for n, tc := range cases {
tc := tc
t.Run(n, func(t *testing.T) {
var requested bool
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requested = true
if r.FormValue("n") != strconv.Itoa(tc.n) {
t.Errorf("param n want %s got %d", r.FormValue("n"), tc.n)
}
fmt.Fprint(w, {"value":"hoge"}
)
}))
defer s.Close()
cli := mypkg.Client{HTTPClient: s.Client()}
_, err := cli.Get(tc.n)
switch {
case err != nil && !tc.hasError:
t.Error("unexpected error:", err)
case err == nil && tc.hasError:
t.Error("expected error has not occurred")
}
if !requested {
t.Error("no request")
}
})
}
}
テストを実行してみましょう。
実行するとテストが落ちてしまいました。
どうやらhttps://example.com/api/v2/get
にアクセスしようとして失敗したようです。
このURLでアクセスしてしまうとhttptest.NewServer
で作ったモックサーバにアクセスすることができません。
$ go test -v mypkg === RUN TestGet === RUN TestGet/100 === RUN TestGet/200 --- FAIL: TestGet (0.01s) --- FAIL: TestGet/100 (0.00s) client_test.go:40: unexpected error: Get https://example.com/api/v2/get?n=100: dial tcp: lookup example.com: no such host client_test.go:46: no request --- FAIL: TestGet/200 (0.00s) client_test.go:40: unexpected error: Get https://example.com/api/v2/get?n=200: dial tcp: lookup example.com: no such host client_test.go:46: no request FAIL FAIL mypkg 0.031s
httptest.Server
にはURL
というフィールドがあるため、そちらをbaseURL
に設定すれば良さそうです。
baseURL
への設定は、”非公開な変数の値を変更する”で紹介したように、export_test.go
に関数を用意します。
// export_test.go package mypkg func SetBaseURL(s string) (resetFunc func()) { var tmp string tmp, baseURL = baseURL, s return func() { baseURL = tmp } }
SetBaseURL
関数は、テストコードからしかアクセスできない関数で、引数で渡した値をbaseURL
に設定します。
戻り値は、設定した値を元に戻す関数で、これを呼び出すことでbaseURL
を元の値に戻すことができます。
ここで注意点としては、SetBaseURL
は複数のゴルーチンから呼ばれることは期待していないため、テストを並行に実行する場合には注意が必要です。
SetBaseURL
関数は次のように使います。
defer mypkg.SetBaseURL(s.URL)()
のようにdefer
で呼び出すことで、サブテスト関数が終了した際にbaseURL
の値を戻し、次のサブテストに移ります。
// client_test.go
package mypkg_test
import (
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"mypkg"
)
func TestGet(t *testing.T) {
cases := map[string]struct {
n int
hasError bool
}{
"100": {n: 100},
"200": {n: 200},
}
for n, tc := range cases {
tc := tc
t.Run(n, func(t *testing.T) {
var requested bool
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requested = true
if r.FormValue("n") != strconv.Itoa(tc.n) {
t.Errorf("param n want %s got %d", r.FormValue("n"), tc.n)
}
fmt.Fprint(w, {"value":"hoge"}
)
}))
defer s.Close()
defer mypkg.SetBaseURL(s.URL)() // baseURLをモックサーバのものに入れ替え
cli := mypkg.Client{HTTPClient: s.Client()}
_, err := cli.Get(tc.n)
switch {
case err != nil && !tc.hasError:
t.Error("unexpected error:", err)
case err == nil && tc.hasError:
t.Error("expected error has not occurred")
}
if !requested {
t.Error("no request")
}
})
}
}
$ go test -v mypkg === RUN TestGet === RUN TestGet/100 === RUN TestGet/200 --- PASS: TestGet (0.00s) --- PASS: TestGet/100 (0.00s) --- PASS: TestGet/200 (0.00s) PASS ok mypkg 0.030s
うまくテストを実行することができました。
これでも問題ありませんが、fmt.Fprint(w,
の部分が気に入りません。{"value":"hoge"}
)
文字列リテラルで指定するのではなく、型を使ってJSONを生成したいと思います。
レスポンスを表すgetResponse
型は次のように定義されていて、公開されていません。
type getResponse struct {
Value string json:"value"
}
これを公開するには、export_test.go
で次のように記述する必要があります。
// export_test.go package mypkg type ExportGetResponse = getResponse
そうすると、テストコードを次のように変更することできるようになります。
// client_test.go package mypkg_test import ( "encoding/json" "net/http" "net/http/httptest" "strconv" "testing" "mypkg" ) func TestGet(t *testing.T) { cases := map[string]struct { n int hasError bool }{ "100": {n: 100}, "200": {n: 200}, } for n, tc := range cases { tc := tc t.Run(n, func(t *testing.T) { var requested bool s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requested = true if r.FormValue("n") != strconv.Itoa(tc.n) { t.Errorf("param n want %s got %d", r.FormValue("n"), tc.n) } resp := &mypkg.ExportGetResponse{ Value: "hoge", } if err := json.NewEncoder(w).Encode(resp); err != nil { t.Fatal("unexpected error:", err) } })) defer s.Close() defer mypkg.SetBaseURL(s.URL)() cli := mypkg.Client{HTTPClient: s.Client()} _, err := cli.Get(tc.n) switch { case err != nil && !tc.hasError: t.Error("unexpected error:", err) case err == nil && tc.hasError: t.Error("expected error has not occurred") } if !requested { t.Error("no request") } }) } }
前述の例と比べると、JSONを生成する部分が次のように変更されています。
ExportGetResponse
型を用いてJSONが生成されていることがわかります。
resp := &mypkg.ExportGetResponse{ Value: "hoge", } if err := json.NewEncoder(w).Encode(resp); err != nil { t.Fatal("unexpected error:", err) }
おわりに
この記事では、Go Fridayで話題にあがった非公開な機能を使ったテストについて解説しました。
ここで解説した話はgolang.tokyo #17でも発表する予定です。
この記事を読んで疑問に思った点がある方は、ぜひgolang.tokyoに参加して質問をしてみてください。
また、Go Fridayでは今回のようなネタを「あーでもない、こーでもない」とメルカリのGopher(Goのエンジニア)で集まってわいわいと話をしています。
不定期でゲストをお呼びして開催もしていますので、ご興味のある方はお声がけください!