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の機能について紹介したいと思います。