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

更新:次回分書きました

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

アドベントカレンダー ソフトウェアテスト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()を組み合わせても同じことができますが、こちらの方がテストの可読性が高くなるかと思っています。

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

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

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

OSSプロジェクトにLambCIを導入しようとした話

最近はLambStatusというOSSを作っているのですが、今回は、このプロジェクトでCIを回すためにLambCIを導入しようとした話です。結果は失敗に終わったのですが、つまづいた箇所とかが参考になるかもしれないので残しておきます。

LambCIとは

LambCIは、AWS LambdaベースのCIシステムです。〜CIというとCircleCIみたいなクラウドサービスっぽいですが、クラウドサービスではなく、自分で構築して運用していくものです。

以下のMediumの記事が一時期話題になったので、聞いたことがある方もいるかもしれません。

medium.com

なぜ今更そんな自前運用のCIシステムかというと、LambCIがいわゆるサーバレスアーキテクチャで作られていることが大きいと思います。Jenkins等のCIシステムを自前運用していくのはなかなか大変ですが、サーバレスなら自前運用の辛さをかなり軽減できるかもしれない、また、CI系のクラウドサービスはビルドの並列度を上げると料金が結構高くなりますが、サーバレスなら並列度を上げても全体の料金はほぼ変わらなそう、などのサーバレスを活かした特徴が注目されているようです。

より詳しい紹介やアーキテクチャについては、上記の記事を参照して下さい。

LambCIをOSSプロジェクトで使ってみる

今回LambCIを導入するOSSプロジェクトはJavascriptで作っていて、テストはkarma、mocha、electron等を利用して動かしています。実際のテストや設定はこちらから確認できます。

LambCIのインストールについては公式のREADMEに詳しく書かれているので省略して、設定ファイル.lambci.jsの作成から始めます。レポジトリのトップディレクトリに、以下のような設定ファイル.lambci.jsを作成しました。

module.exports = {
  cmd: 'cd packages/frontend && npm install && npm run test',
  build: true
}

GitHubにPushしたところ、AWS Lambdaでのビルド中に、以下のようなエラーが出ました。

f:id:ks888:20161119150257p:plain

末尾にError: ENOSPC: no space left on device, writeと書かれています。npm install中に、ディスクサイズが足りなくなったようです。ローカル環境で確認してみると、babelやelectron等のパッケージにより、依存パッケージだけで700MB程度のディスクを消費していました。AWS Lambdaの一時ディスク容量は512MBなので、これが原因のようです。

まぁディスク容量に限らず、AWS Lambdaには色々制限があります。LambCIは、AWS Lambdaの制限によりビルドがうまく行かないときは、ECS (EC2 Container Service)との組み合わせを勧めています。ディスク制限の回避は難しそうなので、素直にECSを使ってみます。

LambCIとEC2 Container Serviceを組み合わせて使ってみる

LambCIとECSを組み合わせる場合は、以下のレポジトリを利用します。

https://github.com/lambci/ecs

こちらはまだREADMEがあまり書かれていないので、導入手順を紹介します。

1.CloudFormationスタックの立ち上げ

こちらのテンプレートを使って、AWSコンソールからCloudFormationスタックを立ち上げます。これで、ECSクラスタ等が立ち上がります。今回はt2.smallインスタンスタイプを使いました。

2.CloudFormationテンプレートとスタックの更新

(ECSではなく)LambCI本体の立ち上げに使用したCloudFormationテンプレートを更新します。LambCIのGitHubサイトからテンプレートをダウンロードして、LambdaExecutionという名前のIAM Roleリソースに、以下のポリシーを書き加えます。

{
  "PolicyName": "RunECSTask",
  "PolicyDocument": {
    "Statement": {
      "Effect": "Allow",
      "Action": "ecs:RunTask",
      "Resource": "arn:aws:ecs:*:*:task-definition/lambci-ecs-BuildTask-XXXXXXXXXXXXX"
    }
  }

XXXXXXXXXXXXXの箇所は、Step.1で作成されたECSのタスク名で置き換えてください。

書き換えたテンプレートを利用して、AWSコンソールからLambCIのCloudFormationスタックを更新します。

3.設定ファイルの更新と追加

.lambci.jsに対して、ビルドに使用するクラスタ名などを以下のように指定します。

module.exports = {
  ... 既存の設定 ...
  docker: {
    cluster: 'lambci-ecs-Cluster-XXXXXXXXXXXXX',
    task: 'lambci-ecs-BuildTask-XXXXXXXXXXXXX'
  }
}

XXXXXXXXXXXXXの箇所は、Step.1で作成したECSのクラスタ名とタスク名です。

これに加えて、Dockerfile.testというファイルをレポジトリのトップディレクトリに作成します。これは、ビルド処理を動かすDockerイメージをビルドするためのファイルです。

今回は以下のようなDockerfile.testファイルを作成しました。

FROM node:4.3.2

RUN apt-get update && apt-get install -y xvfb x11-xkb-utils xfonts-100dpi xfonts-75dpi xfonts-scalable \
  xfonts-cyrillic x11-apps clang libdbus-1-dev libgtk2.0-dev libnotify-dev libgnome-keyring-dev \
  libgconf2-dev libasound2-dev libcap-dev libcups2-dev libxtst-dev libxss1 libnss3-dev \
  gcc-multilib g++-multilib libgconf2-4 gtk2-engines-pixbuf

ADD packages/frontend/package.json /frontend_build/

RUN cd /frontend_build && npm install

ADD . .

RUN rm -rf packages/frontend/node_modules && mv /frontend_build/node_modules ./packages/frontend

CMD cd packages/frontend && xvfb-run -a --server-args="-screen 0 1024x768x24" npm run test

キャッシュを効かせるためにpakcage.jsonをごにょごにょしているため少しややこしいですが、基本的にはnpm installnpm run testをしているだけです。これに加えて、非GUI環境でelectronを動かすため、aptで色々入れています。

実際の設定ファイルは、こちらで確認できます。

これらのファイルをGitHubにPushすると、ECS上でのビルドが実行されます。なお、S3上のビルド結果画面には以下のようなログしか表示されないため、ビルド結果はCloudWatch LogsのECSのログで確認します。

f:id:ks888:20161119151548p:plain

初回は20分ほどかかりましたが、無事にビルドが成功しました。t2.smallインスタンスタイプを利用したので仕方ないと思いますが、同等のビルドをCIサービスのwerckerで動かすと7分ほどで終わったので、ちょっと遅めです。

LambCI導入をやめた理由

ここまでは進めたのですが、しばらくはクラウドサービスを使おうかな、と思っています。理由としては以下です。

  • ECS上でビルドした結果がS3に反映されない

    ECSを利用しない場合、ビルド結果はS3に渡されるため、S3を通してビルド結果を他の開発者と共有できます。同様にビルド結果を示すバッジ画像も、S3を通してREADME等に表示できます。しかしECSを利用すると、ビルド結果がS3に反映されないため、ずっとビルド中の状態になってしまいます。ビルド結果はCloudWatch Logsからわかるのですが、CloudWatch Logsの閲覧権限をパブリックに共有するのは気が進みません。

  • Lambdaの代替としてECSを使いたくない

    今回はLambdaの代わりにECSを使おうとしましたが、Lambdaに期待することをECSで実現するのは無理があると感じました。

    例えば、今回は最初t2.microインスタンスクラスタを立ち上げたのですが、これだとビルドが何回繰り返しても通りませんでした。インスタンスの性能が足りないのか思い、試しにt2.smallにしてみたところ、通りました。また、クラスタを立ち上げる度にECS Agentが古いから更新しろと警告してきます(更新は数クリックで済みますし、AMIを適切に作り直せば警告は消えると思いますが)。こういう問題は、始めからECSを利用するつもりなら何でもない話ですが、Lambdaを使うつもりだった立場からすると、ドウシテコウナッタ感があります。

    また、LambCIは、ビルド並列度を上げても全体の料金がほぼ変わらないところや、ビルドが実行されてないときは料金がほぼかからないところが利点です。しかし、ECSだとその恩恵にあずかるのは難しくなります。オートスケールを組み合わせれば多少ましになるかもしれませんが、それでも、1時間単位課金と100ms単位課金ではだいぶ料金が変わってくる気がします。

1つ目はそれほど大きな理由ではないし、回避策もあると思うのですが、2つ目の理由は結構厳しいと思い、導入をやめました。Lambdaの制限が緩和されて、ECSなくてもいけそう!と思えた段階で、改めて導入しようかと思います。

ちなみに、今はwerckerを使っています。こんな設定ファイルを書くといい感じにテストしてくれます。