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

やらなイカ?

たぶん、iOS/Androidアプリの開発・テスト関係。

GAE/Goで動くLINE BOTのテストを書いてみた

App Engine Golang Testing

Google App Engine Go(以下GAE/Go)上で動くLINE BOT調整さんリマインダBOT」のMessaging API対応やグループ対応をしつつ、テストを書いて得た知見のメモ。

環境

環境変数

API KEYなどをapp.yaml環境変数として定義している場合、テスト実行($ goapp test)では値を取得できません。

なので、Makefileにテスト用の値を定義し、常に$ make testで実行するようにしました。

export LINE_CHANNEL_SECRET=012345678901234567890123456789ab
export LINE_CHANNEL_ACCESS_TOKEN=u012345678901234567890123456789ab

test:
    goapp test -v

実際のMakefileでは、カバレジ取得、-runオプションの付与も行なっていますが割愛。

aetest package

プロダクトコード内でappengine.NewContext(http.Request)context.Contextを取得している場合、httptest.NewRequest()などで生成したhttp.Requestを渡すとエラー*1になります。

そのため、GAE/Goのテストではaetest packageを使用してcontext.Contexthttp.Requestを生成して使う必要があります。

Contextだけが必要な場合

データストアに関するテストを書く場合などContextだけが必要な場合は、以下のように取得できます。

c, done, err := aetest.NewContext()
if err != nil {
    t.Fatal(err)
}
defer done()

aetestを使用すると、GAE/Goのローカル開発サーバ( local development server)が起動します。

これには都度(テストケースごとに)起動に時間がかかるのと*2defer done()を忘れるとプロセスが起動したまま残ってしまう*3ので注意しましょう。

Instanceが必要な場合

http.Requestを使うテストの場合には、以下のようにGAEインスタンスを直接取得します。引数のaetest.Optionsは、データストアを使用しないならnilでも構いません(後述)。

opt := aetest.Options{StronglyConsistentDatastore: true}
instance, err := aetest.NewInstance(&opt)
if err != nil {
    t.Fatalf("Failed to create aetest instance: %v", err)
}
defer instance.Close()

こちらも、defer instance.Close()を忘れずに。

取得したインスタンスから、以下のようにhttp.Requestcontext.Contextを生成できます。

req = httptest.NewRequest("POST", "/line/callback", json)
c := appengine.NewContext(req)

なお、appengine.NewContext()は、例えばプロダクトコード内とテストコード側で二回記述されていても、インスタンスが同じなので同一のデータストアを扱うことが出来ます。プロダクトコードの引数にhttp.Requestがあれば、無理にcontext.Contextまで渡す必要はありません。*4

http.RequestのContent-Typeヘッダ

テストに使うhttp.Requestを自力で組み立てる場合、以下のようにContent-Typeヘッダを付与しないとBodyが渡りません。

Task Queueなど、url.ValuesをEncode()する場合

req.Header.Set("Content-Type", "application/x-www-form-urlencoded")

jsonの場合

req.Header.Set("Content-Type", "application/json")

WebhookのX-LINE-Signatureヘッダ

LINE Messaging API SDKにパースさせるWebhookのリクエストには、リクエストヘッダにSignatureを乗せる必要があります*5。 Signatureは、API Referenceの"Webhook Authentication"に書かれている検証手順を元に、以下の手順で生成できます。

  1. Channel Secretを秘密鍵として、HMAC-SHA256アルゴリズムによりRequest Bodyのダイジェスト値を得る
  2. ダイジェスト値をBASE64エンコードした文字列を、Request Headerに付与する

具体的には以下のコードで生成・付与できます。

channelSecret := os.Getenv("LINE_CHANNEL_SECRET")
hash := hmac.New(sha256.New, []byte(channelSecret))
hash.Write(byteBody)
encoded := base64.StdEncoding.EncodeToString(hash.Sum(nil))
req.Header.Add("X-LINE-Signature", encoded)

httpmock

httpmockは、テスト実行時に外部サーバのスタブとして動作します。Goではhttptest.NewServer()で簡単にスタブを立てることはできるのですが、今回のようにアクセス先URLがSDK内に隠蔽されているケースなど、アクセス先URLを書き換えずにモックできるので便利です。

本家(jarcoal/httpmock)はメンテナンスが止まっているので、forkされて継続開発されているこちらを使用しました。

github.com

httpmockの有効化

以下のコードで有効化できます。単にhttpmockを有効化すると、以降すべてのURLに対するリクエストをhttpmockが受け取り、エラーレスポンスを返すようになります。

ctx := appengine.NewContext(req)
client := urlfetch.Client(ctx)

httpmock.ActivateNonDefault(client)
defer httpmock.DeactivateAndReset()

ここで重要なのは、引数付きのActivateNonDefault()を使い、appengine.urlfetchインスタンスを渡している点です。

GAE/Goでは、外部へのhttpリクエストは(DefaultClientではなく)urlfetch.Client()で生成したクライアントから送る必要があります。そのため、linebotクライアントもurlfetchを使って初期化しています(過去記事「調整さんリマインダLINE BOTを作ってみた - やらなイカ?」参照)。

httpmockはhttp.Clientインスタンスに対して効力があるため、linebotクライアントに渡したものと同じインスタンスActivateNonDefault()に渡す必要があります。

スタブの定義

例えば、LINEのReply Message APIが単に正常終了するスタブであれば、以下のように記述します。

httpmock.RegisterStubRequest(
    httpmock.NewStubRequest(
        "POST",
        "https://api.line.me/v2/bot/message/reply",
        httpmock.NewStringResponder(200, "{}"),
    ),
)

以降、指定したURLに対するリクエストにはステータスコード200、ボディに"{}"が返るようになります。なお、URLは完全一致で、URLパラメタ(?key=value)も含めて同一の文字列である必要があります。

スタブは、必要なだけいくつでもRegisterStubRequest()で追加できます。

[10/31追記]同一のURLを持つスタブを複数RegisterStubRequest()してもエラーにはなりませんが、そのURLに複数回リクエストを発行しても、常に最初のスタブが使われます。従って、後述のAllStubsCalled()でエラーとして検知されます。

スタブが呼ばれたことを検証する

想定したURLすべてに対して正しくリクエストが送られたことを確認するには、AllStubsCalled()を使います。

if err := httpmock.AllStubsCalled(); err != nil {
    t.Errorf("Not all stubs were called: %s", err)
}

定義したのに呼ばれていないスタブがひとつでも存在するとErrorを返してくれます。呼ばれていないスタブが複数あるときにはErrorに全て列挙してくれます。

なお、リクエストの内容(たとえばLINEに送信したText Messageの内容)まで検証したければ、スタブのNewStubRequest()の第3引数に直接無名関数を書くことで検証が可能です(httpmock - GoDocに例があります)。

その他のTips

Goのテストではあたりまえのことかも知れませんが、その他、知ったこと。

Error()とFail()

  • t.Error()では、テストコードの実行は継続されるが、テストはFAILする。構造体メンバの検証、パラメタライズドテストなど、一度の実行で問題点を全て知りたいときに有用
  • t.Fail()は、テストがFAILし、テストコードの実行が中断される。テストフィクスチャ構築中や、その他、継続しても仕方のない箇所でのエラーに使う
  • t.Log()で出力したログは、テストがFAILしないと出力されない

-run オプション

  • $ go test -run 関数名とすると、関数名に一致するテストだけ実行できる

TODO

今後なんとかしたいこと。

Task Queueのテスト

Task Queueに関するテストが書けない(taskqueue.Add()されたことを検証できない)件。

  • taskqueueをモックすればできるのかも?
  • そもそも、このBOTで個々の処理をTQにする必要はなかったのでは。goroutineを使うべき?

バージョン番号の埋め込み

『みんなのGo言語』p.56に書かれていた、ビルド時の-ldflagsgit describe --tagsで取得したバージョン番号を埋め込む方法を試しましたが、GAE/Goでは指定できず。goapp deploy時に指定しても無駄でした。

とりあえず、Makefile中でversion.goファイルを書き出すことで実現。

依存パッケージ管理

Glideで依存パッケージ管理をしようと試みましたが、deploy時にエラーが出てしまい断念。下記エントリに従えばできそうなので、いつかリトライ予定。

静的解析

gometalinterをローカルおよびTravis CI上でもafter_successで実行していますが不完全。

  • warningだけでも終了コード>0が返るため、scriptで実行させられないのが現状。warningを全部取るか、細かくパラメタ設定するか
  • Travis CI上では、ローカルでは出ないerrorが出る。正しくvendoringする必要がありそう

参考

関連エントリ

書籍

みんなのGo言語[現場で使える実践テクニック]

みんなのGo言語[現場で使える実践テクニック]

第6章 Goのテストに関するツールセット

システムテスト自動化 標準ガイド (CodeZine BOOKS)

システムテスト自動化 標準ガイド (CodeZine BOOKS)

  • 作者: Mark Fewster,Dorothy Graham,テスト自動化研究会,伊藤望,玉川紘子,長谷川孝二,きょん,鈴木一裕,太田健一郎,森龍二,近江久美子,永田敦,吉村好廣,板垣真太郎,浦山さつき,井芹洋輝,松木晋祐,長田学,早川隆治
  • 出版社/メーカー: 翔泳社
  • 発売日: 2014/12/16
  • メディア: 単行本(ソフトカバー)
  • この商品を含むブログ (3件) を見る
第14章 CI(継続的インテグレーション

*1:panic: appengine: NewContext passed an unknown http.Request

*2:ログ出力のためだけにContextを渡しているのであれば考え直したほうがよさそう。テスト実行時ならfmt.Print()でも視認できるので

*3:ローカル開発サーバの生き残りプロセスは $ ps -ef | grep dev_appserver.py で確認できます

*4:調整さんリマインダBOTではこのあたりキレイに書けておらず、いずれリファクタリングします。不慣れな言語こそTDDしないとダメですね

*5:クライアント側でSignatureを検証する必要があり、その機能までSDKに実装されているため