読者です 読者をやめる 読者になる 読者になる

luciでAppEngine/Goの異常系テストを手軽に書く

こんばんは。アドベントカレンダー Google Cloud Platform(2) 24日目担当の @ks888 です。

昨日の記事に続いて、AppEngine/Goのテストに便利なluci/gaeというライブラリを紹介していきます。昨日は簡単な紹介として、AppEngine/Goのテスト実行時間を短縮する例を出しました。今日は、特に異常系のテストを書くのに役立つ、filter機能とmockのリプレース機能を紹介したいと思います。

filter機能

filterはdatastoreやtaskqueue等のサービスへの操作をプロキシできる機能で、サービスへのアクセス前後で何か処理を行いたいときに役立ちます。luci/gaeには、memcacheをdatastoreの手前に挟むfilterや、サービスへの操作回数をカウントするfilter等が付属しています。もちろん、自分でfilterを作ることもできます。

今回はテストに役立つfilterとして、featureBreakerというfilterを使ってみます。featureBreakerを使うと、サービスの操作に対して強制的に任意のエラーを返させることができます。

前回と同じですが、以下のようなコードを考えます。/userにPOSTされたデータをDatastoreに書き込んでいます。

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)
}

これに対して、datastoreへのPutがエラーを返すケースのテストが以下のように書けます。

func Test_HandlePostUser_DatastoreError(t *testing.T) {
    ctx := memory.Use(context.Background())
    ctx, fb := featureBreaker.FilterRDS(ctx, nil)
    fb.BreakFeatures(nil, "PutMulti")

    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.StatusInternalServerError {
        t.Fatalf("unexpected code: %d", response.Code)
    }
}

3, 4行目でfeatureBreakerを使っています。PutMultiというメソッドが呼ばれたら、エラーを返すように設定しました。今回はデフォルトのエラーを返す設定にしましたが、任意のエラーを返すこともできます。これだけでエラーケースのテストが書けるのは楽ですね。

ここでちょっとややこしいのが、テストされる側のコードではdatastoreのPut関数を呼んでいるのに、featureBreakerではPutMultiメソッドに対してエラーを返す設定をしています。

これはluci/gaeの設計上致し方ないかなと思っています。というのも、luci/gaeはユーザー向けのインターフェース内部用のインターフェースをもっており、ユーザー向けのインターフェースの実装中で内部用のインターフェースを呼ぶ、という実装になっています。そして、filterは内部用のインターフェースに対するプロキシとして動作します。

今回のケースでも、Put関数はユーザー向けのインターフェースで、内部ではPutMultiメソッドを呼んでいます。そのため、Put関数にエラーを返させたいときは、PutMultiメソッドに対してfeatureBreakerを設定してあげる必要があります。

mockのリプレース機能

luci/gaeでは、luciが提供するdatastoreやTaskQueueのモックの代わりに、自分で定義したモックを使うこともできます。datastore等に思い通りの挙動をさせることができるので、通常の利用では起きにくい状況、あるいは起きないはずの状況を想定したテストを作成するのに役立ちます。

まずはモックを作ってみます。datastoreのモックとして認識してもらうためは、こちらのインターフェースを実装した構造体が必要です。一つ一つ実装してもいいですが、luci/gaeのdummyパッケージを使うと、独自実装したいメソッドだけ実装すればいいので楽です。

type datastoreMock struct {
    datastore.RawInterface
    putMultiCallCount int
}

func newDatastoreMock() datastore.RawInterface {
    return &datastoreMock{dummy.Datastore(), 0}
}

func (m *datastoreMock) PutMulti(keys []*datastore.Key, vals []datastore.PropertyMap, cb datastore.NewKeyCB) error {
    m.putMultiCallCount++

    if m.putMultiCallCount <= 1 {
        return errors.New("failed to put entities")
    }
    return nil
}

PutMultiメソッドだけ、自分で実装しています。ここでは、1回目の呼び出しは必ずエラーを返すようにしてみました。リトライのテストとかで使えるかなーと思います。

このモックは、以下のように使います。

func Test_HandlePostUser_Retry(t *testing.T) {
    ctx := memory.Use(context.Background())
    ctx = datastore.SetRaw(ctx, newDatastoreMock())

    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)
    }
}

3行目のSetRaw()でモックを独自のものに入れ替えています。

かなり雑ですが、テストされる側のコードもPut操作をリトライするように書き換えます。

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 {
        err = datastore.Put(ctx, &UserEntity{user, key})
        if err != nil {
            http.Error(w, err.Error(), http.StatusInternalServerError)
            return
        }
    }

    w.WriteHeader(http.StatusOK)
}

テストを実行してみます。

% goapp test ./...
ok      gaetest/luci    0.038s

ちゃんと動いているようです。独自のモックを作成することで、リトライが発生するケースのテストも簡単に作成できました。

まとめ

luci/gaeのfilter機能とmockのリプレース機能を使ったテストの例を紹介しました。luci/gaeはとにかくドキュメントが足りてない印象があるので、この記事が参考になれば幸いです。