たまに落ちるテストをいい感じにリトライするCircleCI Workflowsの設定

TL;DR

rspec-retryは同じプロセスの中で再実行(問題: まじで落ちてるテストも再実行される、プロセスで保持してる状態が壊れると何度実行してももとには戻らない)

bundle exec rspec

Rerunは別プロセスで再度実行(2連ガチャ)

bundle exec rspec
bundle exec rspec

この記事で紹介してるのは落ちたものに限って別プロセスで再度実行

bundle exec rspec --failure-exit-code=0
bundle exec rspec --only-failures

本題

夏といえばそうめん。流しそうめんって掴みそびれると落ちますよね。

こんかい紹介する.circleci/config.ymlはこちら! たまに落ちるRSpecのexampleを受け止めて、落ちたexampleだけリトライするCircleCIの設定です。 サンプルプロジェクトはGitHubにあります。

なおこの例ではカバレッジとかrspec_junit_formatterの結果のマージ等してないので、そこは各自やっていって良い感じの設定をブログで書いて教えてください❗

# spec/spec_helper.rb
RSpec.configure do
  config.example_status_persistence_file_path = 'spec/examples.txt'
end
version: 2.1

executors:
  ruby:
    docker:
      - image: circleci/ruby

jobs:
  test:
    executor: ruby
    steps:
      - checkout
      - run: gem install rspec
      - run: rspec --failure-exit-code=0
      - persist_to_workspace:
          root: .
          paths:
            - '*'

  test_again:
    executor: ruby
    steps:
      - attach_workspace:
          at: ~/project
      - run: gem install rspec
      - run: rspec --only-failures

workflows:
  test:
    jobs:
      - test
      - test_again:
          requires: [test]

2019/07/12追記: testを並列で流してるとき用

test jobは並列で実行してtest_againでは落ちたテストをまとめて流す

# spec/spec_helper.rb
RSpec.configure do
  config.example_status_persistence_file_path = "spec/examples-#{ENV['CIRCLE_NODE_INDEX']}.txt"
end
# .circleci/config.yml
version: 2.1

executors:
  ruby:
    docker:
      - image: circleci/ruby

jobs:
  test:
    executor: ruby
    parallelism: 2
    steps:
      - checkout
      - run: gem install rspec
      - run: |
          TEST_FILES=$(circleci tests glob spec/**/*_spec.rb | circleci tests split)
          rspec --failure-exit-code=0 -- $TEST_FILES
      - persist_to_workspace:
          root: .
          paths:
            - '*'

  test_again:
    executor: ruby
    steps:
      - attach_workspace:
          at: ~/project
      - run: gem install rspec
      - run: |
          head -2 spec/examples-0.txt > spec/examples.txt
          tail -qn +3 $(ls spec/examples-*.txt) >> spec/examples.txt
          mv spec/examples.txt spec/examples-0.txt
      - run: rspec --only-failures

workflows:
  test:
    jobs:
      - test
      - test_again:
          requires: [test]

2019/07/12追記2: 同じjobの中で再実行

テストを流すためのセットアップに時間がかかるような場合は同じjobの中で再実行するだけでもよさそう

# spec/spec_helper.rb
RSpec.configure do
  config.example_status_persistence_file_path = "spec/examples.txt"
end
# .circleci/config.yml
version: 2.1

executors:
  ruby:
    docker:
      - image: circleci/ruby

jobs:
  test:
    executor: ruby
    steps:
      - checkout
      - run: gem install rspec
      - run: |
          TEST_FILES=$(circleci tests glob spec/**/*_spec.rb | circleci tests split)
          echo $TEST_FILES > TEST_FILES
          rspec --failure-exit-code=0 -- $TEST_FILES
      - run: rspec --only-failures $(cat TEST_FILES)

workflows:
  test:
    jobs:
      - test

たまにおちるテスト問題

Railsあるある稀によく落ちるテストでお悩みの皆さん、CircleCIでテスト実行していますか?

たまに落ちるテストとの向き合い方でこんな感じの問題がありました。

  • たまに落ちるテストは無視できない
  • たまに落ちるテストなのかどうかわからない
  • たまに落ちる原因を探すのが難しい
  • たまに落ちるテストを直すのが難しい
  • たまに落ちるテストのために余計な時間がかかる
    • マジで落ちてるテストを再実行
    • マジで落ちてるテストのタイムアウトが伸びる
    • たまに落ちるテストガチャ、沼、時間溶ける...。

今回はここをよくする方法を紹介します。

たまに落ちるテストのために余計な時間がかかる

以下、既知の手法と問題点についていくつか紹介します(いい感じにリトライするやりかたまで読み飛ばしてもかまいません)。

テストはたまに落ちます

テストは人間が書いています。なのでテストは実質人間です。テストはたまに落ちます、にんげんだもの

テストがたまに落ちると何がよくないのか?

CIを通さないとリリースできない場合にリリースの妨げになってしまいます。 価値が届くまでに時間がかかってしまう。

これは私の話ですが、再度テスト通るまでの間に休憩入って無を過ごしてしまいがち。モチベが下がる。

たまに落ちるテストを人間が無視してもよいのでは?

テストがオオカミ少年みたいな状態だと「なーにどうせたまに落ちるテストだろ」でエイヤッとマージすること、あるものとします。 マジでヤバイときにコードの悲鳴を無視して、エイヤでマージすると、プロダクションが死んだり死ななかったりします。

基本的には無視はよくないです。

既知の手法: コードを直す

たまにおちるテスト、実はプロダクションコードがたまにバグっていることがあります。 プロダクションコードを直して落ちなくなった! やったね!

例: SQLORDER BYを指定しわすれているため、並び順を確認するテストがたまに落ちる

既知の手法: テストを直す

テストコードに問題がある場合。たまに落ちる原因調査にかけるコストに対してわりに合わなくて諦めがち

原因がわからない、外部サービスのせいで落ちてると自力では直せない、などの問題がある。

例: HTMLに外部のCDNのURL埋め込んでたらそのURLが404になっちゃった、たまに503を返す

既知の手法: タイムアウトを伸ばす

例えばCapybaraなどのテストは、たまたま時間がかかってたまに落ちがち。 ならタイムアウト伸ばせばよくね?

全体の実行時間が伸びたり、リアルガチで落ちてるテストが落ちるまでの時間が伸びたり、waitオプション渡す場合どこに時間がかかるか意識してテストコードも直するのが大変。

例: default time outを伸ばしたり重いところでwaitオプションを指定する

既知の手法: rspec-retryを使う

よく知られた方法としてrspec-retryを使う方法がある。

これはたまにじゃなくてリアルガチで落ちてるテストが多いときに無駄にリトライしてテスト時間が伸びるみたいな問題がある。 あと同じプロセスの中で再実行をかけるので、teardownがうまく行ってないと、retryする意味がなくこける。

既知の手法: CircleCIもっかいRerun

たまに落ちるテストガチャ。

全部テストを再実行すると時間がかかる、たまに落ちるテストAが通ったと思ったらたまに落ちるテストB、C、D...がひょっこり出てくるなどの問題がある

今回紹介するいい感じにリトライするやりかた

RSpecのjobを2つに分けて実行します。

1つめのjob

テストを流します。 たまにテストが落ちます。 落ちたテストの情報を2つめのjobにわたします。

2つめのjob

落ちたテストの情報を受けとります。 落ちたテストだけを再実行します。

落ちたテストがない場合、成功して終了します。

また落ちてしまった場合、2つめのjobだけを再実行します。

どこがいい感じか

rspec-retryやテストのjob全体をrerunするのと違い、落ちたテストだけを別プロセスで再実行できる。 手動でRerunする必要がなく、jobをつなげるだけで自動リトライ回数が増やせる。

実例

リポジトリはこちらです。

hanachin/rspec-soumen

たまに落ちるテスト

たまに落ちるテストがあります。このテストは夏日じゃない気温の場合に冷やしそうめんがうまくなくなってテストが落ちます。

# spec/soumen_spec.rb
using Module.new {
  refine(Integer) do
    def 冷やしそうめんがうまい?
      self >= 25
    end
  end
}

RSpec.describe '冷やしそうめん' do
  it { is_expected.to be }

  context '気候変動' do
    let(:temperature) { rand(10..40) }

    subject { temperature.冷やしそうめんがうまい? }

    it { is_expected.to be }
  end
end

CircleCIで以下のような設定をして、jobを2段構えにします。

version: 2.1

executors:
  ruby:
    docker:
      - image: circleci/ruby

jobs:
  test:
    executor: ruby
    steps:
      - checkout
      - run: gem install rspec
      - run: rspec --failure-exit-code=0
      - persist_to_workspace:
          root: .
          paths:
            - '*'

  test_again:
    executor: ruby
    steps:
      - attach_workspace:
          at: ~/project
      - run: gem install rspec
      - run: rspec --only-failures

workflows:
  test:
    jobs:
      - test
      - test_again:
          requires: [test]

1つめのjobと2つめのjobをつなげる

test_again jobのrequirestestを指定し、test jobがおわってからtest_again jobが実行されるようにしています。

workflows:
  test:
    jobs:
      - test
      - test_again:
          requires: [test]

参考: requires

1つめのtest jobでテストが失敗しても、2つめのtest_again jobを実行する

test_againrequiresに指定したjobが失敗した場合、test_again jobは実行されません。

これを防ぐため、テストが失敗しても必ず成功したように見せかけるようRSpec実行時に--failure-exit-codeオプションをわたします。

jobs:
  test:
    # 略
    steps:
      # 略
      - run: rspec --failure-exit-code=0

参考: `--failure-exit-code` option (exit status) - Command line - RSpec Core - RSpec - Relish

失敗したテストの情報を2つめのjobに渡す

RSpecの設定でexample_status_persistence_file_pathを指定すると、テストの実行結果を保存できます。

# spec/spec_helper.rb
RSpec.configure do |config|
  config.example_status_persistence_file_path = "spec/examples.txt"
end

spec/examples.txtをCircleCIのpersist_to_workspaceを使ってtest_again jobに渡します。 ここではcheckoutしてきたソースコードspec/examples.txtなどworking_directory直下のファイルすべてをworkspaceに保存しています。

jobs:
  test:
    # 略
    steps:
      # 略
      - persist_to_workspace:
          root: .
          paths:
            - '*'

参考: Only Failures - Command line - RSpec Core - RSpec - Relishpersist_to_workspace

2つめのjobで失敗したテストの情報を受け取る

attach_workspaceを使って2つめの jobのworking_directory(デフォルトは~/project)に、さきほどpersist_to_workspaceで保存した1つめのjobのworking_directory以下のファイルを戻します。これでソースコード一式とspec/examples.txtを受け取ることができました。

jobs:
  # 略
  test_again:
    # 略
    steps:
      - attach_workspace:
          at: ~/project

参考: attach_workspace

落ちたテストだけを実行する

RSpec--only-failuresオプションを渡して実行するとexample_status_persistence_file_pathで指定したファイルから情報を読み取り失敗したテストだけを再実行できます。やったぜ!

job:
  # 略
  test_again:
    # 略
    steps:
      # 略
      - run: rspec --only-failures

参考: Only Failures - Command line - RSpec Core - RSpec - Relish

実際の流れ

テストが落ちました。2つ目のjobで落ちているようです...。2度テストが落ちたということですね。

f:id:h6n:20190709233223p:plain https://circleci.com/workflow-run/2cced3b3-59c4-4e3e-9f53-33172388b74b

1つめのtest jobで落ちていたテストが

f:id:h6n:20190709233218p:plain https://circleci.com/gh/hanachin/rspec-soumen/6

2つめのtest_again jobでも落ちたようです。 画面をみて分かるように、1つめのtest jobでは2つテストが実行されていましたが、2つめのtest_again jobではtest jobで落ちたテスト1つしか実行されていませんね。

f:id:h6n:20190709233213p:plain https://circleci.com/gh/hanachin/rspec-soumen/7

ワークフローの「Rerun」をクリックして「Rerun from failed」をクリックすると落ちたjobからやり直すことが出来ます。

f:id:h6n:20190709233207p:plain

やりなおしたWorkflowsがこちら。やったぜテスト通った! test jobのリンクをクリックすると分かるのですが、さきほどのtest jobと同じjobの結果に飛びます。 再実行されたのは落ちていたtest_again jobだけです。

f:id:h6n:20190709233202p:plain https://circleci.com/workflow-run/09fb370d-bbdd-4648-acba-0bebe5a7bddf

2019/07/12追記: 並列実行する場合

保存されるファイル名がかぶらないようexample_status_persistence_file_pathのファイル名に環境変数CIRCLE_NODE_INDEXを含めます。

example_status_persistence_file_pathの内容は単純なテキストファイルです。

example_id                         | status | run_time        |
---------------------------------- | ------ | --------------- |
./spec/soumen_brand_spec.rb[1:1]   | passed | 0.00022 seconds |
./spec/soumen_brand_spec.rb[1:2:1] | failed | 0.00022 seconds |
./spec/soumen_spec.rb[1:1]         | passed | 0.00262 seconds |
./spec/soumen_spec.rb[1:2:1]       | passed | 0.00289 seconds |

各コンテナーでのexample_status_persistence_file_pathの内容をまとめたい場合、ヘッダー2行を書き出したあと単純に各ファイルの3行目以降を追記していくといいです。

# ヘッダーを出力
head -2 spec/examples-0.txt > spec/examples.txt

# 3行目以降をまとめて出力
tail -qn +3 $(ls spec/examples-*.txt) >> spec/examples.txt

# ファイル名を1コンテナーの場合のexample_status_persistence_file_pathに合わせる
mv spec/examples.txt spec/examples-0.txt

まとめ

CircleCIのWorkflowsを使ってテストのjobを2つに分けた。

  1. 全てのテストを実行する
  2. 落ちたテストを再実行する

これにより、CircleCIたまに落ちるテストRerunガチャで2のガチャだけを引けるようになり、あたりを引きやすくなりました。

また、テストコードに変更を加えずに落ちたテストだけを再実行できるようになりました。 teardown漏れてて再実行が通らないやばめのテストがあっても安心して再実行できるし、落ちたテストを自動で1回再実行してくれます。 たまに落ちるテストが稀にではなくほぼ毎回落ちる現場の場合でも2のjobを重ねればリトライの実行回数を増やせて安心♡

たまに落ちるテストのために余計な時間をかけるのは生産的ではないので、なんかいい感じのやり過ごし方を見つけて折り合いをつけていきましょう......。

もっといい方法あったらおしえてください。

最近たべたそうめんは冷やし担々麺風そうめん f:id:h6n:20190608144638j:plain

魚肉ソーセージと人参のソーメンチャンプルー f:id:h6n:20190622183539j:plain

いいなと思ったらKyashでお金を下さい
20191128011151
GitHubスポンサーも受け付けています
https://github.com/sponsors/hanachin/