Django with unittest で CircleCI の Splitting by Timing Data を使う


概要

ルー大柴みたいなタイトルになってしまいましたが、Django で unittest を使用する場合に、 CircleCI で Splitting by Timing Data(過去のテストの実行時間を基にテストスイートを分割して並列実行)を行う方法について書いていきます。

ちなみに Django の unittest で実現するのは結構大変なので、unittest の資産がなくてこれから新しく Django のプロジェクトを始めていく方は落ち着いてdjango-noseを使いましょう。
この記事は unittest から逃れられない人の為の記事です。

CirleCI も

Django は、django-nose テストランナーを使用して設定する必要があります。

と言っています。
(ref: https://circleci.com/docs/ja/2.0/collect-test-data/#%E3%83%95%E3%82%A9%E3%83%BC%E3%83%9E%E3%83%83%E3%82%BF%E3%81%AE%E6%9C%89%E5%8A%B9%E5%8C%96)

そもそも Splitting by Timing Data とは

Circle CI にはテストを並列実行する仕組みがありますが、テストの分割方法にはいくつかの種類があります。

  1. (前回の)実行時間を基に分割する
  2. テストのファイルサイズを基に分割する
  3. テストファイルで分割する

上から順に最適な分割方法になっていて、もちろん実際のテスト実行時間を基にして分割すれば(理論的には)どのコンテナでも同じような時間に終わるようにテストを分割できるわけです。
例えば3の方法だと、あるプロジェクトではこのぐらいのばらつきが発生してしまいました。

うまく平均すれば6分前後でJobが終了するはずですが、テストの分割方法が悪くて8分かかってしまっています。

これを実行時間を基に分割することでここまで平均化することができるのです。

Splitting by Timing Data 最高でしょ?

Django で Splitting by Timing Data をするには

結論

やらないといけないことが多いのですが、書いていきます。
再度言いますが unittest の資産がなくて、これから Django のプロジェクトを始める人は落ち着いて django-nose を使いましょう。

$ pip install unittest-xml-reporting
.circleci/config.yml
- run:
    name: Generate test target file
    command: |
        ./.circleci/get_all_test_class.sh > split_test_modules.txt
- run:
    name: test
    command: |
        circleci tests split --split-by=timings --timings-type=classname < split_test_modules.txt | \
        xargs ./manage.py test
- store_test_results:
    path: unittest-results
.circleci/get_all_test_class.sh
#!/bin/bash

# テストファイルの中を見て、実行するクラスをすべて取得する
filelist=`find /path/to/tests -name 'test_*.py'
for file in $filelist; do
  module=`echo $file | sed -e 's/\//\./g' | sed -e 's/\.py//'`
  classes=`cat $file | grep class | grep -oP '(?<=class )\w+(?=\(.+)'`
  for class in $classes; do
    echo $module.$class
  done
done
xml_test_runner.py
import xmlrunner
from xmlrunner.extra.djangotestrunner import \
    XMLTestRunner as DjangoXMLTestRunner


class XMLTestRunnerForCI(DjangoXMLTestRunner):
    test_runner = xmlrunner.XMLTestRunner

    def run_suite(self, suite, **kwargs):
        runner_kwargs = self.get_test_runner_kwargs()
        # outsuffix='' にしないとテスト実行結果に実行時間のsuffixがついてしまって、CircleCIの --split-by=timings が有効にならない
        runner = self.test_runner(outsuffix='', **runner_kwargs)
        results = runner.run(suite)
        if hasattr(runner_kwargs['output'], 'close'):
            runner_kwargs['output'].close()
        return results
settings.py
TEST_RUNNER = 'path.to.xml_test_runner.XMLTestRunnerForCI'
TEST_OUTPUT_DIR = './unittest-results/unittest'
TEST_OUTPUT_FILE_NAME = 'results.xml'

上で何をやっているのか説明

JUnit XMLの出力形式について

CircleCIで実行時間を基にテストを分割するには、テストの実行時間の結果を JUnit XML か Cucumber JSON 形式で出力する必要があります。
unittest で JUnix XML を出力するためには unittest-xml-reporting ぐらいしか見当たらないのですが、これが CircleCI との相性が悪く、デフォルトで実行結果のログを [テストクラス名]-[timestamp] の形式で吐き出します。
こんな感じ

<testsuite errors="0" failures="0" file="tests/test_huga.py" name="tests.test_huga.FooTest-20191211001126" skipped="0" tests="3" time="2.251" timestamp="2019-12-11T00:11:28">

次回CircleCIでテストを実行する際には、この filename パラメータに対して実行テスト名を検索して実行時間を知る動きをします。
name 側はタイムスタンプが入っていて邪魔なので、file の方を使いたいのですが、Django の unittest ではファイル名を引数にしてテストの実行ができません。。 ( $ ./manage.py test /path/to/test_file.py ができない)

仕方ないのでnameに記されているテストクラス名を使う方針で、タイムスタンプを消しに行きます。name="tests.test_huga.FooTest" になって欲しい訳です。

unittest-xml-reporting の実装該当部分を見ると、タイムスタンプを消すには XMLTestRunner クラスの初期化で outsuffix の引数に空文字 '' を渡してあげれば良いことがわかりますが、このライブラリが提供しているDjango向け実装ではここへの変更に settings.py などから手を加えられないようになっています。

そのため、上のコードに書いたように XMLTestRunnerForCI を自分で実装して

runner = self.test_runner(outsuffix='', **runner_kwargs)

の部分だけ書き換えています。
これにより JUnit XML の出力形式がタイムスタンプなしのテストクラス名だけになりました。

テスト実行の引数に対して

続いて .circleci/get_all_test_class.sh の説明です。
先程

次回CircleCIでテストを実行する際には、この filename パラメータに対して実行テスト名を検索して実行時間を知る動きをします。

と書きましたが、

$ ./manage.py test hoge.test_huga.FooTest

と実行するとXMLの name="hoge.test_huga.FooTest" な部分を見に行ってくれる訳です。
つまりテストファイルの中身を全部見にいって [ファイル名].[クラス名] の一覧を生成する必要があり、それをやってくれるのが .circleci/get_all_test_class.sh です。

テストファイルの置き場所によっては少しコードを修正する必要があると思うので注意してください。
実行結果が以下のようになれば、期待通りです。

hoge.test_huga.FooTest
hoge.test_huga.FooTest1
hoge.test_huga.FooTest2
hoge.test_huga.FooTest3
hoge.test_bar.BarTest1
hoge.test_bar.BarTest2
hoge.test_bar.BarTest3

まとめ

ここまでやるのに1~2日ぐらいかかって気がします。まさかタイムスタンプが出ているとは思わずにJUnit XMLだしても過去の実行結果見つからないって言われるしどういうことなの〜とかなり悩みました。
(ファイルを書き出す場所が悪いのかなーとか…)

誰かの参考になれば幸いです。(最近みんなunittest使ってないのかな…)