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

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

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はとにかくドキュメントが足りてない印象があるので、この記事が参考になれば幸いです。

AppEngine/Goのテストを100倍高速化するライブラリluciの紹介

AppEngine Go テスト

こんばんは。アドベントカレンダー 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の機能について紹介したいと思います。

更新:次回分書きました

NightmareでE2Eテストをするときに役立った独自アクションのメモ

テスト Nightmare

アドベントカレンダー ソフトウェアテスト6日目担当の @ks888 です。

ソフトウェアテスト Advent Calendar 2016 - Qiita

最近はNightmareでE2Eテストを書くことが増えてきました。Nightmareを使うと、例えば以下のような感じでテストを書けます。

var Nightmare = require('nightmare');
var expect = require('chai').expect; // jshint ignore:line

describe('test yahoo search results', function() {
  it('should find the nightmare github link first', function(done) {
    var nightmare = Nightmare()
    nightmare
      .goto('http://yahoo.com')
      .type('form[action*="/search"] [name=p]', 'github nightmare')
      .click('form[action*="/search"] [type=submit]')
      .wait('#main')
      .evaluate(function () {
        return document.querySelector('#main .layoutMiddle a').href
      })
      .end()
      .then(function(link) {
        console.log(link);
        expect(link).to.equal('https://github.com/segmentio/nightmare');
        done();
      })
  });
});

コード中で出てくるclickとかwaitとかはNightmareではアクションと呼ばれています。2016年12月時点では以下のようなアクションが用意されています(各アクションの意味は公式のREADMEを参考にして下さい)。

engineVersions
title
url
visible
exists
click
mousedown
mouseover
type
insert
check
uncheck
select
back
forward
refresh
wait
evaluate
inject
viewport
useragent
scrollTo
screenshot
html
pdf
cookies.get
cookies.set
cookies.clear
cookies.clearAll
authentication

アクションは自作することもできて、いくつか追加しておくと、テストを書くのが更に捗ります。今回はメモがてら、これまでに作った独自アクションを残しておきます。

HTTPヘッダーを付与する(addHeaderアクション)

var Nightmare = require('nightmare');

// アクションを追加する
Nightmare.action('addHeader',
  function (name, options, parent, win, renderer, done) {
    parent.respondTo('addHeader', function (headerKey, headerValue, done) {
      win.webContents.session.webRequest.onBeforeSendHeaders((details, callback) => {
        details.requestHeaders[headerKey] = headerValue
        callback({cancel: false, requestHeaders: details.requestHeaders})
      })
      done()
    })
    done()
  }, function (headerKey, headerValue, done) {
    this.child.call('addHeader', headerKey, headerValue, done)
  })

// 利用例
var nightmare = Nightmare()
nightmare
  .addHeader('Key', 'Value')
  .goto('http://localhost:8080')
  .end()
  .then()

任意のHTTPヘッダーを付与するアクションです。公式でheader()というメソッドが既にあるのですが、header()で指定したヘッダーはgoto()で指定したページをロードするときしか使われず、JS等で動的にロードする場合には使われません。addHeader()はJS等で動的にロードする場合にも指定したヘッダーが付きます。

ちなみにaddHeader()を複数回呼んでも、最後の呼び出しで指定したHeaderしか付きません。ちょっといけてないですが、今のところ困ってないので。。

特定URLへのアクセスを遮断する(blockAccessアクション)

var Nightmare = require('nightmare');

// アクションを追加する
Nightmare.action('blockAccess',
  function (name, options, parent, win, renderer, done) {
    parent.respondTo('blockAccess', function (blockUrls, done) {
      win.webContents.session.webRequest.onBeforeRequest({ urls: blockUrls }, (details, callback) => {
        callback({cancel: true})
      })
      done()
    })
    done()
  }, function (blockUrls, done) {
    this.child.call('blockAccess', blockUrls, done)
  })


// 利用例
var nightmare = Nightmare()
nightmare
  .blockAccess(['http://localhost:8080/*'])
  .goto('http://localhost:8080')
  .end()
  .then()

指定したパターンにマッチしたURLへのアクセスを全てキャンセルします。 例えばGoogleAnalyticsサーバへのアクセスをキャンセルすると、E2EテストがGoogleAnalyticsのデータに影響を与えるのを防ぐことができます。

条件付きでクリックする(clickIfアクション)

var Nightmare = require('nightmare');

// アクションを追加する
Nightmare.action('clickIf', function (condFn, selector, done) {
  var clickIfFn
  eval(
    "clickIfFn = function() {" +
    "  if (" + condFn + "()) {" +
    "    document.activeElement.blur();" +
    "    var element = document.querySelector('" + selector + "');" +
    "    if (!element) {" +
    "      throw new Error('unable to find element by selector: " + selector + "');" +
    "    }" +
    "    var event = document.createEvent('MouseEvent');" +
    "    event.initEvent('click', true, true);" +
    "    element.dispatchEvent(event);" +
    "  }" +
    "}"
  );
  this.evaluate_now(clickIfFn, done)
})

// 利用例
var nightmare = Nightmare()
nightmare
  .goto('https://github.com/')
  .clickIf(function() { return document.querySelector('#user-links') === null }, '.site-header-actions > a')
  .end()
  .then()

第一引数で与えた関数がtrueを返した場合のみ、第二引数が指定する要素をクリックします。関数はElectron内で実行されます。evaluate()click()を組み合わせても同じことができますが、こちらの方がテストの可読性が高くなるかと思っています。

独自アクションを書く上で参考になる情報

自分でアクションを書く際には、以下のリンクが役立ちました。

参考になれば幸いです。新しいアクションを書いたら、また追記します。