AppEngine/Goのテストに役立つライブラリluciの紹介
こんばんは。アドベントカレンダー Google Cloud Platform(2) 23日目担当の @ks888 です。
突然ですが、luciというライブラリをご存知でしょうか。AppEngine/Goのテストに効果を発揮する素晴らしいライブラリなのですが、あまり使っているという話を聞きません。今回はそんな不遇?のライブラリluciを紹介してみたいと思います。
luciとは
luciのGitHubページには次のように書かれています。
LUCI stands for Layered "Universal" Continuous Integration. It's a fleet of complementary, but independent, scalable services made to facilitate small-to-huge-scale CI needs.
うーんCIに役立つサービス群とありますが、ピンと来ません。具体例として、luci/gaeというluciのサブプロジェクトの説明をみてみましょう。luci/gaeのgodocには、以下のように書いてあります。
Package gae provides a fakable wrapped interface for the appengine SDK's APIs. This means that it's possible to mock all of the supported appengine APIs for testing (or potentially implement a different backend for them). Features gae currently provides interfaces for: - Datastore - Memcache - TaskQueue - Info (e.g. Namespace, AppID, etc.)
luci/gaeは、AppEngineのAPIをラップしたインターフェースを提供していて、そのインターフェースの実装をモックに置き換えることで、テストがしやすくなるようです。実はluci/gaeはDatastoreやTaskQueue等のインメモリ実装を提供しており、これをモックとして利用することで、高速なテストの実行が可能になります。また、モックは自分で好きな実装に置き換えられるので、色々なテストケースに対応することができます。
luciのGitHubページに書かれていた「CIに役立つサービス群」とは、luci/gaeにおいてはAppEngine APIのラッパーインターフェース+そのインメモリ実装のことなのかと思います。
では、luci/gaeを使うとどのようにテストが書けるのか、以下で試してみます。なお、luciにはluci/gae以外にもサブプロジェクトがありますが、今回はluci/gaeにフォーカスします。
luci/gaeを使わずにテストする
まずは、luci/gaeを使わない場合のテストコードを確認してみます。
以下のようなAppEngine/Goのコードを考えます。/user
にPOSTされたデータをDatastoreに書き込んでいるだけです。DatastoreのKeyとEmailがかぶっていますが、今回は簡単のためにこのままにします。
package main import ( "encoding/json" "net/http" "golang.org/x/net/context" "google.golang.org/appengine" "google.golang.org/appengine/datastore" ) const userKind = "User" // User ... type User struct { Name string `json:"name"` Email string `json:"email"` } func init() { http.HandleFunc("/user", userHandler) } func userHandler(w http.ResponseWriter, r *http.Request) { ctx := appengine.NewContext(r) switch r.Method { case "POST": handlePostUser(ctx, w, r) default: http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } } func handlePostUser(ctx context.Context, w http.ResponseWriter, r *http.Request) { var user User err := json.NewDecoder(r.Body).Decode(&user) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } defer r.Body.Close() key := datastore.NewKey(ctx, userKind, user.Email, 0, nil) _, err = datastore.Put(ctx, key, &user) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.WriteHeader(http.StatusOK) }
対して、以下のようなテストコードを考えます。
package main import ( "net/http" "net/http/httptest" "strings" "testing" "google.golang.org/appengine/aetest" "google.golang.org/appengine/datastore" ) func Test_HandlePostUser(t *testing.T) { ctx, done, err := aetest.NewContext() if err != nil { t.Fatal(err) } defer done() response := httptest.NewRecorder() json := `{"name": "mahiru", "email": "inami@example.com"}` request, _ := http.NewRequest("POST", "/user", strings.NewReader(json)) handlePostUser(ctx, response, request) if response.Code != http.StatusOK { t.Fatalf("unexpected code: %d", response.Code) } user := User{} datastore.Get(ctx, datastore.NewKey(ctx, userKind, "inami@example.com", 0, nil), &user) if user.Email != "inami@example.com" { t.Fatalf("invalid email: %s", user.Email) } if user.Name != "mahiru" { t.Fatalf("invalid name: %s", user.Name) } }
テストを実行してみます。
% goapp test ./... ok gaetest 4.623s
テストは通りました。しかしこれだけのテストなのに、実行時間が4.623s
になっています。なぜでしょうか?
答えは、テストコードの冒頭で呼び出しているaetest.NewContext()
にあります。以下はaetestパッケージのドキュメントからの抜粋です。
func NewContext() (context.Context, func(), error) NewContext starts an instance of the development API server, and returns a context that will route all API calls to that server, as well as a closure that must be called when the Context is no longer required.
NewContext()
を呼び出す度に開発用サーバが起動していて、そのためにテストの実行時間が長くなっているようです。
どうすればいいでしょうか?ここからのアプローチは色々あると思うのですが、その一つにluci/gaeがあります。
luci/gaeを使えば、開発用サーバの代わりにDatastoreやTaskQueueのモックを使ってテストすることができます。開発用サーバを立ち上げずに済むので、テストの実行時間が無駄に長くなりません。また、DatastoreやTaskQueueのモックは各テストで独立しているので、テスト間でDatastore操作等が競合しないように気を使う必要もありません。
luci/gaeを使ってテストする
では先程のコードをluci/gaeを使ってテストしてみます。luci/gaeはAppEngineのAPIをラップしたインターフェースを提供しているので、まずはテストされる側のコードをluci/gaeのインターフェースに沿って書き換えます。datastoreパッケージのgodocはこちらです。
以下のように書き換えました。
package main import ( "encoding/json" "net/http" "github.com/luci/gae/impl/prod" "github.com/luci/gae/service/datastore" "golang.org/x/net/context" "google.golang.org/appengine" ) const userKind = "User" // User ... type User struct { Name string `json:"name"` Email string `json:"email"` } // UserEntity ... type UserEntity struct { User Key *datastore.Key `gae:"$key"` } func init() { http.HandleFunc("/user", userHandler) } func userHandler(w http.ResponseWriter, r *http.Request) { ctx := prod.Use(appengine.NewContext(r), r) switch r.Method { case "POST": handlePostUser(ctx, w, r) default: http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } } func handlePostUser(ctx context.Context, w http.ResponseWriter, r *http.Request) { var user User err := json.NewDecoder(r.Body).Decode(&user) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } defer r.Body.Close() key := datastore.NewKey(ctx, userKind, user.Email, 0, nil) err = datastore.Put(ctx, &UserEntity{user, key}) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.WriteHeader(http.StatusOK) }
主に変更したのは、context生成部分と、datastoreのKey指定部分です。
contextをprod.Use()
という関数でラップすることで、luci/gaeに(モックではなく)実際のAppEngine環境を使うよう指定します。
また、datastoreのKeyは引数ではなく構造体のフィールドの一つとして指定します。上記コードのUserEntity構造体を定義している部分です。
次に、テストコードも書き換えます。
package main import ( "net/http" "net/http/httptest" "strings" "testing" "github.com/luci/gae/impl/memory" "github.com/luci/gae/service/datastore" "golang.org/x/net/context" ) func Test_HandlePostUser(t *testing.T) { ctx := memory.Use(context.Background()) response := httptest.NewRecorder() json := `{"name": "mahiru", "email": "inami@example.com"}` request, _ := http.NewRequest("POST", "/user", strings.NewReader(json)) handlePostUser(ctx, response, request) if response.Code != http.StatusOK { t.Fatalf("unexpected code: %d", response.Code) } user := UserEntity{Key: datastore.NewKey(ctx, userKind, "inami@example.com", 0, nil)} err := datastore.Get(ctx, &user) if err != nil { t.Fatal(err) } if user.Email != "inami@example.com" { t.Fatalf("invalid email: %s", user.Email) } if user.Name != "mahiru" { t.Fatalf("invalid name: %s", user.Name) } }
こちらも、context生成部分と、datastoreのKey指定部分を変更しました。
contextをmemory.Use()
という関数でラップすることで、luci/gaeにインメモリのAppEngine環境を使うよう指定します。
テストを実行してみます。
% goapp test ./... ok gaetest/luci 0.020s
実行時間が0.020s
にまで短縮されました。開発用サーバの代わりにモックを使っているおかげで、余計なオーバーヘッドなくテストを実行できています。また、datastoreはcontextごとに独立しているので、テストの並列化もしやすいかと思います。
まとめ
簡単ですが、luci/gaeの使い方を紹介しました。AppEngine/Goのテストに役立ててもらえると幸いです。次回はテストを書くときに役立つluci/gaeの機能について紹介したいと思います。