テストのための技術選定
最近、自分で0からAPIを作ることがありました。その際に、ちゃんとテストを書けるようにしよう。と思い、自分の知識をかき集めて、なんとかある程度の型を作ることができました。しかし、なぜこんなに苦労したのだろう?と思いかえしてみると、こういったテストをきちんとするための技術選択は、技術の選択の仕方、ライブラリの子細によって、かなりブレる印象がありました。そして、そういった中で抑えるべきポイントというものが、なんとなくふわっとしているため、何をどう選択するのが正しいのかわからない。という事態に陥りました。そのため、テストにおける技術選択において、何を抑えるべきなのか。何ができていればOKなのか。それを言語化して備忘録として書きました。
テストとアーキテクチャ戦略
ユニットテストの定義と価値とリスクという記事でも紹介しましたが、「実践テスト駆動開発」で定義されているテストの定義です。
分類 | 説明 |
---|---|
受け入れテスト | システム全体が機能するか? |
インテグレーションテスト | 私たちが変更できないコードに対して,書いたコードが機能するか? |
ユニットテスト | オブジェクトは正しく振舞っているか?また,オブジェクトは扱いやすいか? |
ここで簡単なCRUDのREST APIを例に挙げます。ここでは、以下のようなシーケンス図として表されるアーキテクチャとして作るものとします。
Controllerはフレームワークに密接した層で、HTTPリクエストからparamやheaderをとりだしたり、HTTPのレスポンスを返します。Applicationはいわゆるビジネスロジックと呼ばれる層で、業務ドメインの処理を行います。そして、Repositoryは、DataBaseと連携し、データを取得したり、書き込んだりする層とします。
こういった構成を組んだ場合、私は以下のような区分でテストを構築しています。
ユニットテスト(UnitTest)は、Applicationのビジネスロジックの検証を行い、Repository層との連携部分はMockで代用します。インテグレーションテスト(IntegrationTest)は、実際にDataBaseへの連携を行い、データが永続化され、取り出せるかを検証します。最後に、受入れテスト(Acceptanceテスト)は、すべての層をつなげた場合の動作をテストを検証します。HTTPのリクエストを送り、ルーティングされ、パラメーターをもとに、データベースからデータを取り出し、処理され、HTTPのレスポンスとして返ってくるまでを確認します。
ユニットテストの難しさ
言語 | おすすめテストフレームワーク | おすすめモックライブラリ |
---|---|---|
Java | JUnit | Mockito |
Go | testing | gomock |
Python | pytest | pytest(MagicMock) |
PHP | PHPUnit | PHPUnit |
TypeScript | jest | jest |
テストフレームワークの選定の難しさ
テストフレームワークの選定って難しい?と思われるかもしれません。"言語に依存する"ところが多いです。例えば、PHPであれば、PHPUnit。JavaであればJUnitあたりがベターな選択肢になると思います。goもgo testなどが標準ライブラリで提供されているので、そこまでめんどくさくないです。
めんどくさいタイプなのがPythonやJavaScriptです。Pythonは標準でdoctestや、unittestがあります。標準外でも、pytestや、nose2などがあったりします。どう選べばいいのか。最近、agwuiというpython製のライブラリを作ったときに困りました。その時には、OSSを参考に選定しました。その時に、見たのはflaskで、flaskがpytestを採用していたので、信頼がおけるだろう。と思ったので、pytestの導入を決定しました。実際、書いてみたら必要十分な感じでした。
JavaScriptの技術選定が激しくめんどくさいです。フロントエンドのJavaScript界隈はあまりにもホットで3か月ぐらい経つと、それ流行遅れじゃない?みたいな感じがあって、適当に追っている分には個人的には辛い界隈です。そんなわけで、「テスト javascript」を調べても、不正確な情報や、古かったりするせいで、割と検索に難があります。外から見ている分には何が古くて、何が新しいのか微妙によくわからないことがあります。例えば、少し思い出すだけでも、Karma,Istanbul,Chai,Mocha,Jasmine,Jest,Selenium,Cypressといっぱいある。あと、だんだんテストライブラリか、アサーションのライブラリか、はたまたE2E特化か、一見するとわからなかったり、一度に勉強しようとすると脳内メモリがあふれます。というわけで、昔にまとめたのが以下の記事になります。
TypeScriptでREST APIをテスト駆動するためのHowTo
正直、今、上の記事のコードが動く気がしないのだけれども、このように激しく動く分野は自分で動かしてみて、得手不得手を見極めながらやり続けるしかないのかなーというのが所感。個人的には、jest推し。理由は4つ。モックが標準でついている。カバレッジが見やすい。CLIの出力結果が色付きで見やすい。Facebookが作ってる。開発元が大きいのは安定性の面で結構重要視してます。
モックライブラリの選定の難しさ
動的言語にMockライブラリはいらない。とは私は思うものの、最近は、ちょっと旗色が変わってきたな。と感じます。PHPも7以降は型を付けられるようになり、Pythonも型ヒントを付けられるようになってきました。そういうわけで、雑なダックタイピングもしにくくなってきたために、結構、動的言語にもモックライブラリが必要になってきそうだな。と思っています。
静的言語の経験でいうと、JavaはMockitoがいい感じでした。しかし、純粋なMockitoです。これは好き嫌いの問題かもしれませんが、Spring Boot付属のMockitoは非常に辛い。@MockBean
アノテーションは本当に嫌い。コンパイル時にエラーを吐かなかったり、そもそもSpringBootがE2Eのテストを要求してきたりするために、基本DIコンテナが上がるような作りになっているので、テストが遅い。慣れないと死ぬほど開発サイクルを回すのが大変。したがって、回し始めるのがすごく大変なイメージ。
Golangはgomockがベター。testifyもあるのですが、gomockのほうが"マシ"です。どういうことかというと、testifyはモックを記述するために、"モックを作るためのクラス"を定義する必要があり、これが非常にめんどくさい。したがって、小規模で組んでいるうちはいいんですが、テストケースを量産していくフェーズになると、gomockで作る方が簡単になってきます。ただgomockのほうにもクセがあって、interfaceからmockクラスのソースコードを自動生成する必要があります。そういった生成系を使うと、CIや開発環境の構築にひと手間必要で、私はめんどくさかったりします。
インテグレーションテストの難しさ
ここでインテグレーションテストとは、"RepositoryパターンにおけるDataBaseと連携したテスト"という文脈でお話しします。そうした場合、ポイントとなってくるのは、
- 冪等性の確保
- 本番環境との差異
です。"冪等性"とは何度テストを行っても同じ結果になる性質です。インテグレーションテストを行う際には、この冪等性の確保が重要になってきます。もうひとつは「本番環境との差異」です。本番環境と同じ環境を開発環境で作ることは難しいです。これは、簡単なシステムであればよいのですが、「RAID0+1のストレージで構成されたMySQL」とか「北海道と関東の2拠点で地理的分散を図られたCassandra」など、そういうものが出てきた場合、きわめて難しいものとなります。ほかにも、Amazon Auroraなど、ベンダロックインされ、ローカル環境で動かないようなDBを使うのもテストを難しくする原因の1つになります。
手法 | 冪等性の確保 | 本番環境との差異 |
---|---|---|
軽量DBによるテスト | 易しい | 大きい |
Dockerを用いたテスト | 普通 | 少ない |
共有DBのテスト | 難しい | 少ない |
軽量DBによるテスト
「軽量DB」という言葉は正しくないかもしれませんが、ちょこちょこと使われているので、この用語を使います。いわゆる「SQLite」や「H2」と呼ばれるデータベースです。どのあたりが軽量か。というとPostgreSQLやMySQLはインストールした後、それぞれでデーモンを起動する必要があります。そのようなデーモンなしに、プログラム側からライブラリとしてインストールすることができて、簡単に呼び出せるDBです。例えば、SQLiteはファイルベースのデータベースでPythonに標準ライブラリとして入っていたり、Androidでも標準で使えます。H2は、Javaで使われるデータベースです。これらの軽量DBは、簡単に使い捨てすることが可能で、DBをメモリ内にのみ立てる。といったことができます。この特徴が非常に楽で、DBの立ち上げ、終了に時間がかからず、ゴミデータをテスト環境に残さないので、すごく扱いやすいです。しかし、問題があり、それは本番環境との差異が大きいことです。このような軽量DBは本番環境で使わず、先の例にあげたMySQLやPostgreSQL,Oracleなどでサービスすることが多いです。そうした場合、「テストになっていない」といった議論があります。
例えば、SQLAlchemyというPython製のORMを使ったとします。本番環境では、MySQL。テスト環境ではSQLiteを利用したとします。そして、データの永続化層としてRepositoryを作り、インテグレーションテストを書いたとします。これに何が問題があるか?というと、テスト環境で検証しているのは、「RepositoryのクラスがSQLAlchemyを通してSQLiteにデータを永続化したこと」を確認しています。MySQLに対してデータが永続化されたことは確認していません。当たり前のことを言っているようですが、少し視点が異なっています。SQLAlchemyを信用するか否かという点です。
Repositoryのソースコードを自前で書いた。ということは、「自分が書いたデータの永続化のロジック」にバグがある可能性があります。SQLiteを用いたテストでは、その部分に関しては十分検証したといえます。そのため、MySQLでも動くだろう。というのが1つの解釈です。しかし、SQLiteからMySQLへ変更した場合、SQLAlchemyでうまく永続化できないかもしれない。と考えるのも1つの視点としてあります。そこまで・・・と思うかもしれませんが、ミッションクリティカルなシステムになり、落とせないシステムになるほど、そのあたりの技術選定にはセンシティブになります。この辺りは、構築するシステムの信頼性などに依存してテスト戦略を考える必要があります。
少し、話がそれますが、逆パターンでバグが起こることは体感としてよくあります。例えば、本番環境でMySQLにつないだシステムがある。しかし、テストがない。そこで、テストを書くために、ローカルでは簡易的にSQLiteで代用して動かそうと思ったときに、ORMが適当な実装しかなくて、バグってテストが通らない。ということは、割とある印象です。
Dockerを用いたテスト(DinD,DooD)
私としては一番ベターな選択肢が、Dockerを用いたテストです。MySQLや
PostgreSQLに関していえば、Dockerイメージが公開されており、結構細かくバージョニングをされているので、本番環境との差異も少なく、構成もしやすいです。そのため、「テストになってない」などの批判も緩和されます。また、最近だとdocker-composeなどで複数のdockerコンテナを立ち上げるのも簡単になってきているので、開発環境を立ち上げるのも簡単です。
しかし、銀の銃弾か?と呼ばれると、ちょっと難しいところもあります。それは"CIでの実現性"です。これにはDinDやDooDと呼ばれるパターンがあり、テストコンテナとそれ以外をどう構成するかといった問題があります(Dockerコンテナ内からDockerを使うことについて)。個人的には、DooDでよいと思うのですが、次の課題として資料が少ない。ということがあります。Qiitaに上がっているCircleCIやGithubActionsなどの記事は入門編が多く、簡単なユニットテストを行う場合が多いです。そのため、このような少し込み入ったことをするテストに関しては、かなり情報が少ないです。そのため、CIのプラットフォームの機能を駆使し、ドキュメントを読み込み、自前でノウハウを構築する必要があるため、安定した継続的に動くCIの環境構築に時間がかかります。
共有DBのテスト
ミッションクリティカルなシステムを作っており、性能要件が厳しい場合、開発時に本番と似た構成のDBを用意する場合があります。しかし、本番と似たような構成をする。といった場合、極論としてはサーバーのハードウェアや、そこに至るネットワークスイッチや物理配置まで合わせる必要があるので、おいそれと個人に1つの独立した環境を渡せないこともあります。そういった場合、チームで1台のDBを共有してのテストとなります。これは、本番環境との差分は非常に少ないため、本番でバグが顕在化する。といったことは少ないです。しかし、インテグレーションテストは1人ずつしかできないですし、データベースを壊すと、手動で復旧の必要性がある。など、非常に開発効率が落ちます。そして、開発時は不正なクエリを発行してしまうことも多いので、良く壊す・・・といった負のループを構成するケースが多いです。
受け入れテストの難しさ
受け入れテスト。E2Eテストなどと言われることもあります。このテストにおいて
- フレームワークのテストのドキュメントがあること
- モックが差し込めること
この2つが重要になってきます。
フレームワークのテストのドキュメントがあること
node.jsのフレームワークであるexpress.jsはテストのドキュメントがありません。では、expressはテストがないのか?というとそういうわけではなく、リポジトリを見に行くと、istanbul,mocha,should,supertestあたりのライブラリを使っています。有名なライブラリではありますが、そのテスト技法については書かれていなくて、ノウハウを自分で言語化する必要があります。
他の話ですと、ドキュメントがあるのですが、ちょっと違和感を感じたのがgo-swaggerでした。ざっと説明すると、「ビジネスロジックとハンドラーを丁寧に分割することで、ビジネスロジック部分をテストできるように作ることを勧めるよ。」という感じです。この方針自体は納得いくものではあるのですが、フレームワークの説明としてはちょっとズレているという感想でした。今、自分がやりたいテストは「受け入れテスト」であり、そのためには、どうしてもフレームワークと結合したテストを書く必要がある。だから、「go-swaggerでのプラクティスが知りたい。」というのが視点にあるにも関わらず、「テストできるように疎結合にしましょう」みたいなことを言われても、答えが違う。という話です。まぁ、go-swaggerの場合は、受入テストの方法も書かれてはいるので、悪くはないのですが・・・例えば、この話を真に受けてテストを書いてしまうと、/usersというエントリポイントのテストを書いて、バグがないことを確認する。ということはできます。しかし、受入テストがないので、/usersにリクエストを送ったときに、「先ほどのロジックにルーティングされること」は保証できない。といった問題が生まれます。
また、JavaでAPIを作る。となると、SpringBootが選択に上がってくると思います。例えば、TERASOLUNAのドキュメントなどは非常に網羅性が高く、素晴らしいものがあります。ただ「わからない」という問題があります。Springは真面目に使うと、「DIコンテナー」「疎結合」「レイヤー化」「テスト戦略」を考えていないと、そもそも「テストができない」という問題に直面します。しかし、初心者ユーザーが知りたいのは「テストの方法」に過ぎず、知識を持たず読むと、「なぜレイヤーという概念が出てくるんだ?」「どうしてテストの方法がこんなに複数あるんだ?」と、わからないことが多発する事態になります。それでも、テストを書く!という風に進めてしまうと、間違ったレイヤーの切り方で、不適切な方法で無理やりテストを書いてしまうことがあります。こういうことをするとフレームワークと密結合した壊れやすい不完全なテストが生まれやすいです。Springは真面目に丁寧に使おうとすると、かなり設計やアーキテクチャ、そもそものSpringの設計思想に関する知見を必要とされる印象があります。そもそもが、かなりハイコンテキストなものだと思います。
先ほど、設計思想。という言葉が出ました。大体、どんなフレームワークも似たようなもので、同じようなものだろう。と思われると思います。ただ、この設計思想を知っているか知っていないかで実装する戦略が変わってきます。以前に書いた、バリデーションの実装はどこにすべきか?という記事があります。端的に言ってしまえば、バリデーションをControllerに書く派閥(Spring,Laravel)とModel側に書く派閥(Django,RoR)といったものがあります。これらには割と深い溝があり、そもそもモジュールをどう分けるか、どういう順番でテストをするかも変わってきます。それに起因して、テストの方法論も違います。そのため、思想レベルで理解を行い、それをどういったプロダクトに利用するかも考える必要があります。
これは総じて言えることなのですが、ドキュメントが足りないです。RoRで作ってみました。Flaskで作ってみました。このようなフレームワークに入門しました。これだけ読めば、Webアプリが動かせます。みたいなドキュメントは多いです。しかし、フレームワークに依存したテストの知見というのは本当に少ないです。こういった知見が手軽に検索して見れるものがないと、そもそもプロダクトにE2Eを入れたい。という気持ちにならなかったり、英語がしんどいし・・・となってしまって、受入テストの導入ハードルが高くなる傾向にあると思います。逆に言ってしまえば、受入テストを導入するということは、そういった茨の道を通ることを覚悟した方がよい。ということです。
モックが差し込めること
受け入れテストになぜモックが差し込める必要があるのか?という疑問があると思います。例えば、DBへの接続が失敗したとき、APIレベルでは500エラーを返す必要があります。そういったとき、テストを実行している任意のタイミングでDBを落とす。といったことが必要になります。しかし、それは非常に難しい処理になります。そのため、そういったテストをする際にはモックが差し込めるか否かが1つキーポイントとなってきます。また、理由はもう1つあり、それは「テストのため」です。モックはテストのためにあるので、当たり前のようですが、少し違います。例えば、メールの送信にAmazon SESを使いたい。という要求があるとします。しかし、Amazon SESを常時つないでおくと、テストのたびにメールが送信されてしまいます。そういった場合、E2Eテストだけれども、メールの部分はダミーに置き換えておきたい。という要望が生まれます。そのような、「ある機能を置き換えたい。」といった用途にモックが有効になってきます。
しかし、「モックを差し込むこと」には2つのハードルがあり、「そもそもモックが差し込めるような疎結合なアーキテクチャを設計できるか」「フレームワークがモックが差し込めるように作られているか」という2点です。前者に関していえば、プログラマの技量です。どこまでソフトウェアのその処理フローを理解できており、テスタブルなものが作れるのか。というところになります。これは努力次第でなんとかなります。後者に関しては、先の例に挙げたgo-swaggerに関していえば、モックの差し込みが非常に難しいタイプのフレームワークでした。そもそもgo-swaggerは、アプリケーションの立ち上げ、ルーティングなどは、swaggerの定義ファイルから自動で生成されます。そして、その部分は手動では変更しない。ということが鉄則になっています。そのおかげで、触れるコードの部分が少なく、良いポイントでもあるのですが、個人的にはテストで動かすときと本番で動かすときのコードを切り替えが行いにくかったです。そのため、モックがかなり差し込みにくく、go-swaggerによってブラックボックス化されている部分を把握しながら、本番に影響を与えないように組む必要があったので、少し大変でした。
特に最近の傾向で顕著ですが、プログラムの起動を意識しにくくなってきた。ということがあります。「ライブラリ」と「フレームワーク」は何が違うのかという文章にあるように、自分のプログラムが「フレームワーク」により呼び出される形が多くなってきました。そのため、アプリを書く際に必要となるフレームワークの起動処理はチュートリアルの定型で十分で、むしろハンドラー側をゴリゴリ書く方が多かったりします。しかし、テストを書く場合は、そうもいかず、ある程度、起動の部分の処理フローであったり、処理のフックのタイミング、方法を知っている必要があり、実はその部分のドキュメントを調べるのが大変だったりします。
感想
最初に「技術の選択の仕方、ライブラリの子細によって、かなりブレる印象」と書きましたが、本当にこの通りで苦労しました。ライブラリを選定するだけでもそもそも大変ですが、テストになると、また次元の話が加わってきて難しいです。教科書として、「ユニットテスト」「結合テスト」「受入テスト」という区分は非常に有用ではありますが、それを実際のコードに落とし込むまではいろいろな問題があります。その実装に至るまでの障害点を明らかにすることを試みました。この辺の内容がきれいにまとまってるドキュメントってどこかにあるのかな・・・