テストを不安定にする5つの残念な書き方(翻訳)
不安定なテストは、日々の苦労を台無しにしてくれる技術上の負債の一部となります。テストが不安定だとCIが赤信号になってしまい、それだけのために新しいコードのリリースを中断してビルドをやりなおすはめになります。実際のコードはどこもおかしくないのに、どこかがおかしいのではないかという疑念が湧くと、ストレスの元になります。
数百人の開発者と5万件のテスト項目があるような大規模案件1では、不安定なテストが混入する可能性がさらに高まります。
本記事で扱うデモの中には、テストの実行順序に関連するものもありますが、そうでないものもあります。テストの実行順序とは何か、それがテストにどう関連するのか。それを確認する一番の方法は、テストの実行順序をランダムにしてみることでしょう。そうすれば、あるテストが他のテストに紐付けられていないことと、テストが実行順序に依存していないことがわかります。
本記事では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
の公開に要する時間がわずか1 msec超え、かつ#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つはremote 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
# test/unit/simple_test.rb
require 'test_helper'
class SimpleTest < ActiveSupport::TestCase
test "something" do
# ...
end
end
まとめ
実行結果の不安定なテストの修正はたいてい面倒なものであり、それだけでブログ記事が1本書けるほどです。ひとまず、こうした不安定な要素をテストに持ち込まないよう注意することをおすすめします。
不安定なテストについてご興味がおありの場合は、以下の英語記事をご覧ください。
- 動画: RSpecのbisectで順序依存テストをあぶりだす
- 不安定なテストをうまく扱い、排除する方法
- Rubyコアで見つかった不安定なテスト#12776 Flaky test case: TestThread#test_thread_name
- 不安定なテストの影響を緩和する: Googleの場合
関連記事
- 訳注: 原文のhttps://jobs.lever.co/shopify?lever-via=eV7L5-Yackはリンク切れのため、Shopifyの同等のjobページにリンクを変更しました。 ↩
概要
原著者の許諾を得て翻訳・公開いたします。