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

更新:次回分書きました