CIとTest Sizesの話
はじめに
どちらかというとこっちが本編。
前回の記事ではTest Sizesについて紹介したが、今回の記事はその分類が実際の開発にどう役に立っているのかをまとめたいと思う。もちろん用語の統一も大きな意味を持つが、それ以外のことを書いていきたい。 具体的には、CIでテストのパイプラインを組む時にこの分類どおりに組んでいくと綺麗に整理でき、CI全体のスループット向上にも効果がでているという話だ。今回の話は僕たちのチームに特化した内容になるが、1) Test SizesごとにTestの起動コマンドを分ける、 2) Smallから順に実行していき、落ちるべきテストはできるだけ早期に落とす、というポイントはどこにでも使えるものだと思う。
コンテナ技術とテスト
僕たちはローカルの開発環境だけではなく、本番環境やCI環境でコンテナ技術(主にDocker)を積極的に導入している。
コンテナ技術のメリットの一つに環境の再現性がある。(理論的には)ローカル環境・CI環境・本番環境かかわらずどこでも同じ環境を簡単に再現することができる。CIで自動テストを回している人なら一度は遭遇したことがあると思うが、世の中にはローカルで回したら問題ないのに、CI環境に上げたら落ちるようなテストがある。そのような環境依存で落ちるテストを調査するのは非常に辛く面白くない作業だが、コンテナの上でテストが回っていれば、CI環境で使っているコンテナImageをそのままローカルにPullしてきて、落ちているときの環境を再現しながら調査することができる。コンテナ以前に比べると圧倒的にこの手の環境依存の調査のコストが大幅に削減されていると感じている。
また、CIで回すテストの難しさに、DBなどのTestで使うMiddlewareをどう用意するかといった問題がある。 そういったときは docker-compose
を使えばテストに必要なmiddlewareを個別に立ち上げられることができ、アプリケーションのコードに特に変更を加えなくて良いのも大きなメリットである。
CircleCI2.0やWerckerなど、多くのモダンなCIはデフォルトでコンテナベースになっており、CLIと組み合わせて、手元でも容易に環境が再現できるようになっている。
CIのテストパイプライン
実装
上記のようなメリットを享受するため、僕たちはCI環境のテストのパイプラインを以下のように設計していた。ちなみにCIはコンテナベースでのパイプラインを容易に組める ConcourseCI を使っている。
GithubからのPull Request(PR)が来たらCIがキックされ、sourceをpullしてきて、imageをbuildし、コンテナレジストリであるECRにpush。そしてそのimageとテストに必要なmiddlewareを docker-compose
で立ち上げてテストを実行するという流れである。テストに使うimageを一度ECRにpushするので、もしテストが落ちた時には手元にテストに使った一式のイメージをpullしてきて、環境を再現することができる。
スループットに関する問題
基本的にはこの構成で問題はなかったが、PRのたびにDockerの build
やimageの push
が走り、IO負荷などが原因で、テストを一通り実行するまでに30分以上かかることがあった。PRのたびに回るテストは10分以内に回りきるのが望ましいとされており、この状態が開発チームのストレスになってしまっていた。もちろんDockerfileの書き方の問題や、テストの並列実行数、CIのインスタンスのパワーの問題などもあるが、ECRのレスポンスが極端に悪化するケースがあるなど、既存の構成のままでの対策に限界があった。そこでボトルネックとなっているimageの build
/ push
を極力削ることでスループットの改善を試みた。
Test Sizesに従ったテストパイプライン
CIで回すテストが失敗する要因は様々だが、環境要因で落ちることは実は稀で、大半は lint
や、簡単なテストケースの修正漏れであり、この中にはいちいち docker-compose
でmiddlewareを立ち上げなくても検知できるものが多い。そのため、 docker-compose
するまでもなく落ちることがわかっているようなimageの余分なbuildなどを省ければ、全体のCIのスループットが上げられると考えた。
具体的には、 lint
や Small
のテストは外界に依存が存在しなく、 docker-compose
で依存middlewareを立ち上げなくても実行可能なので、docker buildする際に実行してしまい、そのbuildが通ったもののみ docker-compose
を立ち上げて Medium
以降のテストを実行するというパイプラインにすれば良さそうである。イメージとしては以下の通りである。
実装としては
- Testを
Small
/Medium
/Large
別々に起動できるようにする - Dockerifleのbuildのstep中に
Small
のテストを起動できるようにする
というシンプルなステップで可能になる。Small
, Medium
のテストを別々に起動するためには、testing frameworkにより様々な実現方法はあるだろうが、例えばgoだったらテストを
// Medium func TestXXX_Medium(t *testing.T) { ..... } // Small func TestXXX(t *testing.T) { ..... }
のように宣言して、名前で実行しわける方法がある。jsで jest
などを使っていれば、directoryを分ける方法もあると思う。
このように、落ちることがわかっているテストのパイプラインを早期に落としてしまうことで、ボトルネックを解消でき、CIのスループットを改善することができた。
コンテナとTest Sizesのまとめ
遠回りになってしまったが、ここで伝えたいのは、Test Sizesごとに別々にテストを起動できるようにしておくと、CIのテストのパイプラインを設計する際に便利だし、CIのスループットを上げる際においても有利に働くということである。
Small
のテストはdocker buildと同じタイミングで、 Medium
のテストは docker-compose
でというようにきれいに整理できるため、今後はこの形でCIを整備していきたい。
Large
のテストはテストの記述自体がそもそも難しいが、同様にコマンドを分けて実行できるようにしておくことで容易にパイプラインが組めるようになると思う。
ちなみに、その思想で作られた@orisanoの gobase というGoのプロジェクトテンプレートもある。 Dockerfile
や Makefile
の書き方の参考になると思う。
今回のポイントとしては、
- Test Sizesごとにテストの起動コマンドをわける
- テストを実行する際に、準備と実行のコストが低いSmallから順に実行していき、失敗したら即座にパイプラインを中止する
という2点にある。
まとめ
単体テスト・結合テストという呼び方はやめて、 Test Sizesによる分類を進め、その分類どおりにCIのテストパイプラインを組んだ。
Small / Medium / Largeのそれぞれテストを別々のコマンドで分けて起動できるようにしておき、 Small
は docker build
の際に、 Medium
は docker-compose
で、 Large
以降はテスト環境に実際にデプロイしてから、とテストの実行のフェイズを分けておくと、余分なImageのbuildを削減できるようになった。