17
Help us understand the problem. What are the problem?

More than 3 years have passed since last update.

posted at

updated at

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

はじめに

はじめまして、@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

使用gem

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

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ファイルを作成することで設定することができます。

--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つのテスト用データベースが作成されます。

$ 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にアップロードしています。

この設定では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ジョブで同じ実行環境を使用するため、executorsdefaultという名前の実行環境を定義して、使い回せるようにしています。

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_workspacerspecジョブで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を使って設定を共有できるみたいなので、それも試してみたいと思っています。

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
17
Help us understand the problem. What are the problem?