Railsアプリの自動テスト環境をCirlceCIからGitHub Actionsへ移行したときにやったこと

Railsアプリの自動テスト環境をCirlceCIからGitHub Actionsへ移行したときにやったこと

はじめに

こんにちは、WEARバックエンド部バックエンドブロックの塩足です。普段は弊社サービスであるWEARのバックエンド開発・保守を担当しています。

WEARのバックエンドでは、これまで自動テスト環境としてCircleCIを使用していましたが、運用保守の改善を目的にGitHub Actionsへ移行しました。

今回は、GitHub Actionsへ移行する際に取り組んだ以下の3点について紹介します。

  • 効率的にテストを分割してテストを並列実行する方法
  • 失敗したテストのみを再実行する仕組みの構築
  • GitHubのCheck annotationsを活用して、失敗したテスト情報を表示

また、最後に今回行ったテストカバレッジのレポーティングとGitHub Pagesでのホスティングの方法について紹介します。

目次

背景

前述の通り、WEARのバックエンドではこれまで自動テスト環境としてCircleCIを使用していました。

なぜ自動テスト環境をCircleCIからGitHub Actionsに移行したのか

では、なぜ自動テスト環境をCircleCIからGitHub Actionsに移行したのか、その理由を説明します。

テストカバレッジのレポーティングやGitHub Pagesでのホスティングがシンプルに実現可能

octocovの導入が容易

octocovはコードメトリクスを収集するツールキットです。octocovには前回のテストカバレッジと比較するdiff機能があります。この機能を使うにはデータストレージが必要です。

CircleCIで利用する際は、外部ストレージを利用する必要があります。一方、GitHub Actionsでは、octocovがGitHub Actionsアーティファクトをデータストレージとしてサポートしています。また、k1LoW/octocov-action@v1アクションが用意されているため、簡単に導入できます。

ワークフローをトリガーする豊富なイベント

これまで、CircleCIではpushをトリガーにワークフローを実行していました。しかし、octocovを利用するにはpull_requestをトリガーにワークフローを実行する必要があります。pull_requestをトリガーにすると、Pull Requestを作成するまでテスト結果を確認できないため、開発生産性が落ちてしまいます。

そこで、CircleCIでpushpull_requestの両方をトリガーする方法を検討しました。しかしながら、両方をトリガーするにはGitHub ActionsからCircleCIのAPIでトリガーする必要があり、全体のワークフローが複雑になってしまいます。一方、GitHub Actionsであれば柔軟なトリガーが可能なため、シンプルに解決できます。

全社的にGitHub Actionsを推奨

全社的に新規のプロジェクトに関しては、基本的にGitHub Actionsの利用を推奨しています。そのため、今後はGitHub Actionsに揃えることで、ナレッジの共有やメンテナンスが容易になると考えました。

上記2つの理由から、自動テスト環境をCircleCIからGitHub Actionsへ移行することを決定しました。

課題

CircleCIからGitHub Actionsに移行するにあたって、以下の3つの課題がありました。

  1. CircleCIのタイミングベースでテスト分割する仕組みがGitHub Actionsにない
  2. CircleCIの失敗したテストのみを再実行する機能がGitHub Actionsにない
  3. GitHub Actionsで失敗したテスト詳細情報へのアクセシビリティが低い

1. CircleCIのタイミングベースでテスト分割する仕組みがGitHub Actionsにない

CircleCIは標準でタイミングデータに基づいたテストの分割が可能です。GitHub Actionsには同様の機能が備わっていないので、別途仕組みを考える必要があります。

2. CircleCIの失敗したテストのみを再実行する機能がGitHub Actionsにない

CircleCIには標準で失敗したテストのみを再実行する機能を提供しています。GitHub Actionsには失敗したテストのみ再実行する機能がないため、Flaky test等でテストが失敗した際、全テストを実行する必要があり開発生産性に影響します。

3. GitHub Actionsで失敗したテスト詳細情報へのアクセシビリティが低い

CircleCIはJob詳細画面のTESTSタブで失敗したテストを一覧で閲覧できます。

CircleCIの失敗したテスト一覧

一方、GitHub Actionsには失敗したテストを一覧で閲覧する機能はありません。並列で実行して複数のtestジョブでテストが失敗している場合、失敗しているtestジョブの数だけログを確認する必要があります。

課題解決の方法

まずは以下のようなTestワークフロー(.github/workflows/test.yml)を用意し、これを拡張することで3つの課題を解決していきます。

name: Test

on:
  push:

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

env:
  TEST_JOB_PARALLEL_COUNT: 2
  RAILS_ENV: test

defaults:
  run:
    shell: bash

jobs:
  test:
    runs-on: ubuntu-latest
    timeout-minutes: 20
    permissions:
      contents: read
      actions: read
    strategy:
      fail-fast: false
      matrix:
        group_index: ['0,1', '2,3']
    steps:
      - name: Checkout
        uses: actions/checkout@v4
      - name: Setup Ruby
        uses: ruby/setup-ruby@v1
        with:
          bundler-cache: true
      - name: Setup DB
        run: bundle exec rails "parallel:setup[`nproc`]"
      - name: Run rspec in parallel
        run: bundle exec parallel_rspec -n $((${TEST_JOB_PARALLEL_COUNT} * `nproc`)) --only-group ${{ matrix.group_index }}
      - name: Upload test result
        if: ${{ success() || failure() }}
        uses: actions/upload-artifact@v4
        with:
          name: test-result-${{ matrix.group_index }}
          path: |
            test_results/
            coverage/.resultset*.json
          include-hidden-files: true
          if-no-files-found: ignore

このワークフローはmatrix strategyを使用して2つ(TEST_JOB_PARALLEL_COUNT)のtestジョブを並列実行します。さらに、各testジョブはparallel_rspecで2つ(= nprocから取得したCPU数)のrspecプロセスを実行します。テストはparallel_rspecによってファイルサイズをベースに4つ(= testジョブ並列数 × CPU数)のグループに分割され、各testジョブで2グループずつ実行されます。テスト結果は test-result-${{ matrix.group_index }}という名前でアーティファクトに保存します。

testジョブのワークフローのフローチャートは以下のようになります。

testジョブ

1. r7kamura/split-tests-by-timingsアクションを使用したタイミングベースのテスト分割

課題1を解決するための、タイミングベースのテスト分割について説明します。

parallel_rspecのオプション--group-byruntimeを指定することで、タイミングベースでテスト分割できます。しかし、今回はより汎用的に利用できるr7kamura/split-tests-by-timingsアクションを採用しました。

r7kamura/split-tests-by-timingsアクションはJUnit XMLファイルを元にタイミングベースでテスト分割するアクションです。つまり、過去のテスト結果からJUnit XMLファイルを抽出し、r7kamura/split-tests-by-timingsアクションに渡す必要があります。

タイミングベースでテスト分割するために、testジョブを以下のように修正します。

@@ -25,7 +25,7 @@ jobs:
     strategy:
       fail-fast: false
       matrix:
-        group_index: ['0,1', '2,3']
+        test_job_index: [0, 1]
     steps:
       - name: Checkout
         uses: actions/checkout@v4
@@ -35,13 +35,30 @@ jobs:
           bundler-cache: true
       - name: Setup DB
         run: bundle exec rails "parallel:setup[`nproc`]"
+      - name: Download all test results for default branch
+        uses: dawidd6/action-download-artifact@v6
+        with:
+          name: test-result-*
+          name_is_regexp: true
+          path: ${{ runner.temp }}/default-branch-test-results
+          branch: ${{ github.event.repository.default_branch }}
+          workflow_conclusion: success
+          if_no_artifact_found: warn
+      - name: Split tests by timings
+        uses: r7kamura/split-tests-by-timings@v0
+        id: split-tests
+        with:
+          reports: ${{ runner.temp }}/default-branch-test-results/**/test_results
+          glob: spec/**/*_spec.rb
+          index: ${{ matrix.test_job_index }}
+          total: ${{ env.TEST_JOB_PARALLEL_COUNT }}
       - name: Run rspec in parallel
-        run: bundle exec parallel_rspec -n $((${TEST_JOB_PARALLEL_COUNT} * `nproc`)) --only-group ${{ matrix.group_index }}
+        run: bundle exec parallel_rspec -n `nproc` ${{ steps.split-tests.outputs.paths }}
       - name: Upload test result
         if: ${{ success() || failure() }}
         uses: actions/upload-artifact@v4
         with:
-          name: test-result-${{ matrix.group_index }}
+          name: test-result-${{ matrix.test_job_index }}
           path: |
             test_results/
             coverage/.resultset*.json

testジョブにおいて以下の点線で囲った2つのステップが追加されます。

タイミングベースのテスト分割

追加した2つのステップについて説明します。

Download all test results for default branchステップ

このステップではデフォルトブランチで成功している最新のテスト結果をダウンロードします。テストは複数のジョブで分散して実行されるため、その全てのテスト結果を取得する必要があります。

公式で用意されているactions/download-artifactアクションでは、ブランチやワークフロー実行のステータス等を指定してダウンロードできません。そこで、より柔軟に指定できるdawidd6/action-download-artifactアクションを使用することにしました。

dawidd6/action-download-artifactアクションの各入力パラメータについては以下の表で説明します。

キー
name ダウンロードするアーティファクト名のパターンを指定します。
name_is_regexp nameで正規表現を利用できるように、trueを設定します。
path ダウンロードするファイルパスを指定します。
branch ブランチでアーティファクトを検索します。今回はデフォルトブランチを指定します。
workflow_conclusion ワークフローのステータスでアーティファクトを検索します。今回は成功しているワークフローのみに絞り込むため、successを指定します。
if_no_artifact_found アーティファクトが見つからない場合の挙動を定義します。
初回実行を考慮してアーティファクトが存在しない場合でも動作するようにwarnを指定しています。

Split tests by timingsステップ

r7kamura/split-tests-by-timingsアクションはJUnit XMLファイルを元にタイミングベースでテストを分割します。r7kamura/split-tests-by-timingsアクションの各入力パラメータについては以下の表で説明します。

キー
reports JUnit XMLのファイルパスを指定します。
指定したパスにある全てのXMLファイルからタイミングデータを取得します。
glob テストファイルのglobパターンを指定します。
index 分割したグループのインデックスを指定します。
total 分割するグループ数を指定します。
今回はtestジョブの数だけグループを作成します。

分割されたファイルパスのリストは${{ steps.split-tests.outputs.paths }}でアクセスできます。

Split tests by timingsステップで分割したファイルパスのリストをRun rspec in parallelステップで使用することで、タイミングベースのテスト分割ができます。タイミングベースのテスト分割によりtestジョブは約30秒高速化しました。

2. RSpecの--only-failuresオプションで失敗したテストのみ再実行

課題2を解決するため、失敗したテストのみを再実行する方法を説明します。

CircleCIではcircleci tests runを使用することで、失敗したテストのみ再実行できましたが、GitHub Actionsにそういった機能は用意されていません。一方で、RSpecには--only-failuresオプションが用意されています。--only-failuresオプションは、実行されるテストをフィルタリングして、前回実行時に失敗したテストだけが実行されるようにします。

失敗したテストのみを再実行するために、testジョブを以下のように修正します。

@@ -33,9 +33,35 @@ jobs:
         uses: ruby/setup-ruby@v1
         with:
           bundler-cache: true
+      - name: Download previous test result
+        uses: actions/download-artifact@v4
+        with:
+          pattern: test-result-${{ matrix.test_job_index }}
+          path: ${{ runner.temp }}
+      - name: Place previous test result
+        id: previous-test-result
+        env:
+          TEST_RESULT_DIR: ${{ runner.temp }}/test-result-${{ matrix.test_job_index }}
+        run: |
+          if [ -f ${TEST_RESULT_DIR}/spec/examples.txt ]; then
+            mv ${TEST_RESULT_DIR}/spec/examples.txt spec/examples.txt
+            echo "failed-tests-only=true" >> $GITHUB_OUTPUT
+          fi
+          suffix="_`date +%s`"
+          mkdir -p test_results coverage
+          if [ -e ${TEST_RESULT_DIR}/test_results ]; then
+            mv ${TEST_RESULT_DIR}/test_results/* test_results/
+            find test_results -type f -name "*.xml" | sed "p;s/.xml/${suffix}.xml/" | xargs -n2 mv
+            bundle exec rails runner "Dir['test_results/**/*.xml'].each { |path| File.write(path, Nokogiri(File.read(path)).tap { _1.css('testcase:has(failure)').remove }.to_s) }"
+          fi
+          if [ -e ${TEST_RESULT_DIR}/coverage ]; then
+            mv ${TEST_RESULT_DIR}/coverage/.resultset*.json coverage/
+            find coverage -type f -name "*.json" | sed "p;s/.json/${suffix}.json/" | xargs -n2 mv
+          fi
       - name: Setup DB
         run: bundle exec rails "parallel:setup[`nproc`]"
       - name: Download all test results for default branch
+        if: ${{ !steps.previous-test-result.outputs.failed-tests-only }}
         uses: dawidd6/action-download-artifact@v6
         with:
           name: test-result-*
@@ -45,6 +71,7 @@ jobs:
           workflow_conclusion: success
           if_no_artifact_found: warn
       - name: Split tests by timings
+        if: ${{ !steps.previous-test-result.outputs.failed-tests-only }}
         uses: r7kamura/split-tests-by-timings@v0
         id: split-tests
         with:
@@ -53,7 +80,11 @@ jobs:
           index: ${{ matrix.test_job_index }}
           total: ${{ env.TEST_JOB_PARALLEL_COUNT }}
       - name: Run rspec in parallel
+        if: ${{ !steps.previous-test-result.outputs.failed-tests-only }}
         run: bundle exec parallel_rspec -n `nproc` ${{ steps.split-tests.outputs.paths }}
+      - name: Re-run rspec only failures
+        if: ${{ steps.previous-test-result.outputs.failed-tests-only }}
+        run: bundle exec rspec --only-failures
       - name: Upload test result
         if: ${{ success() || failure() }}
         uses: actions/upload-artifact@v4
@@ -61,6 +92,7 @@ jobs:
           name: test-result-${{ matrix.test_job_index }}
           path: |
             test_results/
+            spec/examples.txt
             coverage/.resultset*.json
           include-hidden-files: true
           if-no-files-found: ignore

testジョブにおいて以下の点線で囲った3つのステップが追加されます。

失敗したテストのみを再実行

追加した3つのステップについて説明します。

Download previous test resultステップ

actions/download-artifactアクションで前回のテスト結果のアーティファクトをダウンロードします。

GitHub Actionsのジョブの実行はいくつか方法がありますが、通常はイベントトリガーで実行されます。また、再実行はGitHub Actionsの実行詳細ページにある以下のボタンから「Re-run all jobs」と「Re-run failed jobs」を選択して実行できます。

ジョブの再実行

このジョブの実行方法の違いによりアーティファクト取得の挙動が変化します。

実行方法 アーティファクト取得の挙動
イベントトリガー アーティファクトがまだ存在しないため、取得できません。
Re-run all jobs すべてのジョブのアーティファクトがリセットされます。
つまり、挙動としてはイベントトリガーと同じになります。
Re-run failed jobs 成功したジョブのアーティファクトは変更されず、そのまま保存されます。
失敗したジョブのアーティファクトは再利用でき、再実行後にそのジョブの新しいアーティファクトで上書きできます。

Re-run failed jobsの場合、失敗したジョブのアーティファクトは再利用できるため、前回のテスト結果を利用できることになります。

Place previous test resultステップ

Download previous test resultステップで前回のテスト結果をダウンロードできた場合、以下の3つのファイルを適切に配置する必要があります。

  • spec/examples.txt
  • JUnit XMLファイル
  • カバレッジデータ

それでは1つずつ説明します。

spec/examples.txt

spec/examples.txtは、RSpecの実行結果に関する情報を記録するためのファイルです。このファイルには、前回のRSpec実行時にどのテストが成功したか、失敗したかなどの情報が保存されます。

また、--only-failuresオプションを使用するには以下のように、spec/examples.txtspec/spec_helper.rbに設定する必要があります。

RSpec.configure do |config|
  config.example_status_persistence_file_path = 'spec/examples.txt'
end
JUnit XMLファイル

JUnit XMLファイルは以下の2箇所で利用します。

  • r7kamura/split-tests-by-timingsアクションを使用したタイミングベースのテスト分割
  • 後述するGitHubのCheck annotationsで失敗したテスト結果を表示

bundle exec rspec --only-failuresを実行すると、JUnit XMLのファイルは前回失敗したテストだけの結果に上書きされます。タイミングベースでテスト分割する際に、全体のテスト結果が必要になるため、別の名前にリネームして残しておく必要があります。

当然、このファイルには前回失敗したテスト結果が含まれています。前回失敗したテストは再実行されるため、前回失敗したテスト結果だけ事前に削除しておく必要があります。

path = 'test_results/rspec.xml'
File.write(path, Nokogiri(File.read(path)).tap { _1.css('testcase:has(failure)').remove }.to_s)
カバレッジデータ

このファイルはテストカバレッジをレポートする際に利用します。このファイルもJUnit XMLファイルと同様にbundle exec rspec --only-failuresを実行すると、上書きされてしまいます。そのため、カバレッジデータも別のファイル名にリネームして残しておく必要があります。

前回のテスト結果がある場合は${{ steps.previous-test-result.outputs.failed-tests-only }}trueが設定され、後続する処理で利用されます。

Re-run rspec only failuresステップ

前回のテスト結果がある場合のみbundle exec rspec --only-failuresを実行します。

これでRe-run failed jobsから再実行した場合に、失敗したテストのみを再実行する方法が実現できました。

3. JUnit XMLファイルとreviewdogを用いて、GitHubのCheck annotationsで失敗したテスト情報を表示

課題3を解決するために、GitHubのCheck annotationsで失敗したテスト情報を表示する方法について説明します。

reviewdogReviewdog Diagnostic Format(RDFormat)という独自のフォーマットを利用して任意のlinterと連携できます。

JUnit XMLのデータは、エラーが発生したテストのファイルパス、テスト名(full_description)、エラーメッセージを保持しています。reviewdogを使用し、JUnit XMLのデータだけでエラー情報を表示すると以下のようになります。

GitHub Actionsの失敗したテスト一覧

対象ファイルの1行目にエラー情報が全て表示されるため、どのテストの情報なのか分かりにくくなってしまいます。

そこで、各テスト名の行番号を取得してエラー情報を見やすくする方法を検討しました。今回はRSpec::Core::ExampleGroupオブジェクトのmetadataから行番号を取得する方法を採用しました。

以下のファイル(scripts/generate_rspec_reviewdog_json.rb)はJUnit XMLファイルからRDFormatファイルを生成するスクリプトとなります。

require 'nokogiri'

$LOAD_PATH.unshift 'spec'
require 'rails_helper'

# RSpecのExampleGroupを再帰的に辿り、全てのExampleを取得する
# @param example_group [RSpec::Core::ExampleGroup]
# @return [Array<RSpec::Core::Example>]
def collect_all_examples(example_group)
  example_group.examples + example_group.children.flat_map { collect_all_examples(_1) }
end

# ExampleGroupからfull_descriptionとline_numberの対応関係を生成する
# @param example_group [RSpec::Core::ExampleGroup]
# @return [Hash{String => Integer}]
def map_description_to_line_number(example_group)
  collect_all_examples(example_group)
    .each_with_object({}) { |example, obj| obj[example.metadata[:full_description]] = example.metadata[:line_number] }
end

# キャッシュを利用して、ファイルパスに対応するfull_descriptionとline_numberの対応関係を生成する
# @param path [String]
# @return [Proc]
def cached_description_to_line_number
  cache = {}
  ->(path) {
    cache[path] ||= begin
      example_group = eval(File.read(path)) # rubocop:disable Security/Eval
      map_description_to_line_number(example_group)
    end
  }
end

# JUnit XMLファイルからRDFormatのデータを生成する
# @param junit_xml_file_path [String]
# @return [Array<Hash>]
def parse_junit_failures(junit_xml_file_path)
  description_mapper = cached_description_to_line_number
  Nokogiri(File.open(junit_xml_file_path)).css('testsuite testcase failure').map do |failure_elem|
    elem = failure_elem.parent
    path = elem.attr('file')
    description_to_line = description_mapper.call(path)
    {
      message: failure_elem.text,
      location: {
        path: path,
        range: {
          start: {
            line: description_to_line[elem.attr('name')]
          }
        }
      }
    }
  end
end

File.open(ENV.fetch('REVIEWDOG_JSON_FILE_PATH'), 'w') do |f|
  Dir[ENV.fetch('JUNIT_XML_FILE_PATH_PATTERN')].each do |junit_xml_file_path|
    rows = parse_junit_failures(junit_xml_file_path)
    f.puts(rows.map(&:to_json).join("\n")) if rows.present?
  end
end

上記スクリプトを利用して、TestワークフローにGitHubのCheck annotationsで失敗したテスト情報を表示するreport-failed-testsジョブを追加しました。

  report-failed-tests:
    needs: test
    runs-on: ubuntu-latest
    timeout-minutes: 5
    continue-on-error: true
    permissions:
      contents: read
      pull-requests: write
    if: ${{ success() || failure() }}
    env:
      REVIEWDOG_JSON_FILE_NAME: rspec_reviewdog.jsonl
    steps:
      - name: Checkout
        uses: actions/checkout@v4
      - name: Setup Ruby
        uses: ruby/setup-ruby@v1
        with:
          bundler-cache: true
      - name: Setup reviewdog
        uses: reviewdog/action-setup@v1
        with:
          reviewdog_version: v0.20.0
      - name: Download all test results
        uses: actions/download-artifact@v4
        with:
          pattern: test-result-*
          path: ${{ runner.temp }}/test-results
      - name: Generate RSspec reviewdog json
        env:
          JUNIT_XML_FILE_PATH_PATTERN: ${{ runner.temp }}/test-results/**/test_results/*.xml
          REVIEWDOG_JSON_FILE_PATH: ${{ runner.temp }}/${{ env.REVIEWDOG_JSON_FILE_NAME }}
        run: bundle exec ruby scripts/generate_rspec_reviewdog_json.rb
      - name: Run rspec reviewdog
        env:
          REVIEWDOG_GITHUB_API_TOKEN: ${{ github.token }}
          REVIEWDOG_JSON_FILE_PATH: ${{ runner.temp }}/${{ env.REVIEWDOG_JSON_FILE_NAME }}
        run: |
          cat $REVIEWDOG_JSON_FILE_PATH | reviewdog -f=rdjsonl -reporter=github-check

report-failed-testsジョブではscripts/generate_rspec_reviewdog_json.rbを実行してRDFormatのファイルを作成します。作成されたRDFormatファイルはreviewdogに渡されて、reviewdog内でGitHub APIのUpdate a check runを実行します。

report-failed-testsジョブを実行した結果、以下のように各テストの行にエラー情報が表示されるようになりました。

GitHub Actionsの失敗したテスト一覧(改良版)

テストカバレッジを活用できる環境整備の方法

最後に、テストカバレッジを活用できる環境整備の方法について説明します。

octocovを使用してテストカバレッジをPull Requestにレポートする

まずアプリケーションに以下の.octocov.ymlファイルを用意します。

coverage:
  paths:
    - coverage/.resultset.json
  acceptable: 60%
codeToTestRatio:
  acceptable: 1:1.2
  code:
    - "app/**/*.rb"
    - "lib/**/*.rb"
  test:
    - "spec/**/*_spec.rb"
diff:
  datastores:
    - artifact://${GITHUB_REPOSITORY}
comment:
  if: is_pull_request && !is_default_branch
  hideFooterLink: false
  deletePrevious: true
report:
  if: is_default_branch
  datastores:
    - artifact://${GITHUB_REPOSITORY}

.octocov.ymlファイルは以下のような設定になっています。

  • テストカバレッジは60%未満の時、exist status 1で終了する
  • コードとテストの割合が1 : 1.2未満の時、exist status 1で終了する
  • デフォルトブランチとのテストカバレッジの差分を表示する
  • コメントはデフォルトブランチ以外のPull Requestの場合に行う

次にTestワークフローに以下のreport-coverageジョブを追加します。

  report-coverage:
    needs: test
    runs-on: ubuntu-latest
    timeout-minutes: 5
    permissions:
      contents: read
      pull-requests: write
    if: ${{ success() || failure() }}
    steps:
      - name: Checkout
        uses: actions/checkout@v4
      - name: Setup Ruby
        uses: ruby/setup-ruby@v1
        with:
          bundler-cache: true
      - name: Download all test results
        uses: actions/download-artifact@v4
        with:
          pattern: test-result-*
          path: ${{ runner.temp }}/test-results
      - name: Aggregate all coverage resultsets
        run: bundle exec rails runner "require 'simplecov'; SimpleCov.collate(Dir['${{ runner.temp }}/test-results/**/coverage/.resultset*.json'], 'rails')"
      - name: Report coverage by octocov
        uses: k1LoW/octocov-action@v1
      - name: Upload coverage
        uses: actions/upload-artifact@v4
        with:
          name: coverage
          path: coverage
          include-hidden-files: true

report-coverageジョブはテスト結果をダウンロードし、複数testジョブで生成されたカバレッジデータをSimpleCov.collateで集計します。集計したカバレッジデータをk1LoW/octocov-action@v1に与えることでPull Requestにテストカバレッジをレポートすることが出来ます。

しかし、この方法ではテストカバレッジをレポートできないケースがあります。Testワークフローはpushをトリガーに実行されます。つまり、このTestワークフローのreport-coverageジョブが実行している時点でPull Requestが存在しない場合、テストカバレッジをレポートできません。そこで、pull_requestをトリガーに実行するReport coverageワークフロー(.github/workflows/report-coverage.yml)を用意します。

name: Report coverage

on:
  pull_request:
    types: opened

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

env:
  TEST_WORKFLOW_FILE_NAME: test.yml
  COVERAGE_ARTIFACT_NAME: coverage

defaults:
  run:
    shell: bash

jobs:
  report-coverage:
    runs-on: ubuntu-latest
    timeout-minutes: 5
    permissions:
      contents: read
      pull-requests: write
      actions: read
    env:
      GH_TOKEN: ${{ github.token }}
    steps:
      - name: Checkout
        uses: actions/checkout@v4
      - name: Get run_id of test workflow
        id: get-run-id
        run: |
          gh api \
            -H "Accept: application/vnd.github+json" \
            -H "X-GitHub-Api-Version: 2022-11-28" \
            "/repos/${GITHUB_REPOSITORY}/actions/workflows/${TEST_WORKFLOW_FILE_NAME}/runs?head_sha=${{ github.event.pull_request.head.sha }}&status=completed" | \
            jq '.workflow_runs | sort_by(.id)[] | select(.conclusion == "success" or .conclusion == "failure") | .id' | \
            jq -sr '"test-run-id=\(last)"' >> $GITHUB_OUTPUT
      - name: Download coverage
        if: ${{ steps.get-run-id.outputs.test-run-id != 'null' }}
        uses: actions/download-artifact@v4
        with:
          name: ${{ env.COVERAGE_ARTIFACT_NAME }}
          path: coverage
          run-id: ${{ steps.get-run-id.outputs.test-run-id }}
          github-token: ${{ github.token }}
      - name: Coverage Report by octocov
        if: ${{ hashFiles('coverage/.resultset.json') }}
        uses: k1LoW/octocov-action@v1

Report coverageワークフローのreport-coverageジョブはTestワークフローのreport-coverageジョブで保存したcoverageアーティファクトを利用します。ただし、Report coverageワークフローがトリガーされた時点でTestワークフローのcoverageアーティファクトが存在しない場合は処理をスキップします。

このように、pushpull_requestの両方のトリガーを利用することで、テストカバレッジをPull Requestにレポートできました。

テストカバレッジの結果をGitHub Pagesでホスティングする

続いて、テストカバレッジの結果をGitHub Pagesでホスティングする方法を説明します。

Testワークフローに以下のbuild-github-pagesジョブとdeploy-github-pagesジョブを追加します。どちらのジョブもデフォルトブランチの場合のみ実行します。

  build-github-pages:
    needs: report-coverage
    runs-on: ubuntu-latest
    timeout-minutes: 5
    if: ${{ format('refs/heads/{0}', github.event.repository.default_branch) == github.ref }}
    steps:
      - name: Download coverage
        uses: actions/download-artifact@v4
        with:
          name: coverage
          path: coverage
      - name: Upload pages artifact
        uses: actions/upload-pages-artifact@v3
        with:
          path: coverage

  deploy-github-pages:
    needs: build-github-pages
    runs-on: ubuntu-latest
    timeout-minutes: 5
    if: ${{ format('refs/heads/{0}', github.event.repository.default_branch) == github.ref }}
    permissions:
      pages: write
      id-token: write
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    steps:
      - name: Deploy to GitHub Pages
        id: deployment
        uses: actions/deploy-pages@v4

build-github-pagesジョブはactions/upload-pages-artifact@v3アクションでcoverageをアーカイブします。そして、アーカイブしたファイルをgithub-pagesというアーティファクト名でアップロードします。

deploy-github-pagesジョブはactions/deploy-pages@v4アクションでgithub-pagesアーティファクトをGitHub Pagesにデプロイします。

これでテストカバレッジを活用できる環境整備ができました。

今後の展望

以上の取り組みによって、テストカバレッジを活用できる環境を整えつつ、CircleCIからGitHub Actionsへ移行出来ました。

しかし、まだ改善余地があり、以下のような課題があります。

Flaky testを検出する機能がない

CircleCIにはFlaky testを検出できるテスト インサイト機能があります。GitHub ActionsにはFlaky testを検出できる機能がないため、別途用意する必要があります。

追加・変更した実装コードのソースコードカバレッジ

テストカバレッジの結果をGitHub Pagesでホスティングすることで、デフォルトブランチのテストカバレッジをソースファイル単位で確認できるようになりました。しかし、Pull Requestのテストカバレッジを確認するにはアーティファクトからダウンロードする必要があるため、Pull Request上で確認するには仕組みを考える必要があります。

今後は、これらの課題を解決する方法を検討したいと思います。

まとめ

本記事ではRailsアプリケーションの自動テスト環境をCircleCIからGitHub Actionsへ移行した際に発生した課題とその解決方法に関して紹介しました。Railsアプリケーションの自動テスト環境をCircleCIからGitHub Actionsへ移行を検討している方がいれば、ぜひ参考にしてみてください。

ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。

corp.zozo.com

カテゴリー