🦛

pytestで並列でテストを実行しつつカバレッジを取得する方法

2022/01/06に公開

はじめに

現代の開発ではテストコード及びCI環境は欠かせません。しかし、開発規模が大きくなるにつれて、テストの数が増えて、だんだん実行時間が長くなってきます。そしてテストの実行時間が長くなるほど開発者へのフィードバックにかかる時間が長くなり、開発速度の低下を招きます。10分以上かかるなら危険信号と言っていいでしょう。

テスト実行速度の低下を避けるためにはデータベースや画面など他のものに依存しない、なるだけ軽量なオブジェクトを使用してテストするのが最良ですが、並列化で対応することも一つの手段です。

また、コードカバレッジも開発における重要な指標の一つです。「カバレッジが高い=品質が高い」とは言えませんが、カバレッジが高いほど安心して開発できるのは事実です。また、カバレッジを計測しておくと、テストコードが間違っていたときに気づきやすくなります[1]、そのため、コードカバレッジを計測しておくのは重要です。

ちなみに、コードカバレッジについての考察を次の記事に書いています。

https://zenn.dev/ikemo/articles/test-coverage-100-percent

このようにとても重要な「テストの並列化」と「コードカバレッジの計測」ですが、両方を同時に実現するためには工夫が必要です。この記事では、次の2つを同時に実現する方法を記載します。

  • pytestをCircleCIのコンテナで並列化することで、実行時間を改善する
  • コードカバレッジを計測する

前提条件

次の環境で確認しています。

  • Python: 3.8[2]
  • Django: 4.0.1
  • CircleCI

コードカバレッジの計測のためにCodecovを使用します。

Codecov以前Bashアップローダに問題がありメルカリなどが被害を受けましたが、便利なサービスのため自分は引き続き利用しています(もちろん根本対応され、その後問題が起きてないことが前提ですが)。

動作確認には、以前も書いた自作のDjangoアプリケーションを使用しています。このなかでpytest絡みのライブラリのバージョンは次になります。

CircleCIの設定

まず、現在使っている .circleci/config.yml をそのまま掲載します。

version: 2.1
orbs:
  codecov: codecov/codecov@3.2.0

commands:
  install_packages:
    steps:
      # Download and cache dependencies
      - restore_cache:
          keys:
            - v7-dependencies-{{ checksum "requirements/common.txt" }}-{{ checksum "requirements/development.txt" }}

      - run:
          command: |
            set -eu
            python -m venv venv
            . venv/bin/activate
            pip install -r requirements/development.txt

jobs:
  build:
    docker:
      - image: cimg/python:3.8
        environment:
          - LANG: ja_JP.UTF-8
          - LANGUAGE: ja_JP.UTF-8

    resource_class: large

    working_directory: ~/repo

    steps:
      - checkout
      - install_packages

      - run:
          name: make build
          command: make build

  test:
    docker:
      - image: cimg/python:3.8
        environment:
          - LANG: ja_JP.UTF-8
          - LANGUAGE: ja_JP.UTF-8

    resource_class: large
    parallelism: 3

    working_directory: ~/repo

    steps:
      - checkout
      - install_packages

      - run:
          name: make unit-test
          command: |
            export COVERAGE_FILE=.coverage.${CIRCLE_NODE_INDEX}
            TESTFILES=$(circleci tests glob "apps/**/tests.py" "apps/**/test_*.py" "apps/**/*_tests.py" | circleci tests split --split-by=timings)
            echo $TESTFILES
            . venv/bin/activate
            pytest -n 8 \
              --cov-branch \
              --cov=apps \
              --cov=templates \
              --junit-xml=test-results/junit.xml \
              $TESTFILES

      - save_cache:
          paths:
            - ./venv
          key: v7-dependencies-{{ checksum "requirements/common.txt" }}-{{ checksum "requirements/development.txt" }}

      - store_test_results:
          path: test-results

      - persist_to_workspace:
          root: ~/repo
          paths:
            - .coverage.*

  coverage:
    docker:
      - image: cimg/python:3.8
        environment:
          - LANG: ja_JP.UTF-8
          - LANGUAGE: ja_JP.UTF-8

    working_directory: ~/repo

    steps:
      - checkout
      - install_packages

      - attach_workspace:
          at: ~/repo

      - run:
          name: combile coverage
          command: |
            . venv/bin/activate
            coverage combine
            coverage xml

      - codecov/upload:
          file: coverage.xml

workflows:
  version: 2
  main:
    jobs:
      - build
      - test
      - coverage:
          requires:
            - test

ポイントは次のとおりです。

  • テスト用のジョブ(test) と、カバレッジ集計のためのジョブ(coverage)を分け、後者を前者に依存させる
  • parallelism: 3: 3並列でコンテナを実行
  • pytestのオプション
    • --cov-branch : 「分岐網羅」を使用する(pytest-covのオプション)
    • --cov=apps, --cov=templates: カバレッジを計測するパスを指定(pytest-covのオプション)
    • --junit-xml: JUnit形式のXMLを出力する
  • store_test_results: CircleCIにテスト結果を格納する。このように TESTS タブが表示されます。

各コンテナで計測したカバレッジを保存する

テストを並列実行しつつカバレッジ計測を行うためには、並列実行した各コンテナで計測したカバレッジをマージする必要があります。そのために次のようにしています。

まず、カバレッジ計測ツール coverage では、デフォルトで .coverage というファイルに結果を書き込みます。しかし、デフォルトの設定だと全てのコンテナで同じファイル名のため、 persist_to_workspace で保存する際に上書きされてしまいます。それを防ぐために、次のようにしてファイル名を変更しています。

export COVERAGE_FILE=.coverage.${CIRCLE_NODE_INDEX}

ここでは ${CIRCLE_NODE_INDEX} という形で、ノードIDを指定しています。 すると、 .coverage でなく .coverage.0 のようなファイル名で保存されます。CircleCIでなく他のCIサービスを使う際は、サービスごとに適切な環境変数を使用して、被らないようにしてください。

そして、次のステップでワークスペースに保存します。

jobs:
  test:
    steps:
      - persist_to_workspace:
          root: ~/repo
          paths:
            - .coverage.*

計測したカバレッジを1つにまとめる

次に、計測したカバレッジを1つでまとめます。これは次のステップで行われています。

jobs:
  test:
    steps:
      - run:
          name: combile coverage
          command: |
            . venv/bin/activate
            coverage combine
            coverage xml

coverage combine コマンドで、 .coverage.* ファイルを1つのファイルにマージします。そして、 coverage xml コマンドで、カバレッジをXML形式で出力します。もしHTML形式がほしい場合は coverage html コマンドを実行してください。

最後に、Codecovにアップロードします。

version: 2.1
orbs:
  codecov: codecov/codecov@3.2.0

jobs:
  coverage:
    steps:
      - codecov/upload:
          file: coverage.xml

すると次のようにカバレッジが記録されます。



おわりに

これを書いているときに、CircleCIの無料プランで parallelism がサポートされているのを知りました。毎月 $30 を払うのは正直キツかったので、ありがたいです。$10くらいなら気軽に払えるのですが・・・。

脚注
  1. 本来テストで通るべきコードが通らないときに「おかしいな」と思って調べると、テストコードの書き方が間違ってたことが何度もあります。 ↩︎

  2. 以前書いた記事よりPythonがダウングレードされていますが、実行環境をHerokuから移転したためです。 ↩︎

Discussion