End to EndでテストができるテストケースをKotlinで書いている

川田裕貴氏:前半はMessaging APIの話をしてきましたが、後半はちょっと話を変えます。Messaging APIの中ではテストをいろいろ動かしているのですが、テストケースも全部Kotlinで書いています。普通のユニットテストではなく、End to End Testみたいなことをやっているので、そこの話をしていこうかなと思います。

Messaging APIのテスト環境ですが、マイクロサービスコンポーネントになっていると、大規模な変更をしたい時にいろいろなコンポーネントに変更がまたがります。そういう時にリスクを最小限にリリースしたいとなると、どうしてもサービス単体のテストでは壊れることがあるし、複合的な要因できちんと動いているのかテストするのが難しいことがあります。

そういう時、普通のWebサービスであればQAやテスターがアプリをポチポチしてやると思いますが、私たちのサービスはAPIが機能のメインなので、APIをテスターに触らせてテストをしにくい部分があります。

なので、私たちがきちんとコードでEnd to End Testみたいなことを書いて、Messaging APIの入口からtalk-serverの出口だったり、その逆だったり、そういうところを全部網羅的にテストできるテストケースがほしいなと思って、こういうものを準備しています。そこをKotlinで書いているよという話をしていきたいと思います。

API プラットフォームに求められる3つのテスト

どういうテストをしなければいけないのか。1つ目は、Bot開発者側から見たテスト。Messaging APIを叩いてあるリクエストを投げると、きちんとユーザーにメッセージが届くし、このリクエストパターンできちんと決まったものが毎回同じように届く。

そのレスポンスもありますが、getのレスポンスや、Messaging APIを送った時のレスポンスなどに変更がないか。コンポーネントをまたがって大きな変更を加えても変更がきちんと前と同じように動いているかをテストしなければなりません。

私たちはMessaging APIによるメッセージ1通でお金をもらっていて、メッセージの内容が変わってしまうと問題にもなります。なので、必ず変わっていないということを保証したいというのがまず1つあります。

ほかには、LINEユーザーからメッセージを送った時ですね。いろいろなバージョンを使って、いろいろなクライアントのLINEユーザーがメッセージを送ってくるのですが、これがきちんと同じようにBotに届くのか、Botは正しく受信できるのか、欠落したりしないのかなど、これをテストしたいというのがあります。私たちがLINEアプリを直接触ることは難しいので、内部APIを利用してLINEユーザーを疑似的に再現してテストをしています。

ほかにも、課金に関わる部分だとか、Botをグループに入れる、入れないだとか、いろいろな設定が内部にはあります。そういう設定が正しく反映されているか、正しく送信数が計上されているかなどもテストしています。

Messaging APIのコンポーネント内部にはなりますが、End to Endでテストをすることによってインテグレーションテストみたいなことができるので、そういうことも間違っていないかテストを行っています。

テストケースは600本以上ある

テストケースはどうなっているのかというと、本当にいろいろなことをテストしなければいけません。機能がいろいろあるというのもそうですが、ユーザーを作って、友だちを追加して、Botを友だち追加した時にメッセージを送ったとか、メッセージが来るとか、そういうチェックをして本当にBotがWebhookを受信できるかとか、友だち追加のWebhookが届くからきちんと受信できているのかとかを確認して、メッセージをユーザーから受け取ったら、Botはリプライトークンを受け取ってそれを返信するとか。そういうテストを全部書いていかなければいけません。

いろいろな機能があります。プッシュ、リプライ、ブロードキャスト、特定のユーザーの属性、住んでいる国、年齢、性別など、そういうものを絞って配信できる機能もあるので、そういうものもテストしなければなりません。

Webhookにも大量のタイプがあります。動画、画像、音声をきちんと受信できるか。リッチメニューも全体に対してセットしたり、個人に対してセットしたりなど、テストケースがたくさんあります。現在のテストケースは600個くらいあって、これらを管理しています。

ベータ、ステージング、それぞれの環境でデプロイ後にテストしている

テストの対象も開発環境だけではありません。実際の環境に近いステージング環境もあって、それぞれの環境でテストをしています。開発環境だけだと本番環境との違いが日々どうしても生まれてきてしまいます。

そこをステージング環境でテストをすれば、ほぼ本番環境と同じものが動いているので、ほぼ同じように動くことが保証できます。私たちはベータとステージングのそれぞれの環境で、デプロイ後に毎回このテストを流しています。

基本機能を隅々まで自動検証して、本番環境に持っていっても大丈夫という状況を自信を持って進められるようにしています。これは2020年から2021年に行ったWebhook刷新時にとても役に立ちました。

テストフレームワークは「kotest」を採用

実際のテストの流れは、先ほども少し説明しましたが、いろいろなことをやっています。ユーザーを作成して友だち登録をしてやるのですが、その中身は基本的に全部非同期なAPIです。普通に書いていけば、レスポンスが返ってきたら友だち追加がされてWebhookが届くだろうと思いますが、友だち追加してWebhookが届くまでにはキューが挟まっているので、実際ちょっとした遅延があります。

友だち追加が返ってきたからといって、それが終わってWebhook送信まで終わっているとは保証できません。そういう時にどういうテストを書けばいいのかというのがけっこう難しいんですね。テストフレームワーク自体の機能もなかなかに必要です。

いろいろなことをやらなければいけないので、とにかく便利なテストをするためのフレームワークが必要でしたが、私たちはこのあたりをKotlinで書くにあたって、kotestというライブラリを使っています。

Kotlinで書かれたテストフレームワークですが、中身はほぼScala TestというScalaで書かれた有名なテストライブラリのコピーです。最近はkotest独自の機能も入っているので、随分変わってはきていますが、これはとても便利で使っています。Scala Testを使って便利だなと思った人は、たぶんkotestを使うと「あ、これだ!」と思うかもしれません(笑)。

kotestの3つの重要機能とJUnitとの比較

ここでは3つ重要な機能を取り上げました。まずSpecというものがあります。JUnitであればTestというアノテーションをメソッドに付けるだけですが、kotestにはSpecというものがあって、いろいろなスタイルが選べます。あとで説明しますが、これによってテストを構造化しやすくなっています。また、テストケースの名前をすごくわかりやすく付けることができます。

ほかには、Assertionですね。Kotlin nativeなテストフレームワークなので、Kotlin専用のAssertionが多くあります。例えばNullableは、JUnitで使うとちょっと違うなとか。Extention functionでAssertionできたらいいのになと思うことがあるのですが、こういうのにもかなり網羅的に対応していて、kotestならAssertionを使わない理由はないくらい本当に便利なものです。

あとはCoroutinesなど非同期の対応ですね。Test methodがすべてkotestの場合、suspend functionになっているので、そのままrunBlockingをしなくてもCoroutinesがそのテストの中で使えます。

もう1つ、AwaytillityというJavaのライブラリがあります。非同期で行われる処理を一定時間に条件を満たすなど、Assertionを書きたいことがあると思います。例えば先ほどの友だち追加であれば、友だち追加のAPIを呼んで、成功して返ってきたら何分以内に必ずWebhookが飛んでくるなどをチェックしたいことがあると思いますが、Awaytillityと同じような機能がkotestの場合には準備されているので、とても使いやすいです。

JUnitを使っている人が多いとは思いますが、JUnitと比べると、テストの管理の面でも、非同期対応の面でも、Assertionの面でもいろいろと苦労することがありますよね。

JUnitのテストを見てもmethod名なので、Test名って何のテストをやっているのかよくわからないなぁとか、そもそもTest名をきちんと付けないとか、そういうことになりがちです。kotestを使えばきちんと書くようになるし、非同期テストもやりやすいし、AssertionもKotlin nativeで書きやすいので、Kotlinを使っているならぜひ使ってほしいなと思っています。

kotestのサンプルコード紹介

これからどんなふうに書けるのかを見ていきたいと思います。これはShouldSpecというものを使っていて、Specの1つです。JUnitとはぜんぜん違うと思います。contextで、まずテストのcontextを決めます。

ここではpushAPIのテストをしたいのでpushAPIのURLを書いています。contextによってこのテストはなにをしているのか、どの機能をテストをしているのかが明確になります。

そのあとにshould、さらに名前としてnot push to unrelated userというのを与えています。下を見てみてもらうと、テストの結果にshould not push to unrelated userというテキストがそのまま表示されます。なにをテストしているかがとてもわかりやすいですね。

私たちのように大量のEnd to End Testを持っていると、少しだけ条件を変えたテストがたくさんあります。これをmethod名だけで管理するのは難しいんですよ。なので、こういうふうにテキストが書けると管理がしやすいです。

また、betaOnlyと書いてありますが、こういうふうに条件をいろいろ付けられて、ベータだけ流すテストやステージングだけで流すテストも設定できます。

あとは中身が完全にsuspend functionなので直接awaitを呼べるし、Kotlinなので拡張関数が使えます。

Assertionも拡張関数が使えます。ここではpushの返り値はretrofit2というRestAPIのためのライブラリで、retrofitのレスポンスが返ってきます。それに対して独自の拡張関数を定義してAssertionしている例です。これだけ見るとけっこうイケてると思ってもらえるかなと思います(笑)。

Specの種類は、FunSpecやStringSpecなどいろいろあります。JUnitからただ移行したいのであれば、AnnotationSpecというSpecを使うとほぼJUnitと同じように書くことができます。

ただし、どれを使ってもkotestの機能は変わりません。これは本当に好みで選んでもらえればいいかと思います。ただせっかくkotestを使うなら、あえてAnnotationSpecを使う意味はないのかなと思ったりもします。

非同期テストのサにおける便利な機能

あとは非同期のテストですね。1分以内にメッセージが受信されるのかをテストしたい時、delayを入れてテストするかもしれませんが、いちいちdelayを入れるとそのdelay内に終わるかどうかもわからないし、十分に満足するdelayを入れるとテストの実行時間がどうしても長くなってしまいます。

kotestだとeventuallyというものが用意されています。これもCoroutinesになっているので、suspend functionの中であればなにも考える必要がなく呼べます。1分以内と指定した場合、この中身がある程度条件を満たすまでグルグル回って、1分以内に条件を満たしたらここから勝手に抜けてくれます。1分以内に満たさないのであれば、「1分経ってもこの条件が満たされませんでした」というAssertionを出してくれます。

逆にcontinuallyというのもあります。例えばここではユーザーをブロックをしている場合とか、ユーザーがなんらかの条件で友だち追加をしてもWebhookが届かないことを保証したいのですが、そういう場合にcontinuallyを使うと、この条件の20秒間、なにかがずっと成功し続けていることを保証できます。このような非同期Assertionが充実しているので、私たちは使っています。とても便利だと思います。

あとはCluesですね。これはkotest特有かなと思いますが、エラーが出た時に何のオブジェクトに対してAssertionをかけてどういう条件だったのか判断するのが難しいことがけっこうありますよね。

下のところにテストの結果を簡単に書きました。200を求めているのに、テストの結果が400でしたとなった時、何のAPIを叩いたんだっけ? どんなエラーレスポンスが返ってきたんだっけ? と、普通のテストではわかりにくいのですが、kotestの場合、Cluesというものを設定できます。

Cluesは、Assertionが失敗した時にそのCluesの内容も一緒にプリントしてくれる機能です。ここでは最初に話したretrofitのレスポンスに対して、ステータスコードをチェック拡張関数のAssertionに独自に定義しています。

まずレスポンスに対してasClue、これはthisに対してasClueを呼んでいるのですが、asClueブロックの中にテストコードを入れるとasClueで設定したものがClueに設定されて、この中でAssertionを行った時にエラーが起きたらその内容を一緒に表示してくれます。

さらにその中でlazyなどいろいろやっているのですが、lazyにしているのはbodyを呼んでしまうと結果が変わってしまうことがあるからです。エラーになって必要な時だけbodyを呼んでほしいのでこういうふうにしています。

あまり気にしないで読んでもらうと、bodyの内容をさらにwithClueでClueに設定して、そのブロックの中で最終的にcode().shouldBeなんとかとか、expectedCodeとかやっています。

このような内容を設定することで、結果の1行目のResponseはretrofit2のレスポンスが表示されるし、2行目はエラーbodyの内容が表示されます。このように便利なメッセージログみたいなものを簡単に設定できるので、複雑なテストだととても便利です。

ほかにもいろいろ便利な機能があります。時間がなくなってきたので全部は紹介できませんが、autoCloseというSpecが終わった時に勝手にcloseメソッドを呼んでくれるヘルパーや、特定の回数、特定の時間だけリトライしてくれるretry機能があります。

あとはJSON matcherですね。こういうものもデフォルトで搭載されています。APIテストをやっている側としてはよく失敗するので、このAPIはリトライをかけて1回でも成功したらまあいいやというテストや、userを使い終わったあとにきちんと忘れずにクローズしたりなど、そういう用途にはkotestの便利な機能がすごく役に立っています。

また、Property based testingもできます。自分でランダムな値を生成しなくても、ここがランダムでこういう値で生成してくださいとやると、その中から自分で生成して何回も回せたり、それが全部条件を満たすことと設定できたり。そのようなテストが簡単に書けます。これはユニットテスト向けですね。

私たちは独自にExtensionを書いて、Allure FrameworkやSentryでテストの結果を監視しています。周りのコンポーネントの影響や、ベータで周りのサーバーになにか変なコードを入れてしまった時に落ちることがありますが、それが本当に私たちの影響なのか、継続的に落ちているのかをチェックするためにはJUnitのレポートだけでは厳しいので、AllureやSentryなど特にSentryのほうは自分たちで独自にExtensionを書いてチェックしています。こういう拡張性があるのもkotestのいいところです。

Messaging APIテストにおける今後の課題

今後の課題はいろいろあります。概ね満足していますが、失敗が多いのと、10分程度で待てる時間ではありますが、実行時間が長めなのを今はどうにかしたいなと思っています。 kotestに対して少し気になる点は、最新版のKotlinをかなり要求してくること。しかも最新の機能はたぶん(バージョン)1.6を使わないと動かなかったりします。

また、Experimentalな機能をかなり使っているので、Experimentalになにか変更が加わってdeprecatedになったものがあると、それを全部書き換えなければいけません。

ほかにも、たまにバグっています。並列テストができるのですが、これがバグっていてけっこう苦労したことがあります。私がプルリクエストを送って直した時もありました。テストの話は以上です。

Messaging API Backend開発チーム内部が発見したSpring Securityの脆弱性

最後に、私たちのチームが見つけた、最近公開されたSpring Securityの脆弱性についてお話しします。

Messaging APIのバックエンドチームが日々監視しているのですが、エラーログなど、なにかおかしいな挙動があって深掘りしたら、「これはヤバくない?」という話になって、VMWareに実際に報告してCVEを発行してもらいました。

実際に公開されて、私は載っていませんが、うちのチームの西野さんの名前がクレジットされています。本当に第一発見者なので、感謝したいと思います。こういうバグもたまに見つかるので、実際に影響はありませんでしたが、Spring Securityを使っている人は気にしてみてください。

Spring Securityを使っている場合、SecurityConfigでどこのpassに対して認証をかけるかを設定すると思います。(スライドを示して)ここではBasic認証を/auth/.+というところにかけています。

antMatcherを使う人も多いと思いますが、ここではregexMatcherを利用して、正規表現でマッチしたpassに対して認証をかけています。「.*」とか「.+」など末尾にドットを使っていると、末尾に改行コードを入れた時に、マッチしなくてそのまま認証が回避されてしまうという問題がありました。実際、これをうまくやるとコントローラーまで届きます。

なので条件に合致しているとけっこう危ないです。ドキュメントなどでregexMatcherは紹介されていないので、使っている人は少ないと思いますが、インパクトがある脆弱性なので、ぜひチェックしてみてください。Springを使っている方は特に気をつけてください。

これで私の発表を終わりにします。ありがとうございました。