追記: その後本記事のアイデアが#51625でRailsにマージされました。
Rails: アサーションが動いていないテストを効果的に発見する方法(翻訳)
Shopifyのコアモノリスには、30万件を超えるテストがあります。これらのテストはアプリケーションコードの大半をカバーしているので、アプリを変更するときに大きな信頼を得られます。しかし一部のテストは10年以上も前に追加されたものだったとしても、すべてのテストが今も変わらずに意図した通りの役割を果たしていると言えるでしょうか?本記事では、実行中に何もテストしていなかったテストをあぶり出して修正したときの方法について説明します。
🔗 通常のminitestテスト
まずは、Minitestのテストを通常通り作成して実行することにしましょう。このテストでは、Order
オブジェクトのステータス遷移を検証します。
def test_order_archive_moves_status_to_archived
order = Order.new
assert_equal(:open, order.status)
order.archive!
assert_equal(:archived, order.status)
end
それではこのテストを実行してみましょう。
# Running:
1 runs, 2 assertions, 0 failures, 0 errors, 0 skips
出力は問題なさそうですし、すべて期待通りです。assert_equal
呼び出しが2回行われ、アサーションの件数は2
と表示されています。
今度は別のテストを見てみましょう。こちらのテストでは、注入されたシリアライザのencode
メソッドが呼び出されるとMyCollection
がシリアライズされることを検証します。このテストではシリアライズの詳細については考慮しないので、テスト用シリアライザとして機能するモックを作成します。
def test_collection_is_using_injected_serializer_to_encode_data
serializer_mock = Minitest::Mock.new
my_collection = MyCollection.new(serializer: serializer_mock)
my_collection.push("data")
expected_serialized_data = "[my_serialized_collection]"
serializer_mock.expect(:encode, expected_serialized_data) do |arg|
assert_equal(["data"], arg)
assert_equal(expected_serialized_data, my_collection.serialize)
end
end
それでは実行してみましょう。
1 runs, 0 assertions, 0 failures, 0 errors, 0 skips
これでは期待通りになっていません!assert_equal
を少なくとも2回呼び出したはずなのに、アサーションの件数が0
になってしまっています。こういうときは、puts
を使ったプリントデバッグ手法を駆使してテストの実行フローをもう少し深追いする絶好のチャンスです。
def test_collection_is_using_injected_serializer_to_encode_data
puts "test starts"
serializer_mock = Minitest::Mock.new
my_collection = MyCollection.new(serializer: serializer_mock)
my_collection.push("data")
expected_serialized_data = "[my_serialized_collection]"
puts "before stubbing the :encode call"
serializer_mock.expect(:encode, expected_serialized_data) do |arg|
puts "before assertions"
assert_equal(["data"], arg)
assert_equal(expected_serialized_data, my_collection.serialize)
puts "after assertions"
end
puts "test ends"
end
テストを実行すると以下の結果が得られます。
# Running:
starting the test
before stubbing the :encode call
test ends
どうやら、このテストではexpect
メソッドに渡されたブロックが実行されておらず、そのせいでアサーションが実行されていないようです。
Minitest::Mock#expect
のドキュメントを改めて見てみましょう。むむ何たること、Minitest::Mock#expect
の使い方を取り違えて、Minitestが提供しているObject#stub
のように使ってしまっていたのでした。
Object#stub
は、ブロックが継続している間メソッド呼び出しをスタブしますが、Minitest::Mock#expect
はObject#stub
と異なり、ブロックを必要とするのは、メソッドに渡された引数に対して追加アサーションを実行する場合だけです。そしてこのテストの場合、ブロックを渡す必要はありません。
それでは#expect
メソッド呼び出しを以下のように修正しましょう。
def test_collection_is_using_injected_serializer_to_encode_data
serializer_mock = Minitest::Mock.new
my_collection = MyCollection.new(serializer: serializer_mock)
my_collection.push("data")
expected_serialized_data = "[my_serialized_collection]"
serializer_mock.expect(:encode, expected_serialized_data, [["data"]])
assert_equal(expected_serialized_data, my_collection.serialize)
end
訳注
上記修正のdiffは以下です。
def test_collection_is_using_injected_serializer_to_encode_data
serializer_mock = Minitest::Mock.new
my_collection = MyCollection.new(serializer: serializer_mock)
my_collection.push("data")
expected_serialized_data = "[my_serialized_collection]"
- serializer_mock.expect(:encode, expected_serialized_data) do |arg|
+ serializer_mock.expect(:encode, expected_serialized_data, [["data"]])
- assert_equal(["data"], arg)
-
assert_equal(expected_serialized_data, my_collection.serialize)
- end
end
それではもう一度テストを実行してみましょう。
1 runs, 1 assertions, 0 failures, 0 errors, 0 skips
やりました!今度はアサーションが確かに実行されています。
🔗 アサーション脱落は予防可能か?
こういうアサーション脱落は、やらかすのは簡単ですが、見つけるのは難しいものです。このアサーションに注意を向けていなければ、あっさりマージされていたかもしれません。こうした問題を未然に防ぐ方法はあったのでしょうか?
同じような状況が再発するのを防ぐために、私たちはMiniTest::Unit::LifecycleHooks#after_teardown
に以下のパッチを当てて、アサーションが実行されなかった場合はテストを失敗させるロジックを追加しました。
module CheckAssertions
def after_teardown
super
return if skipped? || error?
raise(Minitest::Assertion, "Test is missing assertions") if assertions.zero?
end
end
Minitest::Test.prepend(CheckAssertions)
テストをスキップした場合や普通に失敗した場合については、ここでの関心の対象外なので、早期return
を追加しています。それ以外の場合は、アサーションの実行が0
回だとテストが失敗します。
これで、アサーションが脱落しているテストを最初から実行すると、以下の結果が得られます。
1) Failure:
MyTest#test_collection_is_using_injected_serializer_to_encode_data [minitest_example_test.rb:39]:
Test is missing assertions
1 runs, 0 assertions, 1 failures, 0 errors, 0 skips
こうしておけば、アサーションが脱落しているテストがコードベースにマージされ、何もテストされていないことに気づかれないままになる事態をずっと効率よく防げるようになります。
例外をraiseする方法は、新規アプリケーションや小規模なアプリケーションではクリーンな解決手段となりますが、既存の大規模コードベースに対しては、deprecation_toolkitのようなツールを利用して既存の違反を最初にまとめて記録しておき、既存の違反には少しずつ対処しつつ、新たな違反を即座に取り締まれるようにするのが有益でしょう。
🔗 アサーションがないテストには2種類ある
アサーションがないテストと、それらをキャプチャする方法を学んだので、次はアサーションのないテストには2種類あること、つまり本当に壊れているテストと、アサーションを報告しないが有効なテストについて理解しましょう。
🔗 1: 本当に壊れているテスト
第1のカテゴリには、意図したアサーションが実行されないという意味で「本当に壊れている」テストが含まれます。そうしたテスト例については既に説明しましたが、もう1つ見てみましょう。
def test_all_published_posts_should_have_a_reviewer do
published_posts = Post.published.to_a
published_posts.each do |published_post|
assert_predicate published_post.reviewer, :present?
end
end
上のテストは、公開済み投稿の配列をイテレーション(反復処理)して、公開された個別の投稿にレビュアーが存在していることを検証します。
さて問題です、これはアサーションのないテストになりうるでしょうか?
その答えは「場合によってはそうなる」です。
published
スコープがtest環境でレコードを1件も返さないように変更されると、空配列がイテレーションされるためassert_predicate
がまったく呼び出されなくなってしまい、突然アサーションなしのテストに化けてしまう可能性があります。
幸い、私たちが追加したチェックのおかげで、アサーションが実行されない限りこのテストは決してパスしないようになりました。
私は、アサーションのないテストを通知する仕組みに加えて、アサーションを試みる前にテストのセットアップそのものも検証しておくのが好みです。以下に例を示します。
def test_all_published_posts_should_have_a_reviewer do
published_posts = Post.published.to_a
flunk("test requires non-empty published posts collection") if published_posts.empty?
published_posts.each do |published_post|
assert_predicate published_post.reviewer, :present?
end
end
上のflunk
1行を追加することで、published_posts
が空のままテストされることを防げます。なお、必ずしもflunk
を使う必要はなく、例外をraiseしても構いませんし、assert_equal(:open, order.status)
などのアサーション(最初のテストでテスト内のオブジェクトが条件を満たしていることを検証するために追加したアサーション)を使っても構いません。私の場合は、セットアップを検証する場合はflunk
を使うことで、コードの振る舞いを検証するアサーションと区別するのが好みです。
🔗 2: 有効だがアサーションがないテスト
第2のカテゴリに該当するのは、完全に有効でありながら、さっき追加したばかりのアサーションなし例外を誘発してしまうテストです。
この種のテストで最もありがちなのは、以下のような「例外をraiseしたら失敗」「例外をraiseしなければ成功」を期待しているテストです。
def test_passed_if_nothing_raised
MyCode.doesnt_raise
end
アサーションなし例外を追加すると、上のようなテストは例外をraiseして失敗します。
この場合の最も一般的な解決方法は、コードをassert_nothing_raised
でラップして、背後のアサーション件数カウントがインクリメントされるようにすることです。人によってはassert_nothing_raised
を追加するよりもコードの戻り値に対するアサーションを明示的に追加する方法を好むかもしれませんが、その方法でも有効です。
def test_passed_if_nothing_raised
assert_nothing_raised { MyCode.doesnt_raise }
end
レアケースとして、「適切なアサーションが存在しない場合」や「手作りのアサーションヘルパーを使っている場合」があります。そのような場合は、以下のようにアサーション件数カウンタを明示的にインクリメントするのが適切なオプションです。
def test_passed_if_nothing_raised
MyCode.doesnt_raise
# このテストは以下の行が実行されるとパスする
self.assertions += 1
end
これは、テストスイートにアサーション脱落チェックを追加する場合のデメリットです。テストによっては、このチェックをパスするためにテストコードを冗長にしなければならなくなることもあります。しかし、その方がテストも読みやすくなり、テストの意図もより明確になるので、実際にはむしろメリットであると考える人もいます。
🔗 テストの改善はオープンソースプロジェクトに貢献するチャンス
オープンソースソフトウェアのテストを改善すれば、ソフトウェアの内部構造も学べますし、オープンソースにはじめて貢献する方法としてもうってつけです。テストを変更してもメインのコードやテストの改善が壊れるリスクはありませんし、特に、完全に壊れていた(実はアサーションが動いていなかった)テストを修正する改善は、メンテナーから常に歓迎されます。皆さんも、自分の好きなコードベースにアサーション脱落テストがどれぐらい見つかるかをぜひ試してみてください。Railsフレームワークであれば、たとえば#48065のプルリクを参考にできます。
🔗 RSpecの場合はどうか
残念ながら、現在のRSpecにはアサーションカウンタやexpectationカウンタが存在していないので、RSpecで上述のようなロジックをセットアップしてアサーション脱落テストをキャッチするのは現実的ではありません。ただし、これについてissue#740がオープンされており、メンテナーに拾われるのを待っています。
🔗 まとめ
テストそのものをテストすることには非常に大きなメリットがあり、テストスイート全体への信頼度を高められます。また、個人がオープンソースの遅いテストを最適化して期待される振る舞いを保証することで、オープンソースに貢献する貴重な機会にもなります。私たちが力を合わせてこうした貢献に積極的に取り組めば、オープンソースプロジェクトの信頼性と有用性を強化できるようになります。
関連記事
-
訳注: flunkは「落第」「退学」「失格」を意味する米国の口語です。Minitestでこの
flunk
を実行すると、必ずテストが失敗します。 ↩
概要
原著者の許諾を得て、CC BY-NC-SA 4.0 Deedの元で翻訳・公開いたします。
日本語タイトルは内容に即したものにしました。
CC BY-NC-SA 4.0 Deed | 表示 - 非営利 - 継承 4.0 国際 | Creative Commons