Ruby: テストを不安定にする5つの残念な書き方(翻訳)

こんにちは、hachi8833です。今回はテストを正しく書く方法を解説する記事の翻訳をお送りします。 概要 原著者の許諾を得て翻訳・公開いたします。 元記事: Five ways to write a flaky test 著者: Kir Shatrov(@kirshatrov) テストを不安定にする5つの残念な書き方(翻訳) 不安定なテストは、毎日の苦労を台無しにしてくれる技術上の負債の一部となります。テストが不安定だとCIが赤信号になってしまい、それだけのために新しいコードのリリースを中断してビルドをやりなおすはめになります。実際のコードはどこもおかしくないのに、どこかがおかしいのではないかという疑念が湧くと、ストレスの元になります。 数百人の開発者と5万件のテスト項目があるような大規模案件では、不安定なテストが混入する可能性がさらに高まります。 本記事で扱うデモの中には、テストの実行順序に関連するものもありますが、そうでないものもあります。テストの実行順序とは何か、それがテストにどう関連するのか。それを確認する一番の方法は、テストの実行順序をランダムにしてみることでしょう。そうすれば、あるテストが他のテストに紐付けられていないことと、テストが実行順序に依存していないことがわかります。 本記事ではMiniTestを用いますが、どの問題もフレームワークに依存しないものばかりなので、MiniTestであるかRSpecであるかを問わず、一般的に応用できます。 1. ランダムなファクトリー # emailフィールドにはunique制約がかかっているとする 10.times do Customer.create!(email: Faker::Internet.safe_email) end どこに問題があるかおわかりでしょうか。 このテストはほとんどの場合パスしますが、ごくたまに、既に使ったメールアドレスがFakerからもう一度返されることがあります。そしてunique制約エラーに引っかかってテストがクラッシュするというわけです。 正しい書き方はこうです。 10.times do |n| Customer.create!(email: Faker::Internet.safe_email(n.to_s)) end Fakerに渡す引数を、(デフォルトの)ランダムなメールではなく、n番目のメールを指定するのがポイントです。 2. データベースのレコード順 assert_equal([1, 2, 3], @products.pluck(:quantity)) このテストもほぼすべてのケースでパスしますが、ORDERなしのSELECTクエリではレコードの順序が保証されません。こうしたランダムな要素が原因のエラーを回避するには、次のように明示的に順序を指定します。 assert_equal([1, 2, 3], @products.pluck(:quantity).sort) # または assert_equal([1, 2, 3], @products.order(:quantity).pluck(:quantity)) 3. グローバル環境の汚染 BulkEditor.register(User) do attributes(:email, :password) end assert_equal [:email, :password], BulkEditor.attributes_for(@user) 私の経験した例では、登録されたモデルのリストをBulkEditorがグローバルな環境に保存していたことがありました。これでテストを実行するとレジストリが汚されてしまい、その後に実行される他のテストが影響されてしまいます。順序依存の一丁上がりです。 解決方法は次のとおりです。 setup to BulkEditor.register(User) do attributes(:email, :password) end end teardown do BulkEditor.unregister(User) end 私の経験したグローバル環境汚染からもうひとつご紹介しましょう。 test “something” do SomeGem::VERSION = ‘9999.99.11’ assert_not @provider.supported? end このテストコードの後に実行されるテストでは、SomeGem::VERSIONから返る値がおかしくなってしまいます。さらに、Rubyレベルでのワーニング「warning: already initialized constant SomeGem::VERSION」が表示されます。 解決方法は次のとおりです。 test “something” do # 変更された定数値をブロックだけが受け取るようにする stub_constant(SomeGem, :VERSION, ‘9999.99.99’) do assert_not @provider.supported? end end 4. 時間に依存するテスト post = publish_delayed_post assert_equal 1.hour.from_now, post.published_at このテストは通常であればパスしますが、ごくたまに、postの公開に要する時間が1msecを超え、かつ#published_atの所要時間が1.hour.from_nowを超えることがあります。 こういう場合に便利なのは、#assert_in_deltaという特殊なヘルパーメソッドです。 post = publish_delayed_post assert_in_delta 1.hour.from_now, post.published_at, 1.second 他にも、Timecopなどのライブラリで時間を止める方法があります。 5. requireに依存するテスト テスト用のクラスが2種類あり、1つはリモートHTTP呼び出しを許可するがもう1つは許可しないとします。以下のような感じのコードになるでしょう。 # test/unit/remote_api_test.rb require ‘remote_test_helper’ class RemoteServiceTest < ActiveSupport::TestCase test “something” do # … end end # test/unit/simple_test.rb require ‘test_helper’ class SimpleTest < ActiveSupport::TestCase test “something” do # … end end 外部へのHTTP呼び出し用に#remote_test_helperを使うテストが大量にあるとします。私見では、こうしたテストは単独であればおそらく完璧に動作します。しかしCIですべてのテストを通しで実行すると、テストの順序によってはリモート呼び出しの後のあらゆるテストで外部呼び出しができてしまう可能性があります? requireはグローバルであり、グローバルな状態を変更します。このことを肝に銘じておいてください。 よりよい解決方法は、特定のテストのコンテキストのみを変更するマクロを使うことです。 # test/unit/remote_api_test.rb require ‘test_helper’ class RemoteServiceTest < ActiveSupport::TestCase allow_remote_calls! test “something” do # … end end # … Continue reading Ruby: テストを不安定にする5つの残念な書き方(翻訳)