おそらくはそれさえも平凡な日々

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)$' とすれば、TestAAATestBBBだけを動かすことができるわけです。

これを組み合わせてテストケースのサブセット分割を実現しています。

パッケージが複数指定された場合にはもう少し工夫しています。と言ってもそっちも簡単な仕組みなので、興味があればソースコードを読んでみてください。

パッケージやテストファイルでの分割ではだめなのか?

CircleCIのドキュメントでは、*_test.goファイル毎や、パッケージ毎の分割が案内されていますが、これはあまり嬉しくありません。以下のような理由です。

  • *_test.go ファイルを直接指定して go test を動かすと、他のファイルのinitTestMainが動かないなど、意図しない挙動になる
  • パッケージでの分割でも、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、簡単に導入できてかなり便利なので、ぜひご利用ください。

created at
last modified at

2020-10-23T11:52:15+0900

comments powered by Disqus