QUO CARD Digital Innovation Lab Tech Blog

クオカード デジタルイノベーションラボの技術ブログです

CIのテストを修正した資産によって分けて実施する

こんにちは、クオカード デジタルイノベーションラボの鳥海です。

QUOカードPayオンラインストアは内製化に切り替えてからもう少しで半年ほど経ちます。
内製化に切り替えた後は、新たな機能追加とリファクタリングを進めており、それぞれで unit test が作成できるものは作成し、CI による自動テストを実施するようにしています。
CI には GitHub Actions を使用しています。

自動テストを修正のたびに都度実施することにより、安心して既存の機能が改修ができたり、リファクタリングが行えるようになったのですが、テストソースが増えるにつれて以下のような問題が発生してきました。

  1. 一部のモジュールの修正しかしていないのに、全てのテストを実施しており無駄な待ち時間や 余計な GithubActions のリソース消費が発生していた
  2. たまに実行中のまま止まってしまうテストがでてきた

1 に関しては、静的資産(HTML、CSS、JS など)のみ修正した場合など、不要な場合もテストを実施していたので、こういう場合は実施しないようにすべきでした。
2 に関しては本来、事象を解析して直したいところでしたが、作業の優先度も考慮し該当のテストを無効にして対応していました。ただ今後その機能が修正された場合はテストを実施すべきであるのと、担当者内で無効にしていることを共有できているうちはよいのですが、新しいメンバーが増えた際に伝達漏れなどが発生し得ると思いました。

上記の点を解決するために、毎回テストを一律実施するのではなく、修正した資産によってテスト絞って実施できないか調べてみました。

GitHub Actions のテストワークフローの実施を GitHub に push したパスによって絞る

GitHub Actions のpathsを使うと、修正したリソースパスを指定してワークフローが実行できるようになります。

下記のようにパスを指定すると push した際に差分 が発生した資産がそのパスに含まれていれば、ワークフローが実施されます。
また、指定したパスの中で除外したいものがあれば!を付与したパスを下行に追加することにより除外できます。
パスの指定方法にはワイルドカードが指定可能です。 指定方法の詳細はここを参照ください。

GitHub Actions のワークフローファイル

on:
  push:
    paths:
    - 'module1/**'
    - '!module1/function1/**'

以下のようにワークフローを実施しない資産のパスを指定することもできます。

on:
  push:
    paths-ignore:
    - 'module1/**'

これで push した際にワークフローを実施する資産を特定できるようになったのですが、これだけだとテストごとにワークフローファイル作らなくてはいません。

ワークフローでテスト以外は Github リポジトリのチェックアウトや Java のセットアップなどがありますが、テストごとにファイルを分けると、これらは各ワークフローで重複することになり、これはよろしくないなと思いました。

そこで一つのワークフローファイルの中で修正された資産を判定し、テストを分けて実施できないか調べてみました。

テストワークフロー内で、修正した資産を判定できるようにする

現時点で GithubActions に用意されている機能ではワークフロー内で修正資産を判断できるものがないため、以下のプラグインを使用してどの資産に修正が入ったか判定できるようにしました。

dorny/paths-filter

このプラグインを使用して資産ごとにテストを分けたワークフローのソースが以下となります。

jobs:
  test:
    name: Run tests
    runs-on: ubuntu-latest

    -- 省略 --

    steps:
      # 修正された資産を判定する※
      - name: paths-filter
      uses: dorny/paths-filter@v2
      id: filter
      with:
        base: ${{ github.ref }}
        filters: |
          module1:
            - 'module1/**'
          module2:
            - 'module2/**'
          module_common:
            - 'module_common/**'

      # モジュール1のテスト
      - name: module1 test
        id: module1-test
        if: steps.filter.outputs.module1 == 'true' || steps.filter.outputs.module_common == 'true'
        run: ./gradlew module1Test

      # モジュール2のテスト
      - name: module2 test
        id: module2-test
        if: steps.filter.outputs.module2 == 'true' || steps.filter.outputs.module_common == 'true'
        run: ./gradlew module2Test

      # 共通モジュールのテスト
      - name: module_common test
        id: module_common-test
        if: steps.filter.outputs.module_common == 'true'
        run: ./gradlew moduleCommonTest

      # モジュール1のテスト結果をアップロード
      - name: store reports of module1
        uses: actions/upload-artifact@v2
        # テストが実行された場合だけアップロードする
        if: always() && steps.module1-test.outcome != 'skipped'
        with:
        name: module1-report
        path: module1/build/reports

      # モジュール2のテスト結果をアップロード
      - name: store reports of module2
        uses: actions/upload-artifact@v2
        # テストが実行された場合だけアップロードする
        if: always() && steps.module2-test.outcome != 'skipped'
        with:
        name: module2-report
        path: module2/build/report

      # 共通モジュールのテスト結果をアップロード
      - name: store reports of module_common
        uses: actions/upload-artifact@v2
        # テストが実行された場合だけアップロードする
        if: always() && steps.module_common-test.outcome != 'skipped'
        with:
        name: module_common-report
        path: module_common/build/report

ソースの※の箇所がプラグインを使用しているところです。 base: ${{ github.ref }}を指定すると、そのブランチのHEADとの差分を比較することになります。(指定しないと分岐元のブランチとの差分比較となります) フィルターを通すと、steps.filter.outputs.<フィルタ名>で修正した資産を判定して、それに応じてテストを実施できるようになります。

また、複数条件を指定することで判定文が長くなるため、テスト結果のアップロードでは同じ条件を指定せず、テストステップに id を付与し、その id のテストが実施されたかを steps コンテキストのoutcome を使用して判定しています。

これでテストをワークフロー内で呼び分けることができました。
次は実施するテストを分けます。

Gradle でテストを実施したい単位に分ける

クオカードペイオンラインストアではビルドツールに Gradle を使用しています。
Gradle では、以下のようにincludeexcludeを使用して、タスクの対象資産を特定することができます。

module1/build.gradle

task module1Test(group: 'verification', type: Test, dependsOn: testClasses) {
  useJUnitPlatform()
  # function1配下は除いたテスト
  exclude '**/module1/function1/**'
}

task function1Test(group: 'verification', type: Test, dependsOn: testClasses) {
  useJUnitPlatform()
  # module1のfunction1のみのテスト
  include '**/module1/function1/**'
}

上記で示した方法を柔軟にテストを分けて実施することができるようになります。
これで修正した資産に影響するモジュールのみテストを行うようにしたので、問題点を解消できました。

テストの並列化

テストは一つのジョブでなく、複数のジョブに分けて定義し並列で実施するようにもできます。
GithubActions では優先度が同じジョブは並列で実施されます。
ただし、各ジョブ間ではセットアップしたものなどのリソースを共有できません。
共通処理をそれぞれ行わなくてはいけなく、今回はそれに伴う時間と並列化により効率化される時間を比較して、結果的に並列化は実施しませんでした。

ただ dorny/paths-filter プラグインの判定結果をジョブを起動する条件にも使えるので、今後テストが増え、テストに大幅に時間がかかるようになった際は並列化をすることで時間の短縮が可能になると思われます。

以下のようにすると module1 と module2 のテストが並列で実施されます。

jobs:
  filter:
    name: Filter
    runs-on: ubuntu-latest
    outputs:
      # 変更した資産の判定結果を他のjobに渡す
      module1: ${{ steps.filter.outputs.module1 }}
      module2: ${{ steps.filter.outputs.module2 }}
      module_common: ${{ steps.filter.outputs.module_common }}
    steps:

      -- 省略 --

      - name: paths-filter
      uses: dorny/paths-filter@v2
      id: filter
      with:
        base: ${{ github.ref }}
        filters: |
          module1:
            - 'module1/**'
          module2:
            - 'module2/**'
          module_common:
            - 'module_common/**'

  test-module1:
    name: Run tests module1
    runs-on: ubuntu-latest
    needs: filter
    if: needs.filter.outputs.module1 == 'true' || needs.filter.outputs.module_common == 'true'

    -- 省略 --

  test-module2:
    name: Run tests module2
    runs-on: ubuntu-latest
    needs: filter
    if: needs.filter.outputs.module2 == 'true' || needs.filter.outputs.module_common == 'true'

    -- 省略 -

色々無駄なところを省くことによって、各メンバーがより効率的に作業ができますし、GitHub Actions の使用リソースも抑えられます。
最初は問題となっていなくてもプロジェクトの規模が大きくなっていくにつれて問題となってくるものもあるので、見直すことで最適化をはかり、非効率となっているものを取り除いていくことも開発を進めるうえで大事だと思いました。