RSpec + parallel_tests + CircleCIで並列テストする

# はじめに

はじめまして、[@h-sakano](https://twitter.com/h_sakano)と申します。
この記事では、以下の内容について取り扱います。

- RailsのテストフレームワークRSpecを並列で走らせる方法
- 並列で走らせたテストのカバレッジを出力する方法

## 取り扱わない内容

- RSpecの詳細説明・設定方法
    - テストフレームワーク
- SimpleCovの詳細説明・設定方法
    - コードカバレッジの計算用gem

# 環境

- Ruby on Rails 5.2.2
- CircleCI 2.1

一部CircleCIのバージョン2.1で使えるようになった新機能を使いますので、`PROJECT SETTINGS` -> `Advance Settings` -> `Enable build processing`で有効化しておきます。

![スクリーンショット 2018-12-16 20.07.03.png](https://qiita-image-store.s3.amazonaws.com/0/172314/43c919e4-a7e2-bad7-d77e-dcc3049c7fa5.png)

# 使用gem

テストに使用するGemを抜き出したものが以下になります。

```rb
group :test do
  # Provides Rails integration for factory_bot
  gem "factory_bot_rails"
  # Generates data at random
  gem "faker"
  # Rails: 2 CPUs = 2x Testing Speed for RSpec, Test::Unit and Cucumber
  gem "parallel_tests"
  # Testing framework for Rails 3.x, 4.x and 5.x
  gem "rspec_junit_formatter"
  gem "rspec-rails"
  # Code coverage for Ruby
  gem "simplecov", require: false
end
```

今回はテストフレームワークとしてRSpecを使用するので、`rspec-rails`とそれに関連するgemをインストールします。
また、RSpecを並列実行するため、`parallel_tests`をインストールします。

# parallel_testsの設定

`parallel_tests`をインストールすると、`bundle exec parallel_rspec`コマンドでRSpecの並列実行が可能になります。
また、`parallel_rspec`コマンド実行時の引数は、プロジェクトのルートディレクトリに`.rspec_parallel`ファイルを作成することで設定することができます。

```.rspec_parallel
--require spec_helper
--format progress
--format RspecJunitFormatter
--out rspec/rspec<%= ENV['TEST_ENV_NUMBER'] %>.xml
--profile 10
```

`--out`オプションで出力ファイル名に`TEST_ENV_NUMBER`という環境変数を使用しています。
この変数には並列実行した際のプロセス番号が自動で割り振られます。

Process number|1|2|3
---|---|---|---
ENV['TEST_ENV_NUMBER']|''|'2'|'3'

この変数を使用することで、並列実行時のテスト結果をそれぞれ分けて保存することができます。
`database.yml`の設定もこの`TEST_ENV_NUMBER`を使って書き換えます。

```config/database.yml
test:
  <<: *default
  database: db/test<%= ENV['TEST_ENV_NUMBER'] %>.sqlite3
```

今回はデータベースにSQLite 3を使用していますが、MySQLやPostgreSQLを使用する場合も、`database`の値に`TEST_ENV_NUMBER`を入れれば大丈夫です。

# parallel_testsの実行

まずは手元でparallel_testsが実行できることを確認します。
例えば、4つのプロセスで並列実行する場合を考えます。

テスト用データベースのセットアップを行います。
例えば、以下のコマンドで4つのテスト用データベースが作成されます。

```bash
$ bundle exec rails parallel:setup[4]
Created database 'db/test3.sqlite3'
Created database 'db/test2.sqlite3'
Created database 'db/test4.sqlite3'
Created database 'db/test.sqlite3'
```

先程設定した`database.yml`のデータベース名が反映されます。

テスト用データベースのセットアップが終わったら、次はRSpecを並列実行します。

```
bundle exec parallel_rspec -n 4 spec
```

# CircleCIの設定

以上を踏まえて、CircleCIでこれらのコマンドを実行するよう、設定ファイルを作成します。
最終的な設定ファイルはgistにアップロードしています。

https://gist.github.com/h-sakano/68104439e8cfb6f6836a8a02bd08551b

この設定では`rspec`ジョブと`coverage`ジョブの2つのジョブを定義しています。
`rspec`ジョブで`RSpec`を並列実行し、`coverage`ジョブでは`SimpleCov`を使用してテストカバレッジを出力します。

```.circleci/config.yml
workflows:
  build:
    jobs:
      - rspec
      - coverage:
          requires:
            - rspec
```

このように、まずは`rspec`ジョブを実行し、その後に`coverage`ジョブを実行するようにしています。

それでは、ファイルの頭から解説していきます。

```.circleci/config.yml
version: 2.1

executors:
  default:
    working_directory: ~/repo
    docker:
      - image: circleci/ruby:2.5.1-node-browsers
    environment:
      RACK_ENV: test
      RAILS_ENV: test

commands:
  setup_packages:
    steps:
      - restore_cache:
          keys:
          - v1-dependencies-{{ checksum "Gemfile.lock" }}
          # fallback to using the latest cache if no exact match is found
          - v1-dependencies-

      - run:
          name: install dependencies
          command: |
            bundle install --jobs=4 --retry=3 --path vendor/bundle

      - save_cache:
          paths:
            - ./vendor/bundle
          key: v1-dependencies-{{ checksum "Gemfile.lock" }}
```

version 2.1から`executors`, `commands`, `parameters`という機能が使用できます。
`executors`では実行環境設定を複数ジョブで使い回すための機能です。

今回は、`rpec`ジョブと`coverage`ジョブで同じ実行環境を使用するため、`executors`で`default`という名前の実行環境を定義して、使い回せるようにしています。

`commands`は複数ジョブで一連のコマンドを使い回すための機能です。
`rspec`ジョブでも`coverage`ジョブでも、必要なGemのインストール等の処理は必要になるため、`commands`にて`setup_packages`という名前で一連の処理をまとめて定義しています。

なお、`perameters`は残念ながら今回は使用していません。

```.circleci/config.yml
  rspec:
    executor:
      name: default
```

`rspec`ジョブの実行環境は前もって`executors`で定義しておいた`default`を使います。

```.circleci/config.yml
    environment:
      PARALLEL_TESTS_CONCURRENCY: 4

    parallelism: 2
```

ここでは、並列実行数を環境変数で設定しています。
また、今回はCircleCIを2つのコンテナで使用することにしましたので、`parallelism`に2をセットしています。
(複数コンテナでのテスト実行は有料となります)

```.circleci/config.yml
    steps:
      - checkout
      - setup_packages

      # Database setup
      - run: bundle exec rails parallel:setup[${PARALLEL_TESTS_CONCURRENCY}]

      # run tests!
      - run:
          name: run tests
          command: |
            mkdir /tmp/test-results
            TEST_FILES="$(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings)"
            bundle exec parallel_rspec -n ${PARALLEL_TESTS_CONCURRENCY} $TEST_FILES

      - run:
          name: Stash coverage results
          command: |
            mkdir coverage_results
            cp -R coverage/.resultset.json coverage_results/.resultset-${CIRCLE_NODE_INDEX}.json

      - persist_to_workspace:
          root: .
          paths:
            - coverage_results

      # collect reports
      - store_test_results:
          path: /tmp/test-results

      - store_artifacts:
          path: /tmp/test-results
          destination: test-results
```

`rspec`ジョブでデータベースを準備して`parallel_rspec`を実行して、テスト結果を保存します。
複数コンテナで`RSpec`を並列実行している場合、SimpleCovはそれぞれのコンテナごとに別個に結果を出力してしまうので、ここでは、出力ファイルを次のジョブで使用できるように`persist_to_workspace`を実行するだけにとどめておきます。

```.circleci/config.yml
  coverage:
    executor:
      name: default

    steps:
      - checkout
      - setup_packages

      - attach_workspace:
          at: .

      - run: bundle exec rails simplecov:report_coverage

      - store_artifacts:
          path: ~/repo/coverage
          destination: coverage
```

`attach_workspace`で`rspec`ジョブで`persist_to_workspace`しておいた、`coverage_results`をカレントディレクトリにアタッチします。

`coverage`ジョブでは`rspec`ジョブで出力された`SimpleCov`の結果を`bundle exec rails simplecov:report_coverage`でマージして、CircleCIのテスト結果から見れるように`store_artifacts`を実行しています。

ここで実行している`simplecov:report_coverage`タスクは自前で作ったタスクです。
`coverage_results`ディレクトリに保存された`SimpleCov`の出力をマージします。

```spec/simplecov_helper.rb
# frozen_string_literal: true

require "active_support/inflector"
require "simplecov"

class SimpleCovHelper
  def self.report_coverage(base_dir: "./coverage_results")
    SimpleCov.start "rails" do
      merge_timeout(3600)
    end
    new(base_dir: base_dir).merge_results
  end

  attr_reader :base_dir

  def initialize(base_dir:)
    @base_dir = base_dir
  end

  def all_results
    Dir["#{base_dir}/.resultset*.json"]
  end

  def merge_results
    results = all_results.reduce([]) { |result, file|
      JSON.parse(File.read(file)).each { |key, value|
        result << SimpleCov::Result.from_hash("#{key}": value)
      }
      result
    }
    SimpleCov::ResultMerger.merge_results(*results).tap do |result|
      SimpleCov::ResultMerger.store_result(result)
    end
  end
end
```

```lib/tasks/simplecov_parallel.rake
# frozen_string_literal: true

require_relative "../../spec/simplecov_helper"
namespace :simplecov do
  desc "merge results"
  task report_coverage: :environment do
    SimpleCovHelper.report_coverage
  end
end
```

# まとめ

もともと開発中のプロジェクトでは、CircleCI2.0で上記の設定を行っていたのですが、今回の記事を書くにあたって、CircleCI2.1で実装された便利な機能を使って書き直してみました。
まだ、ちゃんとキャッチアップできていないのですが`Orbs`を使って設定を共有できるみたいなので、それも試してみたいと思っています。