patorashのブログ

方向性はまだない

1つのspecファイルをparallel_testsで並列処理する方法

parallel_testsでテストの並列化していくのが好きなのですが、まだまだ課題に思っていたことがありました。それは、テスト対象ファイルがCPUコア数より少ない場合、フルに並列化できないこと…。

つまり、CPUコア数が4つで、テスト対象ファイルが1つの場合、1つだけでテストして、残りの3つは何もせずに終了するのです。しかもその1ファイルのテストにかかる時間が10分とかあると長くて辛いし勿体無い!

rspecでは、テストが落ちたときに行番号指定でテスト実行できるので、それを応用してできるんじゃないかな?と思い、やってみました。

仮説の検証

rspecは行番号指定のテストを複数渡して動くか?

rspecに一度に行番号指定のファイル一覧を渡してテストができるかどうかを検証してみたところ、成功。

paralle_testsは行番号指定のテストを複数渡して動くか?

次に、parallel_testsに対して一度に行番号指定のファイル一覧を渡してテストができるかどうかを検証してみたところ、失敗…。これは調べてみたところ、parallel_testsのテストケースの分割方法が、デフォルトでファイルサイズのため、ファイルの有無のチェックを行なっているためでした。--group-byオプションをfoundに指定したところ、成功!これでとりあえずparallel_testsでテストケース毎に分散実行できることがわかったので、あとはテストケースを抽出するだけ。

実装する

テストケースを抽出する

テストが落ちたときには行番号付きで落ちたテスト一覧が表示されるので、なんかあるだろうと考えていたところ、テスト結果の出力をjson形式にして、--dry-runオプションをつけたら、実際にはテストを実行せずにテストケースを行番号付きで取得できたので、これを加工することにした。

bin/rspec --dry-run --format json --out tmp/test-results.json spec/models/user_spec.rb

ちなみに、標準出力でjsonを取得しようとしたところ、simplecovが実行されてjson以外の文字列が混ざってしまってjsonにならなかったため、--outオプションでファイルにした。

テストケースのjsonをパースする

テストケースをparseして、テストファイル名と行数を結合すればOK、と思っていたのだけれど、it_behaves_likeを使ってテストの共通化をしているところは、テストファイル名と行番号がshared_examples_forを定義していたところになってしまっていて、ダメそうだった。以下のfile_pathline_numberのところ。

{
  "version": "3.8.0",
  "examples": [
    {
      "id": "./spec/models/user_spec.rb[1:1:3:1]",
      "description": "エラーになること",
      "full_description": "User エラーになること",
      "status": "passed",
      "file_path": "./spec/models/user_spec.rb",
      "line_number": 106,
      "run_time": 2.0e-06,
      "pending_message": null
    },
    {
      "id": "./spec/models/user_spec.rb[1:1:4:1]",
      "description": "並び順が1以下だとエラーになること",
      "full_description": "User behaves like display_order_validation 並び順が1以下だとエラーになること",
      "status": "passed",
      "file_path": "./spec/support/display_order_validation.rb",
      "line_number": 2,
      "run_time": 2.0e-06,
      "pending_message": null
    },
  ],
  # 略
}

代わりに、idがファイル名と個別にテストケースを認識するための文字だと思ったので([1:1:3:1]のやつ)、これを使ってみたところ、成功。

bin/以下にrubyスクリプトを作成

今回は、bin/parallel_rspec_each_exampleというファイルを作成した。実行権限を与えておくこと。

touch bin/parallel_rspec_each_examples
chmod +x bin/parallel_rspec_each_examples

そして、ファイルには以下の内容を記述する。

parallel_testsを使ってテストケース単位でテストを分割して実行する

使う

実際に使ってみる。

テストファイルを1つ指定して並列実行

bin/parallel_rspec_each_examples spec/models/user_spec.rb

テストファイルを複数指定して並列実行

bin/parallel_rspec_each_examples spec/models/user_spec.rb spec/models/group_spec.rb spec/models/article_spec.rb

並列数を指定して並列実行

bin/parallel_rspec_each_examples -n 2 spec/models/user_spec.rb

応用編

CircleCIで落ちたテストの一覧を取得する仕組みと組み合わせる

過去に書いた、落ちたテストの取得方法は以下の記事。

patorash.hatenablog.com

ちなみに以下のはshellがfishの場合なので、他のshellを使っている場合はxargsの指定が異なるかもしれません。

bin/rails runner script/circleci_failed_spec_files.rb -j test | xargs bin/parallel_rspec_each_examples

これで、落ちたテストが1ファイルであろうと、並列実行でテストされるのでローカルでの検証時間が短くて済みます!

Capybara3にアップデートしたらハマったことをまとめる

長いことCapybara2系を使っていた。ようやくCapybara3系にアップデートする暇が取れたので作業に着手したら、めっちゃテスト落ちた…。ので、その原因と対策を書いていく。

whitespaceが含まれるようになった。

以下のブログ記事にも書かれていますが、半角スペースや改行コードが戻り値に含まれるようになりました。

journal.sooey.com

ですので、passのように、改行コードを含めて記述しなければなりません。

expect(page).to have_content 'foo bar' # => fail
expect(page).to have_content "foo\nbar" # => pass

オマケ

Capybara.default_normalize_ws = trueにすれば、今までと同様の評価になる模様。だけれど、Capybara4では消されるっぽいので、上記の対応をしておいたほうがよさそうです。

page.allがdefault_wait_timeまで待つようになった

これは元々な気もするけれど、Capybara2系のときには起きなかった事で、table要素の中身を掘ってるときに起きました。

contents  = page.all('table tbody tr').map do |row|
              row.all('th,td').map do |cell|
                if cell.all('li').count > 0
                  cell.all('li').map(&:text)
                else
                  cell.text
                end
              end
            end

テーブルのセルの中でliタグがあれば、それは配列として取得、みたいな処理をしているのですが、この処理が全く終わらなくなりました。実際には進んではいたみたいなのですが、どうもif cell.all('li').count > 0のところで、liの存在チェックをCapybara.default_max_wait_timeまで待っているようでした。mapで処理しているので、100セルあればCapybara.default_max_wait_time * 100だけ待たなければなりません…。Capybara2のときは即時評価だったと思うんですが…。なんにしろこれはマズいので、allのところのwaitを変えました。

contents  = page.all('table tbody tr').map do |row|
              row.all('th,td').map do |cell|
                if cell.all('li', wait: 0).count > 0 # => 1秒たりとも待たない!
                  cell.all('li').map(&:text)
                else
                  cell.text
                end
              end
            end

原因不明のエラー

落ちてたテストを直したら再現しなくなったのでちょっとわからないのですが、以下のissueと同じエラーが出ていました。

github.com

どうもCapybara3系とTimecopを使っている場合で起きる、とか。未来に行ったりするとよくないとかは書かれていましたが、該当のテストではそんなこともしてませんでした。落ちたテストの次のテストとの間でエラーが起きるのだろうか…など考えていましたが、もう起きなくなったので、とりあえず放置。また再現したらここに書いていることを思い出すためのメモです、これは。

苦労したこと

何気にシングルクォートで囲んでいるやつをダブルクォートに変換してさらに改行コードを入れて…というのは骨が折れる作業だった。大概は目で確認しながら正規表現で置換していったけれど。とはいえ、ずっと気になっていたバージョンアップができてよかった。

CircleCIをPerformance Planに移行した

会社で使っているCircleCIをPerformance Planに移行しました。

きっかけは以下のブログを読んだことでした。

moneyforward.com

弊社でのCIの課題

弊社ではMoney Forwardさんほどの並列数ではないんですが、16コンテナ契約していて、大きいプロジェクトでは8並列、他のプロジェクトでは4並列を基本として、テストしていました。

大きいプロジェクトを16コンテナではなく8コンテナにしたのは、

  • 大きなプロジェクトであってもテストが並列で2つは走るようにしたかった
  • 大きなプロジェクトにコンテナを占有されてしまわないようにするため

です。

昔は早くテストを終わらせたいということで16コンテナ全てを割り当てていたのですが、逆にテスト詰まり(CI待ちがどんどん増えていくこと)が発生してしまい、他のプロジェクトのテストが実行されるのが数時間後になることもありましたが、parallel_testsとknapsack proを使ってさらに並列化と実行時間の最適化を行なった結果、テスト時間が16コンテナ使っていた頃と遜色ないレベルになりました。

そのあたりの記事はここに書いています。

patorash.hatenablog.com

patorash.hatenablog.com

それでも詰まる

これまでの成果で、テスト待ち時間はかなり解消されてはいるのですが、それでも大きなプロジェクトのほうで修正がたくさんあると、時々詰まりが発生していました。テストの結果は早く見たいので、これが自分にとっては結構ストレスでした。

Performance Planの利点

Performance Planのポイントをまとめると、以下の通り。

  • 先払い式の従量課金(クレジットを消費していく)
  • 利用コンテナ数ほぼ無制限
  • リソースプラン(CIを実行するコンテナのCPU、メモリの性能のいいプラン)変更可能
  • 一度Performance Planに移行すると、前のプランには戻れない

利用コンテナ数がほぼ無制限なので、テストが詰まることがありません!テストは全てすぐ実行されます。助かる〜!

副次的効果のコスト削減

弊社の場合は、コストも削減される予定です。16コンテナ使っていたので、月額$50*(16-1)=$750かかっていました。 Performance Planに移行するにあたって、CircleCI Japanに問い合わせを行い、今までの利用状況から概算見積もりを頂いたところ、一月あたり$450程度になると言われました。その後、実際に2週間のトライアルを行わせてもらい、その結果を踏まえてまた見積もって頂いたところ、やはりその程度になりそうということでした。

Performance Planへ移行!

テストの実行時間の削減とコストまで削減できるので移行しない手はない!ということで、移行しました。

デメリットはほとんどないと思いますが、敢えて言うとすれば、請求費用が定額ではなくなるというあたりかなと思います。

問い合わせも日本語でOK

CircleCIはCircleCI Japanがありますから、私のように英語が不得意な方でも大丈夫。CircleCI Japanのウェブサイトから日本語で問い合わせを行いました。全て日本語でメールのやりとりをして、プラン変更できました。本当に助かりました。

CircleCI Japanのページはこちら。 circleci.jp

「はじめてのフロントエンド開発」を写経した(React Native以外)

React、Angular、Vue.js、React Nativeを使って学ぶ  はじめてのフロントエンド開発

React、Angular、Vue.js、React Nativeを使って学ぶ はじめてのフロントエンド開発

BOOK OFFで見かけた。2018年の本だったので、まだ内容的に大丈夫だろうと思い、購入。React, Angular, Vueはほとんど使ったことがなかったので、一通りやってみたかったのでちょうどよかった。

React

Reactを一番最初にやったのでだいぶ印象が薄くなってしまっているが、結構面倒臭い印象を受けた。ただ、ライフサイクルの説明はわかりやすかった。公式チュートリアルはまだやっていないので、今後やっていこうと思う。

Angular

AngularはTypeScriptで開発できるし、CLIが充実していてファイル生成も楽だし、3つの中では一番いいなと思った。フルスタックなのでルールが厳格だからレールに乗って開発しやすい、という印象。最初からやるんならよさげ。

Vue

Vueは初心者向きみたいな印象が何故かあるが、この本では難しく感じた。Vuexの導入部分の説明がだいぶ端折っていたからだと思う。写経していても理解が進まなかったので、Vuexのサイトに行って説明を一通り読んでからもう一度読んだら問題なかった。が、ちょっと無理に詰め込んだ印象…。 まぁ最初からVuexを使うほうがいいということなんだろうけれど、一旦Vuexなしで作った後にVuexを使うように直すとVuexいいよね!ってなると思った。それはそれで手間かもしれんけれど…。

単一ファイルコンポーネント(1つのファイルにhtml,js,cssをまとめられる)にできる点は、ファイルを色々見に行かなくて済むのでいいと感じた。

感想

好みでいえばAngularだったけれど、会社でVueを使ってみようという話もあるので、Vueについても引き続き勉強する。後輩氏に借りたnuxt本ををやっていく。

コンポーネント内でreCAPTCHAを使う場合の注意点

昨日Qiitaに書いたのですが、knockout.jsでreCAPTCHAのURLを貼ってデフォルトの動作に任せると、うまく動きませんでした。 qiita.com

原因はコンポーネントの中で使っているから、コンポーネントの描画が終わる前にreCAPTCHAのデフォルトのonloadイベントが実行されてしまうからだと思います。これは別にknockoutだからとかではなく、コンポーネントを使っているようなライブラリ、フレームワークなら同じことが起きるみたいなので、注意が必要です。

コンポーネントの描画が終わった後に、明示的にreCAPTCHAのrenderを行えば、問題なく動きました。

CircleCIで前に落ちたテストを先に検証するWorkflowを組んだ

昨日、CircleCIで失敗したテストのファイル一覧を取得するという記事を書きました。

patorash.hatenablog.com

今度はそれを使って、CircleCIで前に落ちたテストを先に検証するワークフローを組みました。

こんな感じです。

f:id:patorash:20190208101246p:plain
CircleCIで再テストを先に行うワークフロー

何故そうしたのか?

以前に落ちたテストはまた落ちる可能性が高い、と感じています。全体テストを流すと結果が出るまで長時間かかりますが、以前に落ちたところだけ流すのは数分で済みます。そして、落ちたらそこでテストは終了するので、時間の節約ができる!と思ったわけです。

もし再テストを通過したら全体テストが行われます。そうなると、再テストの後に全体テストとなるので、その分時間はかかります。

CircleCIの設定を行なう

ワークフローを組む

まずは先にワークフローの部分を書いていきます。

version: 2.1

# jobの設定などは後述

workflows:
  build:
    jobs:
      - prepare_test
      - retry_failed_test:
          requires:
            - prepare_test
      - test:
          requires:
            - retry_failed_test

全体テストのジョブ(test)のrequiresに再テストのジョブ(retry_failed_test)を指定しているので、再テストが失敗したら全体テストは流れなくなります。

retry_failed_testを定義する

では、該当ジョブを定義します。

version: 2.1

# executors, commandsは省略

jobs:
  # prepare_test, testは省略

  retry_failed_test:
    executor: rspec
    parallelism: 4
    steps:
      - restore_code_cache
      - run: bundle --path vendor/bundle
      - run: bundle exec chromedriver-update
      - setup_database
      - run:
          name: Run failed tests if exists
          command: |
            FAILED_FILES=$(bin/rails runner script/circleci_failed_spec_files.rb --line)
            if [ -n "$FAILED_FILES" ]; then
              TEST_FILES=$(echo -e "$FAILED_FILES" | circleci tests split --split-by=filesize)
              if [ -n "$TEST_FILES" ]; then
                bin/rspec ${TEST_FILES}
              fi
            fi

      - store_test_results:
          path: tmp/test-results

      - store_artifacts:
          path: /tmp/test-results
          destination: test-results
該当箇所の説明

肝の箇所はここです。

- run:
    name: Run failed tests if exists
    command: |
      FAILED_FILES=$(bin/rails runner script/circleci_failed_spec_files.rb --line)
      if [ -n "$FAILED_FILES" ]; then
        TEST_FILES=$(echo -e "$FAILED_FILES" | circleci tests split --split-by=filesize)
        if [ -n "$TEST_FILES" ]; then
          bin/rspec ${TEST_FILES}
        fi
      fi
  1. 以前のテストで失敗したファイルがあれば、ファイル名を行毎に出力したものを変数FAILED_FILESに入れる
  2. $FAILED_FILESの中身がなければ何もせず終了。
  3. $FAILED_FILESの中身があれば、落ちたテスト一覧を並列テストするために内容をecho -eで出力して、その結果をpipeでcircleci tests split --split-by=filesizeに渡します(--split-byの指定はお好きに変更を)。そうすることで、コンテナ毎にテスト対象ファイル名が変数TEST_FIELSに配布されます。
  4. $TEST_FILESの中身がなければ、そのコンテナでは何もせず終了。
  5. $TEST_FILESの中身があれば、テストを実行。

感想

テストは全体では15分くらいかかるようになってしまいましたが、retry_failed_testのところで落ちたら5分で終わるようになったので、いいかなと思います。もし新規ブランチで初のテストの場合は落ちたテストがないのでretry_faile_testのジョブも1分くらいで終了しますし、問題ないくらいかなと思います。

CircleCIからテストが失敗したファイル一覧を取得するRails Runner作った

表題の通りですが、作りました。

CircleCIで失敗したテストをローカルで流したいのだけれど、わざわざCircleCIのページを見に行くのもあれだし、どうせならガバッと実行したいな〜と思ってCircleCI APIを叩いて取得できるんじゃないかな?と思って調査してみたらできました。

Railsプロジェクトの準備

gemのインストール

gem circleciとgem gitをインストールします。 circleciは、CircleCI APIにアクセスするためのgemで、直接REST Clientとかを使うより便利です。 gitは、現在のブランチ名を取得するために使っています。

また、dotenv-railsを使っていることを前提とします(環境変数をたくさん使うので)

Gemfileに追記します。

group :development, :test do
  gem 'circleci'
  gem 'git', require: false
  gem 'dotenv-rails'
end

bundle installしましょう。

.envにCircleCIのトークンなどの設定

.envファイルに、CircleCIにアクセスするための環境変数を設定します。 これらは自分の環境に合わせて変更をお願いします。

CIRCLE_PROJECT_USERNAME='username'
CIRCLE_PROJECT_REPONAME='reponame'
CIRCLE_TOKEN='******'

config/initializers/circleci.rbの作成

Rails起動時にトークンを設定するようにします。

if Rails.env.development? || Rails.env.test?
  CircleCi.configure do |config|
    config.token = ENV['CIRCLE_TOKEN']
  end
end

Rails Runnerの作成

scriptディレクトリに以下のファイルを追加します。gistで公開しています。

gist959f476c8d0df95f96709bb65264f10f

使い方

ヘルプを見る

ヘルプは-hまたは--helpで見れます。どんな引数を使うのかなどがわかります。

$ bin/rails runner script/circleci_failed_spec_files.rb -h
Usage: circleci_failed_spec_files [options]
    -n, --build_num VALUE            CircleCI Build number(default: nil)
    -l, --[no-]line                  output lines(default: false)
    -j, --job_name VALUE             job name(default: build)
    -b, --branch VALUE               git branch name(default: current_branch)
    -t, --vcs_type VALUE             vcs_type(default: github)
    -h, --help                       Prints this help
--build_num(-n)

CircleCIのビルド番号を指定します。これが指定されている場合は、対象のビルド番号から落ちているテストのファイル一覧を取得します。対象のビルドが成功している場合は、何も返しません。

--[no-]line(-l)

戻ってきたファイル一覧を1行ごとに表示するか、スペース区切りで取得するかです。デフォルトはスペース区切りです。

スペース区切りの場合は直接rspecに渡すときに便利で、行ごと表示の場合はpecoやCircleCIで加工する際などに便利です。

--job_name(-j)

Workflowを使っている場合に実際にテストをしているジョブの名前を指定します。

--branch(-b)

gitのブランチ名を指定します。デフォルトだと現在のブランチを自動で取得します。

--vcs_type(-t)

バージョン管理システムのタイプを指定します。デフォルトはgithubです。bitbucketを使っている場合は、-vcs_type bitbucketとする必要があります。

使い方のサンプル(基本編)

Workflowを使っていない場合

基本はこれでいいです。

$ bin/rails runner script/circleci_failed_spec_files.rb

Workflowを使っている場合

実際にテストしているjobの名前がtestとしますと、以下の通りです。

$ bin/rails runner script/circleci_failed_spec_files.rb -j test

実際に利用する

現在のブランチで失敗したテストをローカルで実行する

$ bin/rspec $(bin/rails runner script/circleci_failed_spec_files.rb)

他のブランチで失敗したテストをローカルで実行する

$ bin/rspec $(bin/rails runner script/circleci_failed_spec_files.rb -b feature-xxx)

ビルド番号を指定してローカルで実行する

$ bin/rspec $(bin/rails runner script/circleci_failed_spec_files.rb -n 1234)

応用編

pecoに渡す

私はfish使いなのでfishの関数を定義しました。

function peco_pipe_rspec
  peco | read line
  if test -n "$line"
    commandline "bin/rspec $line"
  end
  set -e line
end

そして、これにオプション--lineをつけて渡します。

$ bin/rails runner script/circleci_failed_spec_files.rb --line | peco_pipe_rspec
# 選ぶと…
$ bin/rspec spec/features/xxx_spec.rb # エンター押して実行!

CircleCIで落ちたテストが複数ファイルあった場合、全部流すと時間がかかるのでこれは便利です。

おまけ

昨日書いたのですが、fishでCommand Substitutionsを使うと戻り値を1つの文字列と認識してしまうので、工夫が必要です。

patorash.hatenablog.com

$ bin/rspec (bin/rails runner script/circleci_failed_spec_files.rb | string split " ")

これでバッチリ!

導入が多少面倒ですが、便利です。CircleCIのほうでもこれを使って全体テストを流す前に落ちたテストを実行するようにしました。それについては別記事を書こうと思います。また、余裕ができたらgemにしようと思います。

追記:書きました。

patorash.hatenablog.com