asterisc

技術ネタ中心にその他雑多なこと

CIとTest Sizesの話

はじめに

前回 akito0107.hatenablog.com

どちらかというとこっちが本編。

前回の記事では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 を使っている。 f:id:akito0107:20180825172431p:plain

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のスループットが上げられると考えた。 具体的には、 lintSmall のテストは外界に依存が存在しなく、 docker-compose で依存middlewareを立ち上げなくても実行可能なので、docker buildする際に実行してしまい、そのbuildが通ったもののみ docker-compose を立ち上げて Medium 以降のテストを実行するというパイプラインにすれば良さそうである。イメージとしては以下の通りである。

f:id:akito0107:20180825182409p:plain

実装としては

  1. Testを Small / Medium / Large 別々に起動できるようにする
  2. 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のプロジェクトテンプレートもある。 DockerfileMakefile の書き方の参考になると思う。

今回のポイントとしては、

  1. Test Sizesごとにテストの起動コマンドをわける
  2. テストを実行する際に、準備と実行のコストが低いSmallから順に実行していき、失敗したら即座にパイプラインを中止する

という2点にある。

まとめ

単体テスト結合テストという呼び方はやめて、 Test Sizesによる分類を進め、その分類どおりにCIのテストパイプラインを組んだ。 Small / Medium / Largeのそれぞれテストを別々のコマンドで分けて起動できるようにしておき、 Smalldocker build の際に、 Mediumdocker-compose で、 Large 以降はテスト環境に実際にデプロイしてから、とテストの実行のフェイズを分けておくと、余分なImageのbuildを削減できるようになった。