GoのテストをCIで簡単に並列実行する
https://github.com/Songmu/gotesplit
gotesplit
というかなり便利なツールを書いた。Goのテストをいい感じのサブセットに分割して、それを実行するものです。このアプローチで、社内のテストを15分から3分くらいまでに短縮しました。
これを使えばCI環境での高速なテストの並列実行を簡単に実現できます。
実例
CircleCIやGitHub Actions上で簡単に導入できます。
CircleCIの場合
parallelism: 5
docker:
- image: golang:1.15.3
steps:
- checkout
- run:
command: |
curl -sfL raw.githubusercontent.com/Songmu/gotesplit/main/install.sh | sh -s
bin/gotesplit ./... -- -v
job設定でparallelismを指定し、gotesplitをインストールして、go test
の代わりに、gotesplitを使ってテストを実行するだけです。これでこの場合5分割されたテストが5並列で走ります。
GitHub Actionsの場合
name: CI
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
parallelism: [5]
index: [0,1,2,3,4]
steps:
- uses: actions/setup-go@v2
- uses: actions/checkout@v2
- name: Run tests parallelly
run: |
curl -sfL raw.githubusercontent.com/Songmu/gotesplit/main/install.sh | sh -s
bin/gotesplit -total ${{ matrix.parallelism }} -index ${{ matrix.index }} ./...
少し工夫が必要ですが、GitHub Actionsのmatrixの機能を使い、それをgotesplit
の引数に渡すことでテストの分割を実現しています。これも5並列で走ります。
この方法は GitHub Actionsで並列テストをするを参考にさせてもらいました。
インストール方法
好き嫌いあると思いますがCI環境への簡単な導入のためcurl
でのインストーラーを用意しました。
curl -sfL https://raw.githubusercontent.com/Songmu/gotesplit/main/install.sh | sh -s
また、go get github.com/Songmu/gotesplit/cmd/gotesplit
でもインストール可能です。
解説
gotesplit
は以下の引数体系をとります。
% gotesplit [options] [pkgs...] [-- go-test-arguments...]
gotesplit
のオプションの後に、テスト対象のパッケージリストを渡します。go test
に渡したいオプションがある場合は --
の後に書き足す事ができます(省略可)。例えば -v
や -short
など。例えば以下のような具合です。
% gotesplit -total=5 -index=0 ./...
これは、テストを5個に分割した中で、その0番目(最初)のサブセットを実行します。このindexに0,1,2,3,4を指定することでサブセットの位置を指定します。これらを並列に動かせばテストを網羅できるという仕組みです。
テスト分割ロジック
やっていることは単純で、テストケース関数(Test.*
やExample.*
)の一覧を分割しているだけです。
テストケースの一覧はgo test -list . $pkg
とすることで取得できます。
また、go test
のオプションに -run
というものがありますが、正規表現にマッチしたテストケースに絞って動作させられます。つまり go test -run '^(?:TestAAA|TestBBB)$'
とすれば、TestAAA
とTestBBB
だけを動かすことができるわけです。
これを組み合わせてテストケースのサブセット分割を実現しています。
パッケージが複数指定された場合にはもう少し工夫しています。と言ってもそっちも簡単な仕組みなので、興味があればソースコードを読んでみてください。
パッケージやテストファイルでの分割ではだめなのか?
CircleCIのドキュメントでは、*_test.go
ファイル毎や、パッケージ毎の分割が案内されていますが、これはあまり嬉しくありません。以下のような理由です。
*_test.go
ファイルを直接指定してgo test
を動かすと、他のファイルのinit
やTestMain
が動かないなど、意図しない挙動になる- パッケージでの分割でも、Goのパッケージはそれほど細かく多く切ることはなく、粒度もまちまちであるため、大きいパッケージにテスト時間が引っ張られてしまう
なので、大きいパッケージに関してはそれをいくつかに分割したいこともあるのでこのようなアプローチを取るようにしました。
CircleCIの場合に-total
や-index
引数が不要なのはなぜ?
CircleCI上でparallel実行した場合、CIRCLE_NODE_TOTAL
及びCIRCLE_NODE_INDEX
環境変数というものが設定されますが、gotesplit
はそれをよしなに読んで、-total
と-index
に割り当てるためオプション指定が不要になっています。
ref. https://circleci.com/docs/2.0/env-vars/
想定問答や今後の展望など
t.Parallel
使えば良いんじゃないの?
t.Parallel
をちゃんと活用できていない場合にもすぐに導入できる点がメリットです。複数のマシンパワーを簡単に活用できる点もメリットです。
もちろん、t.Parallel
と併用できれば、なお良いと思います。ただ、t.Parallel
しづらいテストもあります。例えば、DBなどのコンテナも同時に立ち上げてテストしている場合は、環境毎分けてしまったほうが楽なこともあるでしょう。
実際このツールは、既存のt.Parallel
に対応できていないテストケースを分割したいというユースケースのために作られました。
テストカバレッジを取りたいのですが
テストを勝手に複数に分割してしまうため、単体ではカバレッジを出すことは出来ません。複数のcover.outをいい感じにマージするツールなどを別途利用すれば取れるんじゃないかと思います。
ただ、さっさとテストを通してバンバン本番反映する環境で使うことを想定しているため、カバレッジなどの細かい計測はNightlyで回すなどしても良いのではないでしょうか。
テスト分割の効率化
現状、テストの分割は単純な名前順です。複数パッケージある場合は、テスト数が少ないパッケージからリストして、パッケージ内のテストケースは名前順で並べています。
これをテストケースの実行時間を加味してより良い感じに分割できると面白いと思っていますが、あまりアイデアは無いのでpatches welcomeです。
(余談)正規表現の生成とRegexp::Assemble
runオプションに渡す正規表現の生成は"^(?:" + strings.Join(list, "|") + ")$"
と素朴にやっています。こういう正規表現の生成はPerl界の人間であれば、Regexp::Assembleが思いつくでしょう。
本当はこのツールのアイデアが思いついたときには、GoのRegexp::Assemble実装も作ってやろうと思っていたのですが、難しくて断念しました…。このツールのアイデア自体は数年前にあったのですが、Regexp::Assembleがネックになって作れておらず、結局その実装を諦めた結果このツールを世に送り出すことができました。
Regexp::AssembleのGo移植は誰かやってほしい…!
これとかが神資料です。→ Regexp::Assemble for PHP
まとめ
ということで、gotesplit
、簡単に導入できてかなり便利なので、ぜひご利用ください。