Mercari Engineering Blog

We're the software engineers behind Mercari. Check out our blog to see the tech that powers our marketplace.

Go Fridayこぼれ話:非公開(unexported)な機能を使ったテスト #golang

f:id:uedatakuya275:20180808001951p:plain

はじめに

メルペイ エキスパートチームの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 buildgo 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で次のように定義することで、maxValueExportMaxValueとしてテストコードに公開されます。

// 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.gogo 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のエンジニア)で集まってわいわいと話をしています。 不定期でゲストをお呼びして開催もしていますので、ご興味のある方はお声がけください!