こんにちは。
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
package mock_foods
import (
gomock "github.com/golang/mock/gomock"
reflect "reflect"
)
type MockFood struct {
ctrl *gomock.Controller
recorder *MockFoodMockRecorder
}
type MockFoodMockRecorder struct {
mock *MockFood
}
func NewMockFood(ctrl *gomock.Controller) *MockFood {
mock := &MockFood{ctrl: ctrl}
mock.recorder = &MockFoodMockRecorder{mock}
return mock
}
func (m *MockFood) EXPECT() *MockFoodMockRecorder {
return m.recorder
}
func (m *MockFood) Name() string {
ret := m.ctrl.Call(m, "Name")
ret0, _ := ret[0].(string)
return ret0
}
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)
}
}
サーバーを立ち上げる方法ではレスポンスのBody
はio.Reader
型のため変換が必要でしたが、
レコーダーのBody
はio.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が標準で提供しているテストの機能の一部を紹介しました。
言語としてテストを強くサポートしていることが感じられたのではないでしょうか。
まだまだ紹介しきれていない機能やオプションなどがたくさんあります。
今後も有益な機能について紹介できればと思っております。