Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

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

概要

原著者の許諾を得て翻訳・公開いたします。


  • 2017/05/08: 初版公開
  • 2023/03/01: 更新

テストを不安定にする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などのライブラリで時間を止める方法があります。

訳注

Timecopについては以下の記事もどうぞ。

Rails: 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で事前・事後の環境設定を切り替える

FactoryGirlでtraitを使うとintegration test書くのが捗るという話


  1. 訳注: 原文のhttps://jobs.lever.co/shopify?lever-via=eV7L5-Yackはリンク切れのため、Shopifyの同等のjobページにリンクを変更しました。 

CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。