🐈

CircleCIで激遅だったDjangoのテストを爆速で終わるようにしたっぴ

2021/12/19に公開

まえがき

業務でCircleCIでCIを行っているのですが、トータルで1時間以上かかりはじめたため10分ほどで終わるように対応をしました。
今回は、その際に行った対応方法とかのメモを兼ねた共有をしようと思いま!

CircleCIのJob内訳は、およそ以下のような感じでバックエンドテストにめちゃ時間かかってました。

  • フロントテスト (1~3min)
  • バックエンドテスト (50min)
  • ビルド (1~3min)
  • Lint (1~3min)

そのバックエンドテストを5分ほどで終わるように対応し、トータルで10分くらいにした話です。

前提条件

今回、対応した環境などです。

  • Django 1.5.8
  • django-discover-runner 1.0(Djangoのテストランナー)
  • Python 2.7.8
  • CircleCI
  • すでにDjangoでユニットテストコードが導入されていて、Coverageなどが出力されるようになっていること

前提条件を見てもらうと分かる通り、ふる〜いDjangoです。このふる〜いDjangoがちょっとめんどくさかったです。
古いためテストランナーに手を加える必要があり、初めてOSSに手を加えることを行いました。

※なので新しいDjangoを使う場合は必要ありません。古いDjangoを使っている方向けの記事&わたしの備忘録となります。

バックエンドテストの高速化のために行ったこと

行ったことは以下の3つ

  • バックエンドテストJobの並列化
  • ユニットテストそれぞれの時間を計測するようにし、効率的な並列化
  • 激遅だったユニットテストを特定し、削除or軽量化で高速化

ポイントとしては、単に並列化を行ったのではなく効率的な並列を行いました。

高速化の対応方法の流れとしては以下

  1. parallelism の設定(並列数の決定)
  2. DjangoテストClassのリストアップ
  3. テストランナーの変更・設定(xmlrunnerを入れる)
  4. CircleCIさんにええ感じに並列実行を分けてもらう
  5. テスト結果のレポートを吐き出す・吐き出し先を決める

1. parallelism の設定(並列数の決定)

CircleCIの設定ファイル.circleci/config.ymlのJobに以下のような感じでparallelismを設定する。

version: 2
jobs:
  backend_test:
    docker:
      - image: circleci/<language>:<version TAG>
        auth:
          username: mydockerhub-user
          password: $DOCKERHUB_PASSWORD
    parallelism: 4 # parallelismを設定

設定する数値はCircleCIのJob実行を行った詳細ベージから確認でき、以下の画像では1 / 16 parallel runsなどと表示されています。なのでこれは最大並列実行可能数が16であることを示しているので2~16を設定することができます。
image

parallelism の設定する数値を以下のような感じで環境変数から指定したいなと思ったのですがどうやら数値しか指定できないようでした。残念……

    parallelism: $PARALLELISM

ちなみにこの数を多くしすぎると他リポジトリのCI/CIでpending状態が発生してしまう場合があるため、数字を減らすか課金をして数を増やすと良さそうでした。(実際、現場で最大値を設定してみたところ別プロジェクトからpending状態でリリースがなかなか終わらないという苦情がきちゃってました…)

2. DjangoテストClassのリストアップ

並列化実行を行うためにTestClassを継承しているClassを全取得するDjangoカスタムコマンドを作成します。

get_all_test_class.py
# -*- coding: utf-8 -*-
import inspect
import importlib
import pkgutil
import unittest

from django.core.management.base import BaseCommand

# テストが含まれるアプリのパスを記述
# ./app/
TARGET_PACKAGES = ['app']


class Command(BaseCommand):
    def search_all_test_class(self):
        """
        アプリケーション内のすべてのテストクラスを走査してリストとして返す
        """
        test_classes = []
        for _, pkg_name, ispkg in pkgutil.walk_packages(['.']):
            for package in TARGET_PACKAGES:
                # 対象のパッケージと一致
                if not pkg_name.startswith('{}.'.format(package)):
                    continue
                if ispkg or 'test' not in pkg_name:
                    continue
                module = importlib.import_module(pkg_name)
                for class_name, class_obj in inspect.getmembers(module, inspect.isclass):
                    # unittest.case.TestCaseを継承しているのみに絞る
                    if not issubclass(class_obj, unittest.case.TestCase):
                        continue
                    # class内でtest_*名のメソッドを持つもの
                    for x in inspect.getmembers(class_obj, inspect.ismethod):
                        if x[0].startswith('test_'):
                            class_path = '{package}.{class_name}'.format(
                                package=pkg_name, class_name=class_name)
                            test_classes.append(class_path)
                            break
        return test_classes

    def _run(self):
        test_classes = self.search_all_test_class()
        print('\n'.join(test_classes))

    def handle(self, *args, **options):
        self._run()

上記ファイルを適当な箇所に作成し、以下のようなDjangoカスタムコマンドで実行可能なようにしました。

python manage.py get_all_test_class

3. テストランナーの変更・設定(xmlrunnerを入れる)

JUnitXML形式でテスト結果が出力されるよう対応する

xmlrunner をインストール(ライブラリの中身をいじるためpipenvやpoetryなどの仮想環境で行った方が良い)
pipenv install xmlrunner==2.5.2
https://github.com/xmlrunner/unittest-xml-reporting

以下の方法でバージョンの古いDjangoでも動くよう対応する。

Djangoのバージョンが古いためDiscoverRunnerが内蔵されておらず、別途インストールが必要でインポート方法が異なるため以下のように対応を行いました。

unittest-xml-reporting/xmlrunner/extra/djangotestrunner.py
from django.conf import settings

try:
  from django.test.runner import DiscoverRunner
except e:
  from discover_runner import DiscoverRunner


class XMLTestRunner(DiscoverRunner):

CircleCI内で上記の変更を行うpatchを当てることで変更するようにしました。

xmlrunner の導入が出来たら次は以下の記事を参考にランナーによってJUnitXML形式でテスト結果が出力されるよう対応する。
https://qiita.com/showwin/items/796feae450f8848c4c73

4. CircleCIさんにええ感じに並列実行を分けてもらう

これまでのステップでTestClassのリスト化、テストランナーのJUnitXML形式対応ができたのであともうちょっとです。
それらの内容をCircleCIに渡すよう、設定ファイルに記述する必要があります。

いくつかポイント

  • CI実行中に上記で行ったライブラリの中身の変更を自動化
    • Python環境を仮想化を使って修正先をわかりやすくする(PIPENV_VENV_IN_PROJECT: true)
  • Testクラスのリストをcircleci tests splitで渡す
  • テスト結果の出力場所を教える(store_test_resultsの設定)
  • 並列実行が終わった後のJobを作成し、カバレッジレポートを出す

5. テスト結果のレポートを吐き出す・吐き出し先を決める

テストを並列して行うだけでも十分だったりするが、カバレッジレポートやテスト結果を確認できるようにする設定も行う。
これをすることでテストコードがかけていない箇所を見つけることが出来るのでやっておいたほうが良い。

backend_test_reportというJobを作成
Jobで行うことは並列実行されたbackend_test Jobで実行した結果のレポートを集めてきて、それをもとにカバレッジを出す
また、テストレポートを読み込むことで時間が最もかかっているテストメソッドを見つけることが出来るようになりました。

.circleci/config.yml
...
  backend_test_report:
    working_directory: ~/repo
    docker:
      - image: cimg/python:2.7.18
    environment:
      PIPENV_VENV_IN_PROJECT: true
    steps:
        - checkout
        - restore_cache:
            key: deps9-{{ .Branch }}-{{ checksum "Pipfile.lock" }}
        - run:
            name: Install dependencies
            command: pipenv sync --dev
        - attach_workspace: # backend_testで作成したカバレッジレポートを持ってくる
            at: /tmp/coverage
        - run:
            name: Collates all result sets
            # backend_testで作成したカバレッジレポートをマージ
            command: |
                cp /tmp/coverage/.coverage.* .
                pipenv run coverage combine
        - run:
            name: Generate coverage report
            # カバレッジ計測
            command: pipenv run coverage html
        - store_artifacts:
            path: htmlcov
        - save_cache:
            key: deps9-{{ .Branch }}-{{ checksum "Pipfile.lock" }}
            paths:
                - ".venv"
...
workflows:
  test:
    jobs:
      - build
      - backend_test:
          requires:
            - build
      - backend_test_report:
          requires: # backend_testが完了してから実行させるよう設定
            - build
            - backend_test
...

感想

以上のような形で並列化による高速化対応がなんとか完了しました。

1回コミットするごとに1時間テスト待ちというのはまあまあつらいかなと思います。
チームでやっているとそのチームの人数分待ちが生じます。
例えば、30人開発者がいてそれぞれがPRを作ってcommitしてとなると…
単純計算をすると以下のようになってしまう。

30人 x 1時間 = 30時間のテスト待ち時間

チームで開発を行う際、この並列化はめちゃめちゃデカいと思いました。

GitHubで編集を提案

Discussion