🐫

RSpec で seed 固定して flaky test をやっつけましょう

に公開

この記事について

RSpec で書いたテストを CI で実行する際、ときどき失敗するテストがあったりしないでしょうか? いわゆる flaky test と呼ばれるものです。
これに何も考えずに re-run により成功を観測してヨシッ! みたいな運用しているのはもったいないので直しましょうという内容です。

config.order よび Kernel.srand の設定

といっても大したことでなく、 RSpec により生成される設定ファイルの以下の箇所を有効にしましょうという話でしかありません。

spec/spec_helper.rb
  # Run specs in random order to surface order dependencies. If you find an
  # order dependency and want to debug it, you can fix the order by providing
  # the seed, which is printed after each run.
  #     --seed 1234
  config.order = :random

  # Seed global randomization in this process using the `--seed` CLI option.
  # Setting this allows you to use `--seed` to deterministically reproduce
  # test failures related to randomization by passing the same `--seed` value
  # as the one that triggered the failure.
  Kernel.srand config.seed
=end

この設定を有効にしておくと、ランダムに失敗するテストをあとから再現させることができます。
以下は日本語でコメントの箇所を訳したものです。

順序の依存関係を明らかにするために、スペック(テスト)をランダムな順序で実行します。
もし順序の依存関係が見つかり、デバッグしたい場合は、各実行後に表示されるシード値を指定することで、順序を固定できます。
  --seed 1234

このプロセスでグローバルなランダム化を行うには、--seed コマンドラインオプションを使用してください。
ランダム化に関連するテストの失敗を再現する際に、失敗を引き起こしたものと同じ --seed の値を指定することで、決定論的に再現することができます。

ちなみに generate した直後のファイルの状態では =begin から =end までの間に記載されていると思われます。
つまりコメントアウトされているので、有効化する場合はこれらの外に配置してください。

https://github.com/rspec/rspec-core/blob/v3.13.2/lib/rspec/core/project_initializer/spec/spec_helper.rb#L86-L97

=begin
  ここは
  # 全部コメント
  です
=end

flaky test 対応の実践

以下では、試しにどんな流れで flaky test が対応されるかを実践しています。

rails new からの準備

# rails アプリケーション作成
$ rails new --skip-test rspec-rails-test
$ cd rspec-rails-test/

# rspec-rails の導入および初期設定
$ bundle add rspec-rails --group development,test
$ rails generate rspec:install

続いて、例の config.order よび Kernel.srand に関する設定を有効化します。
=begin =end の間に配置されているので、これを外に出す形に調整しましょう。

spec/spec_helper.rb
   # Print the 10 slowest examples and example groups at the
   # end of the spec run, to help surface which specs are running
   # particularly slow.
   config.profile_examples = 10
+=end

   # Run specs in random order to surface order dependencies. If you find an
   # order dependency and want to debug it, you can fix the order by providing
   # the seed, which is printed after each run.
   #     --seed 1234
   config.order = :random

   # Seed global randomization in this process using the `--seed` CLI option.
   # Setting this allows you to use `--seed` to deterministically reproduce
   # test failures related to randomization by passing the same `--seed` value
   # as the one that triggered the failure.
   Kernel.srand config.seed
-=end
 end

試しにここで bundle exec rspec を実行してゼロ件のテストが成功することを確認しておきます。

$ bundle exec rspec
No examples found.

Randomized with seed 37597


Finished in 0.00031 seconds (files took 0.09676 seconds to load)
0 examples, 0 failures
                                                                                                                                                                                                                                                            Randomized with seed 37597

$

モデルを作ってテストも記述

テストの対象であるモデルをひとつ作ってみます。
年月を意味する、 YearMonth モデルとして、属性は yearmonth を有します。
それぞれに簡単にバリデーションも記載します。
only_integer 、つまり整数のみを許容し、 month の方にはさらに 1-12 の範囲のみを許容する形に構成します。

app/models/year_month.rb
class YearMonth
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :year, :integer
  attribute :month, :integer

  validates :year, numericality: { only_integer: true }
  validates :month, numericality: { only_integer: true, in: (1..12) }
end

つづいて YearMonth モデルに対するテストを用意します。
といっても、作成したモデルが valid? であることを確認するだけの簡単なものです。

spec/models/year_month_spec.rb
require "rails_helper"

RSpec.describe YearMonth do
  subject { YearMonth.new(attributes) }

  let(:attributes) { { year:, month: } }
  let(:year) { rand(2100) }
  let(:month) { rand(12) }

  it do
    expect(subject.valid?).to be_truthy
  end
end

テストを実行

そして bundle exec rspec でテストが動作することを確認します。

$ bundle exec rspec

Randomized with seed 54242
.

Finished in 0.0191 seconds (files took 0.92096 seconds to load)
1 example, 0 failures

Randomized with seed 54242

$

たまに失敗するのを観測する

実はこのテストはときどき失敗することがあります。
何回か実行すると、以下のように異常終了が確認できるはずです。

プロダクトのコードで、元気に正常終了している CI が時々失敗を通知してくるイメージですね。

$ bundle exec rspec

Randomized with seed 30925
F

Failures:

  1) YearMonth is expected to be truthy
     Failure/Error: expect(subject.valid?).to be_truthy

       expected: truthy value
            got: false
     # ./spec/models/year_month_spec.rb:11:in 'block (2 levels) in <top (required)>'

Finished in 0.01313 seconds (files took 0.50544 seconds to load)
1 example, 1 failure

Failed examples:

rspec ./spec/models/year_month_spec.rb:10 # YearMonth is expected to be truthy

Randomized with seed 30925

$

失敗を再現する

失敗したときのメッセージについて、以下の箇所に注目します。

Randomized with seed 30925

この、シード値 (今回の場合) 30925 がポイントになります。
この値を、コマンドラインオプション --seed に指定して実行すると先程の乱数の結果が再現できるため確実に再現できるようになります。

$ bundle exec rspec --seed 30925

Randomized with seed 30925
F

Failures:

  1) YearMonth is expected to be truthy
     Failure/Error: expect(subject.valid?).to be_truthy

       expected: truthy value
            got: false
     # ./spec/models/year_month_spec.rb:11:in 'block (2 levels) in <top (required)>'

Finished in 0.02331 seconds (files took 0.90374 seconds to load)
1 example, 1 failure

Failed examples:

rspec ./spec/models/year_month_spec.rb:10 # YearMonth is expected to be truthy

Randomized with seed 30925

$

デバッグによる原因特定

再現さえできれば、あとは素直にデバッグするだけです。
ここでは愚直に puts を入れて attributes の状態を確認しています。

spec/models/year_month_spec.rb
 require "rails_helper"

 RSpec.describe YearMonth do
   subject { YearMonth.new(attributes) }

   let(:attributes) { { year:, month: } }
   let(:year) { rand(2100) }
   let(:month) { rand(12) }

   it do
+    puts "attributes: #{subject.attributes.inspect}"
     expect(subject.valid?).to be_truthy
   end
 end
$ bundle exec rspec --seed 30925

Randomized with seed 30925
attributes: {"year" => 1913, "month" => 0}
F

Failures:

  1) YearMonth is expected to be truthy
     Failure/Error: expect(subject.valid?).to be_truthy

       expected: truthy value
            got: false
     # ./spec/models/year_month_spec.rb:12:in 'block (2 levels) in <top (required)>'

Finished in 0.01298 seconds (files took 0.50863 seconds to load)
1 example, 1 failure

Failed examples:

rspec ./spec/models/year_month_spec.rb:10 # YearMonth is expected to be truthy

Randomized with seed 30925

$

month の値が 0 (ゼロ) ですね。

attributes: {"year" => 1913, "month" => 0}

rannd(12) の箇所が間違っていました。
この場合、 0 から 11 までの範囲の乱数になってしまっています。

  let(:month) { rand(12) }
> 10000.times.map { rand(12) }.uniq.sort
=> [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]

あとは簡単、直してしまいましょう

原因のしっぽを掴んだので、あとはシュッ!と直してしまえば解決です!

spec/models/year_month_spec.rb
 require "rails_helper"

 RSpec.describe YearMonth do
   subject { YearMonth.new(attributes) }

   let(:attributes) { { year:, month: } }
   let(:year) { rand(2100) }
-  let(:month) { rand(12) }
+  let(:month) { rand(1..12) }

   it do
     expect(subject.valid?).to be_truthy
   end
 end
> 10000.times.map { rand(1..12) }.uniq.sort
=> [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]

あとはコミットコメントを積んで CI に流すなりしてしまいましょう!

git commit -m 'fix flaky test'

まとめ

  • RSpec での spec/spec_helper.rbconfig.order = :random および Kernel.srand config.seed を有効にすると flaky test 退治が捗ります。
  • 失敗したときの出力からシード値を確認し、 --seed オプションに指定することで失敗を再現できます
  • 割れ窓にせずコツコツ直し、本当に捕まえるべき失敗を認知しやすい状態を作りましょう

Discussion