DeNA Testing Blog

Make Testing Fun, Smart, and Delighting End-Users

Golang testingことはじめ(3)〜アプリケーションのテスト〜

こんにちは。
2回にわたってGolang標準の testing パッケージを使ったユニットテストについてお伝えしてきました。

今回はGolangで作成したアプリケーションをテストする際に利用できるライブラリなどについて紹介します。

この文章中に登場するサンプルは GitHub にありますので、実際に動作させることが可能です。

$ go get github.com/duck8823/sample-go-testing
$ cd $GOPATH/src/github.com/duck8823/sample-go-testing
$ git checkout refs/tags/blog
$ dep ensure # 依存パッケージのダウンロード

なお、文章中のコマンドは全てバージョン1.9.2での動作を確認しています。

アプリケーションのテスト

アプリケーションをテストする場合はいくらかのテクニックが必要になります。
ここでは、モックを使ったテストやウェブアプリケーションのrouting(エンドポイント)のテストについてGolangでの書き方について記述します。

モック

通常、ある程度の大きさ以上のアプリケーションを作成する場合、複数のパッケージに分けて分割します。 分割したアプリケーションのそれぞれのコードをテストする場合、モックを利用して外部(テスト対象以外)の依存を減らすことが望ましいです。
Golangでは gomock が提供されており、 testing フレームワークと組み合わせて利用することができます。
gomock では静的なモック用のソースコードを作成します。

インストール

モックを生成する mockgen はcliツールですが、go get でインストールできます。
Golangはツールの配布・利用も簡単です。

$ go get github.com/golang/mock/mockgen

モックの作成

モックはインタフェースに対して作成します。

foods/food.go には food インタフェースが含まれているので、これに対してモックを生成します。

foods/food.go

package foods

type Food interface {
    Name() string
}

type Apple struct {
    cultivar string
}

func NewApple(cultivar string) *Apple {
    return &Apple{cultivar}
}

func (apple *Apple) Name() string {
    return apple.cultivar
}

mockgenコマンドを実行します。-sourceオプションでインタフェースが含まれるソース、-destinationオプションで出力先を指定します。

$ mockgen -source=foods/food.go --destination foods/mock_foods/mock_foods.go

foods/mock_foods/mock_foods.go

// Code generated by MockGen. DO NOT EDIT.
// Source: foods/food.go

// Package mock_foods is a generated GoMock package.
package mock_foods

import (
    gomock "github.com/golang/mock/gomock"
    reflect "reflect"
)

// MockFood is a mock of Food interface
type MockFood struct {
    ctrl     *gomock.Controller
    recorder *MockFoodMockRecorder
}

// MockFoodMockRecorder is the mock recorder for MockFood
type MockFoodMockRecorder struct {
    mock *MockFood
}

// NewMockFood creates a new mock instance
func NewMockFood(ctrl *gomock.Controller) *MockFood {
    mock := &MockFood{ctrl: ctrl}
    mock.recorder = &MockFoodMockRecorder{mock}
    return mock
}

// EXPECT returns an object that allows the caller to indicate expected use
func (m *MockFood) EXPECT() *MockFoodMockRecorder {
    return m.recorder
}

// Name mocks base method
func (m *MockFood) Name() string {
    ret := m.ctrl.Call(m, "Name")
    ret0, _ := ret[0].(string)
    return ret0
}

// Name indicates an expected call of Name
func (mr *MockFoodMockRecorder) Name() *gomock.Call {
    return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Name", reflect.TypeOf((*MockFood)(nil).Name))
}

デフォルトのパッケージ名はソースとなるパッケージに mock_ の接頭辞をつけたものになります。これは -package オプションで変更することができます。
その他のオプションは gomock -help で確認することができます。

モックを使ったテスト

インタフェース food を引数にした以下の関数に対してテストを記述します。

func (duck *Duck) Eat(food foods.Food) string {
    return fmt.Sprintf("%s ate %s", duck.Name, food.Name())
}

モックインスタンスは以下のように作成します。

ctrl := gomock.NewController(t)
food := mock_foods.NewMockFood(ctrl)

作成したモックから EXPECT() に続いて関数を指定し、Return の引数で戻り値を指定することができます。

food.EXPECT().Name().Return("kougyoku")

上記の場合、 Name() が呼ばれたら "kougyoku" を返します。

animals/animals_07_test.go

package animals_test

import (
    "github.com/duck8823/sample-go-testing/animals"
    "github.com/duck8823/sample-go-testing/foods/mock_foods"
    "github.com/golang/mock/gomock"
    "testing"
)

func TestDuck_Eat_02(t *testing.T) {
    ctrl := gomock.NewController(t)
    defer ctrl.Finish()

    food := mock_foods.NewMockFood(ctrl)
    food.EXPECT().Name().Return("kougyoku")

    duck := animals.NewDuck("tarou")
    actual := duck.Eat(food)
    expected := "tarou ate kougyoku"
    if actual != expected {
        t.Errorf("got: %s\nwont: %s", actual, expected)
    }
}

上記の例でduck.Eat(food)では、FoodのName()を呼び出します。
Foodはインタフェースなので本来であればその実装に依存しますが、 モックを作成して特定の文字列を返却するように指定しているのでテスト実行時の依存を減らすことができます。

$ go test -v animals/animals_07_test.go animals/animal.go 
=== RUN   TestDuck_Eat_02
--- PASS: TestDuck_Eat_02 (0.00s)
PASS
ok      command-line-arguments  0.007s

この例ではName() の例では引数は必要ありませんでした。
戻り値を設定したい関数に引数が必要な場合は、呼び出される際の引数を指定して限定できます。
値が何でもいい場合は gomock.Any() を指定できます。

ここでは

type Hoge interface {
    Foo(foo string) string
}

というインタフェースを想定します。
モックを利用する場合は以下のようになります。

hogeMock.EXPECT().Foo(gomock.Any()).Return("bar")

フラグのテスト

コマンドライン引数やオプションなど、クライアントツールやサーバーアプリを作成した場合にフラグを利用することはよくあると思います。
フラグによって動作を変更する場合のテストを想定します。 標準の flag を利用してオプションを実現していた場合、設定する値を変えて複数回 flag.Parse() しようとすると flag redefined: ... とエラーになってしまいます。
そこで、フラグを設定する場合flag.Parse()は利用せずflag.NewFlagSet()で生成されたインスタンスを利用することで、テスタブルなコードを記述することができます。

フラグをパースする関数は以下のようにしました。

app/flag.go

package app

import (
    "flag"
    "os"
)

func ParseFlag(args ...string) string {
    flg := flag.NewFlagSet(os.Args[0], flag.ExitOnError)
    optC := flg.String("c", "default value", "flag usage")
    flg.Parse(args)

    return *optC
}

上記の関数は以下のように利用することができます。

package main

import "github.com/duck8823/sample-go-testing/app"

func main() {
    optC := app.ParseFlag(os.Args[1:]...)
    fmt.Printf("option c: %s", optC)
}

関数ParseFlagをテストするコードは以下のようにしました。

app/flag_test.go

package app_test

import (
    "github.com/duck8823/sample-go-testing/app"
    "testing"
)

func TestParseFlag(t *testing.T) {
    t.Run("with argument, returns value", func(t *testing.T) {
        actual := app.ParseFlag("-c", "hello")
        expected := "hello"

        if actual != expected {
            t.Errorf("got: %s\nwont: %s", actual, expected)
        }
    })

    t.Run("with no argument, returns default value", func(t *testing.T) {
        actual := app.ParseFlag()
        expected := "default value"

        if actual != expected {
            t.Errorf("got: %s\nwont: %s", actual, expected)
        }
    })
}
$ go test -v app/flag_test.go
=== RUN   TestParseFlag
=== RUN   TestParseFlag/with_argument,_returns_value
=== RUN   TestParseFlag/with_no_argument,_returns_default_value
--- PASS: TestParseFlag (0.00s)
    --- PASS: TestParseFlag/with_argument,_returns_value (0.00s)
    --- PASS: TestParseFlag/with_no_argument,_returns_default_value (0.00s)
PASS
ok      command-line-arguments  0.013s

異なる値でフラグのパースを複数回行なっていますが、エラーにならず実行できています。

Webアプリケーションのroutingをテストする

ウェブアプリケーションを開発する場合は、実際にエンドポイントへアクセスして正しい結果が返ってくるかテストすることがあります。

アプリケーションを起動してテストする

利用するフレームワークがhttp.Handlerの実装となっている場合は後述のhttptestが利用できるので、そちらを利用した方がよいです。

ここでは、実際にアプリを起動してhttpクライアントからリクエストを投げ、ハンドラーの想定通りのメソッドが実行されているかどうかを確認します。
また、このサンプルではEchoフレームワークを利用しています。

ランダムで空いてるポートをListen

多くのウェブフレームワークではサーバー起動時にポートを指定しますが、指定したポートが利用されていた場合はテストが実行できません。
以下の関数はランダムで空いてるポートを取得します。

func randomAddress(t *testing.T) net.Addr {
    t.Helper()

    listener, err := net.Listen("tcp", ":0")
    listener.Close()

    if err != nil {
        t.Fatal(err)
    }
    return listener.Addr()
}

サーバーの起動

以下のソースコードについてのテストを想定します。
実際にサーバーを起動し、正しいレスポンスが返却されるかをチェックします。

app/server.go

package app

import (
    "fmt"
    "github.com/labstack/echo"
    "net/http"
)

func CreateServer() *echo.Echo {
    e := echo.New()
    e.GET("/hello", hello)
    return e
}

func hello(c echo.Context) error {
    message := fmt.Sprintf("Hello %s.", c.QueryParam("name"))
    return c.String(http.StatusOK, message)
}

利用するウェブフレームワークがフォアグラウンドでサーバーを起動する場合、テストケース内でサーバーを起動すると、そこでテストがストップしてしまいます。
別のターミナルセッションでサーバーを起動した後でテストを実行することもできますがテスト実行以外のプロセスに依存するのは避けた方がよいでしょう。

goルーチン内でサーバーを起動することで、テストケース内で完結するようにしてみます。

app/server_test.go

package app

import (
    "io/ioutil"
    "net"
    "net/http"
    "net/url"
    "testing"
)

func Test_RoutingWithStartServer(t *testing.T) {
    addr := randomAddress(t)

    s := CreateServer()
    go func() {
        s.Start(addr.String())
    }()

    reqUrl := &url.URL{
        Scheme:   "http",
        Host:     addr.String(),
        Path:     "hello",
        RawQuery: "name=duck",
    }
    resp, err := http.Get(reqUrl.String())
    if err != nil {
        t.Fatal(err)
    }

    body, err := ioutil.ReadAll(resp.Body)
    if err != nil {
        t.Fatal(err)
    }

    actual := string(body)
    expected := "Hello duck."
    if actual != expected {
        t.Errorf("got: %s\nwont: %s", actual, expected)
    }
}

func randomAddress(t *testing.T) net.Addr {
    t.Helper()

    listener, err := net.Listen("tcp", ":0")
    listener.Close()

    if err != nil {
        t.Fatal(err)
    }
    return listener.Addr()
}

これを実行すると、

go test -v app/server_test.go app/server.go
=== RUN   Test_RoutingWithStartServer

   ____    __
  / __/___/ /  ___
 / _// __/ _ \/ _ \
/___/\__/_//_/\___/ v3.2.6
High performance, minimalist Go web framework
https://echo.labstack.com
____________________________________O/_______
                                    O\
⇨ http server started on [::]:50985
--- PASS: Test_RoutingWithStartServer (0.00s)
PASS
ok      command-line-arguments  0.021s

ログからも実際にサーバーが起動しているのがわかります。

goルーチンを利用してサーバーを起動した場合の課題

上述の例では正しく動いているように見えました。
しかしサーバーの起動に時間がかかってしまう場合はどうでしょうか。

--- a/vendor/github.com/labstack/echo/echo.go
+++ b/vendor/github.com/labstack/echo/echo.go
@@ -587,6 +587,7 @@ func (e *Echo) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 
 // Start starts an HTTP server.
 func (e *Echo) Start(address string) error {
+       time.Sleep(30 * time.Second)
        e.Server.Addr = address
        return e.StartServer(e.Server)
 }

ウェブフレームワークechoのコードに手を加え、サーバーが起動する際にSleep処理を追加しました。

$ go test -v app/server_test.go app/server.go
=== RUN   Test_RoutingWithStartServer
--- FAIL: Test_RoutingWithStartServer (0.00s)
        server_test.go:27: Get http://[::]:64158/hello?name=duck: dial tcp [::]:64158: getsockopt: connection refused
FAIL
exit status 1
FAIL    command-line-arguments  0.013s

サーバーが立ち上がっていないのでconnection refusedのエラーが出てしまいました。
この場合、サーバーが立ち上がるまで待つといった工夫をしなければなりません。

サーバーの起動待ちを実装するのは面倒ですが、簡単に実現できる方法が用意されています。
ここではgoルーチンを使わない方法でテストを書き直してみましょう。

httptestを利用してテストを実行する

Golangでは、Webサーバーのテストをサポートするnet/http/httptestも用意されています。
このパッケージを利用することで、routingのテストも容易になります。

app/server_01_test.go

package app

import (
    "io/ioutil"
    "net/http/httptest"
    "testing"
)

func Test_RoutingWitHttpTest(t *testing.T) {
    s := CreateServer()
    server := httptest.NewServer(s)
    defer server.Close()

    client := server.Client()

    resp, err := client.Get(server.URL + "/hello?name=duck")
    if err != nil {
        t.Fatal(err)
    }

    body, err := ioutil.ReadAll(resp.Body)
    if err != nil {
        t.Fatal(err)
    }

    actual := string(body)
    expected := "Hello duck."
    if string(actual) != expected {
        t.Errorf("got: %s\nwont: %s", actual, expected)
    }
}

空いているポートの検索・利用は httptest が行なってくれるので、 シンプルに記述することができます。

$ go test -v app/server_01_test.go app/server.go
=== RUN   Test_RoutingWitHttpTest
--- PASS: Test_RoutingWitHttpTest (0.00s)
PASS
ok      command-line-arguments  0.013s

httptest.NewServerすることでラップされたサーバーが立ち上がります。 ウェブフレームワークのStart()をコールしているわけではないので、Sleepされません。
また、ポートも自分で指定せずにランダムで空いているポートを利用してくれます。

リクエストをシミュレーションする

httptestではサーバーを起動するほか、リクエストをシミュレーションする方法を用意しています。

レスポンスはResponseRecorderを介して行われます。
Recorderを利用することでレスポンス内容の取得も楽になります。

package app

import (
    "net/http/httptest"
    "testing"
)

func Test_RoutingWitHttpTestSimulate(t *testing.T) {
    s := CreateServer()

    req := httptest.NewRequest("GET", "/hello?name=duck", nil)
    rec := httptest.NewRecorder()

    s.ServeHTTP(rec, req)

    actual := rec.Body.String()
    expected := "Hello duck."
    if actual != expected {
        t.Errorf("got: %s\nwont: %s", actual, expected)
    }
}

サーバーを立ち上げる方法ではレスポンスのBodyio.Reader型のため変換が必要でしたが、 レコーダーのBodyio.Readerにバッファ機能をつけたリッチなbytes.Budder型となっています。
そのためString()で変換することができ、テストもシンプルに記述できました。

$ go test -v app/server_02_test.go app/server.go
=== RUN   Test_RoutingWitHttpTestSimulate
--- PASS: Test_RoutingWitHttpTestSimulate (0.00s)
PASS
ok      command-line-arguments  0.011s

最後に

これまで3回に渡ってGolangが標準で提供しているテストの機能の一部を紹介しました。 言語としてテストを強くサポートしていることが感じられたのではないでしょうか。
まだまだ紹介しきれていない機能やオプションなどがたくさんあります。
今後も有益な機能について紹介できればと思っております。