CircleCI で並列実行した RSpec のカバレッジを Coveralls に送る

[Coveralls](https://coveralls.io/) に以下のドキュメントがあるのだけど、書いてある通りにやってもうまくいかなかったので、なんとかうまくいった方法を書いておきます。

- https://docs.coveralls.io/ruby-on-rails
- https://docs.coveralls.io/parallel-build-webhook

# やりたいこと

RSpec を並列で実行し、すべてのインスタンスの終了を待って、別のジョブで各インスタンスのカバレッジの結果をマージして Coveralls に送信したい。

- [ドキュメント](https://docs.coveralls.io/ruby-on-rails)には `Coveralls.wear_merged!` を呼んで最後に `coveralls:push` を実行しろとあるけれど、`coveralls:push` (`Coveralls.push!`) で使われている `SimpleCov::ResultMerger.merged_result` は *同じ環境で実行された* 複数のテストのカバレッジ結果をマージするものであり、違う環境で実行された結果をマージできるわけではない。
- [Webhookを使う方法](https://docs.coveralls.io/parallel-build-webhook)もあるけれど、テストを実行するジョブと結果を送信するジョブが違うので、結果を送信するときに `payload[build_num]` を指定することができない。

# やり方

https://github.com/lemurheavy/coveralls-ruby

```ruby:Gemfile
group :test do
  gem 'coveralls', '~> 0.8.23', require: false
end
```

## RSpec

`Coveralls.wear!` は Coveralls にカバレッジを送信するけれど、`Coveralls.wear_merged!` は送信しない（[SimpleCov を実行してるだけ](https://github.com/lemurheavy/coveralls-ruby/blob/v0.8.23/lib/coveralls.rb#L22-L27)）。

```ruby:spec/rails_helper.rb
if ENV['COVERALLS_REPO_TOKEN']
  require 'coveralls'
  Coveralls.wear_merged!('rails')
end

# [...]
```

## カバレッジ結果をマージ・送信する Rake Task をつくる

SimpleCov の README にある "[Merging test runs under different execution environments](https://github.com/colszowka/simplecov#merging-test-runs-under-different-execution-environments)" の通りにやればいいのだけど、Coveralls の gem が依存している SimpleCov のバージョンが古くて、`SimpleCov.collate` が存在しない。よって自分でつくる必要がある。

```ruby:lib/tasks/coverage.rake
namespace :coverage do
  desc 'Collate all result sets and push to Coveralls'
  task push: %i[environment collate] do
    require 'coveralls'
    Coveralls.push!
  end

  desc 'Collate all result sets generated by the different test runners'
  task collate: :environment do
    require 'simplecov'

    # coverage/.resultset-*.json を coverage/.resultset.json にまとめる
    # see: https://github.com/colszowka/simplecov/blob/v0.18.5/lib/simplecov.rb#L80

    results = Dir['coverage/.resultset-*.json'].flat_map do |filename|
      (JSON.parse(File.read(filename)) || {}).map do |command_name, coverage|
        SimpleCov::Result.from_hash(command_name => coverage)
      end
    end

    SimpleCov::ResultMerger.merge_results(*results).tap do |result|
      SimpleCov::ResultMerger.store_result(result)
    end
  end
end
```

## CircleCI

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

executors:

  app:
    docker:
      - image: circleci/ruby:2.6.5-node-browsers
        environment:
          BUNDLE_PATH: vendor/bundle
          RAILS_ENV: test

jobs:

  test:
    executor: app
    parallelism: 2
    steps:
      - checkout
      
      # [...]
      
      - persist_to_workspace:
          root: .
          paths:
            - .

      - run:
          name: RSpec in parallel
          command: bin/rspec $(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings)

      # coverage/.resultset.json にカバレッジ結果が生成されているので、
      # 名前がぶつからないように別の名前を付けたファイルを persist_to_workspace する
      - run:
          name: Prepare to collate coverage results
          command: cp coverage/.resultset.json "coverage/.resultset-${CIRCLE_NODE_INDEX}.json"

      - persist_to_workspace:
          root: .
          paths:
            - coverage/.resultset-*.json

  report:
    executor: app
    steps:
      - attach_workspace:
          at: .

      # こんな感じになっているはず
      # ./coverage
      #   .resultset-0.json
      #   .resultset-1.json

      - run: bin/rake coverage:push

workflows:
  version: 2

  test-report:
    jobs:
      - test
      - report:
          requires:
            - test
```

# できた

```
#!/bin/bash -eo pipefail
bin/rake coverage:push
Running via Spring preloader in process 81
[Coveralls] Submitting to https://coveralls.io/api/v1
[Coveralls] Job #132.1
[Coveralls] https://coveralls.io/jobs/XXXXXXXX
Coverage is at 100.0%.
Coverage report sent to Coveralls.
CircleCI received exit code 0
```