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の記事が一時期話題になったので、聞いたことがある方もいるかもしれません。
なぜ今更そんな自前運用の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でのビルド中に、以下のようなエラーが出ました。
末尾に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を組み合わせる場合は、以下のレポジトリを利用します。
こちらはまだ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 install
とnpm run test
をしているだけです。これに加えて、非GUI環境でelectronを動かすため、aptで色々入れています。
実際の設定ファイルは、こちらで確認できます。
これらのファイルをGitHubにPushすると、ECS上でのビルドが実行されます。なお、S3上のビルド結果画面には以下のようなログしか表示されないため、ビルド結果はCloudWatch LogsのECSのログで確認します。
初回は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なくてもいけそう!と思えた段階で、改めて導入しようかと思います。