Google App Engine Go(以下GAE/Go)上で動くLINE BOT「調整さんリマインダBOT」のMessaging API対応やグループ対応をしつつ、テストを書いて得た知見のメモ。
環境
- Google App Engine SDK for Go 1.9.40
- Go SDK for the LINE 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.Contextやhttp.Requestを生成して使う必要があります。
Contextだけが必要な場合
データストアに関するテストを書く場合などContextだけが必要な場合は、以下のように取得できます。
c, done, err := aetest.NewContext()
if err != nil {
t.Fatal(err)
}
defer done()
aetestを使用すると、GAE/Goのローカル開発サーバ( local development server)が起動します。
これには都度(テストケースごとに)起動に時間がかかるのと*2、defer 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.Requestやcontext.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"に書かれている検証手順を元に、以下の手順で生成できます。
- Channel Secretを秘密鍵として、HMAC-SHA256アルゴリズムによりRequest Bodyのダイジェスト値を得る
- ダイジェスト値を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されて継続開発されているこちらを使用しました。
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に書かれていた、ビルド時の-ldflagsにgit describe --tagsで取得したバージョン番号を埋め込む方法を試しましたが、GAE/Goでは指定できず。goapp deploy時に指定しても無駄でした。
とりあえず、Makefile中でversion.goファイルを書き出すことで実現。
依存パッケージ管理
Glideで依存パッケージ管理をしようと試みましたが、deploy時にエラーが出てしまい断念。下記エントリに従えばできそうなので、いつかリトライ予定。
静的解析
gometalinterをローカルおよびTravis CI上でもafter_successで実行していますが不完全。
- warningだけでも終了コード>0が返るため、
scriptで実行させられないのが現状。warningを全部取るか、細かくパラメタ設定するか - Travis CI上では、ローカルでは出ないerrorが出る。正しくvendoringする必要がありそう
参考
- Local Unit Testing for Go | App Engine standard environment for Go | Google Cloud Platform
- GAE/GoとGojiの組み合わせでテストを書く - Qiita
- Go言語 http POSTする際のContent-Type - Qiita
- go - Compile App Engine application in Travis - Stack Overflow
- Go – Coveralls
- Golang: gometalinter でソースコードを静的解析しまくる - CUBE SUGAR CONTAINER
関連エントリ
書籍
![みんなのGo言語[現場で使える実践テクニック] みんなのGo言語[現場で使える実践テクニック]](http://ecx.images-amazon.com/images/I/51lqgv%2BWyxL._SL160_.jpg)
- 作者: 松木雅幸,mattn,藤原俊一郎,中島大一,牧大輔,鈴木健太
- 出版社/メーカー: 技術評論社
- 発売日: 2016/09/09
- メディア: Kindle版
- この商品を含むブログを見る

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