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
関連エントリ
書籍
- 作者: 松木雅幸,mattn,藤原俊一郎,中島大一,牧大輔,鈴木健太
- 出版社/メーカー: 技術評論社
- 発売日: 2016/09/09
- メディア: Kindle版
- この商品を含むブログを見る
システムテスト自動化 標準ガイド (CodeZine BOOKS)
- 作者: Mark Fewster,Dorothy Graham,テスト自動化研究会,伊藤望,玉川紘子,長谷川孝二,きょん,鈴木一裕,太田健一郎,森龍二,近江久美子,永田敦,吉村好廣,板垣真太郎,浦山さつき,井芹洋輝,松木晋祐,長田学,早川隆治
- 出版社/メーカー: 翔泳社
- 発売日: 2014/12/16
- メディア: 単行本(ソフトカバー)
- この商品を含むブログ (3件) を見る