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

Rails: アサーションが動いていないテストを効果的に発見する方法(翻訳)

概要

原著者の許諾を得て、CC BY-NC-SA 4.0 Deedの元で翻訳・公開いたします。

日本語タイトルは内容に即したものにしました。

CC BY-NC-SA 4.0 Deed | 表示 - 非営利 - 継承 4.0 国際 | Creative Commons

追記: その後本記事のアイデアが#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#expectObject#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のようなツールを利用して既存の違反を最初にまとめて記録しておき、既存の違反には少しずつ対処しつつ、新たな違反を即座に取り締まれるようにするのが有益でしょう。

Shopify/deprecation_toolkit - GitHub

🔗 アサーションがないテストには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

上のflunk1行を追加することで、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がオープンされており、メンテナーに拾われるのを待っています。

🔗 まとめ

テストそのものをテストすることには非常に大きなメリットがあり、テストスイート全体への信頼度を高められます。また、個人がオープンソースの遅いテストを最適化して期待される振る舞いを保証することで、オープンソースに貢献する貴重な機会にもなります。私たちが力を合わせてこうした貢献に積極的に取り組めば、オープンソースプロジェクトの信頼性と有用性を強化できるようになります。

関連記事

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

Railsでブラウザテストを「正しく」行う方法(翻訳)


  1. 訳注: flunkは「落第」「退学」「失格」を意味する米国の口語です。Minitestでこのflunkを実行すると、必ずテストが失敗します。 

CONTACT

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