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

Rails: mocktailでテストをモックする方法を詳しく解説します(翻訳)

概要

元サイトの許諾を得て翻訳・公開いたします。

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

testdouble/mocktail - GitHub

Rails: mocktailでテストをモックする方法を詳しく解説します(翻訳)

私は数年前に、Test DoubleのMocktailというライブラリをRuby向けに書きました。リポジトリのREADEMEには、2つのインストール方法を選べる画像付きリンクを用意し、APIドキュメントも完備していますが、具体的なテスト例がなかったので、そのままでは現場で使いにくくなっていました。

久しぶりにmocktailで最初のテストを書いたところだったので、mocktailのかわいいREADMEに驚いた方や、mocktailを活用した単独の単体テストがどのようなものか知りたい方向けに、本記事で共有したいと思います。

🔗 本記事の目標

本日の私は、Atomフィードをフェッチするクラスを書いているところです。このクラスには3つの役割があります。

  1. キャッシュを尊重し、フィードが更新されていなければ処理を終えて脱出する
  2. フィードを解析する
  3. フィードエントリ(および更新されたキャッシュヘッダ)を永続化する

ここに書かれていない4つ目の作業として、3つのタスクを調整する作業もあります。私が最初に書くクラスは、他の3つをオーケストレーションすることになります。すなわち、行うべき唯一の作業は、しかるべき条件セットのもとで、適切な依存関係を適切な方法で識別して呼び出すことです。

🔗 コード

ここではテストに集中したいので、テスト駆動開発の細かな点は省略し、この後書く予定のテストにパスする本編コードだけを以下に示します。

class FetchesFeed
  def initialize
    @gets_http_url = GetsHttpUrl.new
    @parses_feed = ParsesFeed.new
    @persists_feed = PersistsFeed.new
  end

  def fetch(feed)
    response = @gets_http_url.get(feed.url, headers: {
      "If-None-Match" => feed.etag_header,
      "If-Modified-Since" => feed.last_modified_header
    }.compact)
    return if response.code == 304 # 変更なし

    parsed_feed = @parses_feed.parse(response.body)
    @persists_feed.persist(
      feed,
      parsed_feed,
      etag_header: response.headers["etag"],
      last_modified_header: response.headers["last-modified"]
    )
  end
end

ご覧の通り、このコードは私が長年Rubyで実践してきた独特のスタイルに沿っています。

  • 依存関係を保持するためのインスタンス変数がいくつもありますが、「作業単位」というステートはインスタンス変数に保存していません。
    コンストラクタはオブジェクトの設定のみを行い、publicメソッドは必要に応じて多くの無関係なオブジェクトに対して作業を実行します。

  • 命名が「動詞 名詞」スタイルになっています。
    命名で動詞(の三単現)を冒頭に置くことで、今後システムが成長して再利用の機会が増えたときに、「クラスの"コア機能"をむやみに一般化する(単一責任の原則に違反する)」のではなく「クラスの"ダイレクトオブジェクト"をポリモーフィズムで一般化する」ことが促進されるようになります。
    (つまり、たとえばPetsDog(イヌをペットにする)のように動詞が先行する命名ならPetsAnimal(動物をペットにする)への進化が促されますが、DogPetter(イヌ飼育係)のように名詞が先行すると、責務過剰なDogManager(イヌ管理者)に進化がちです。)

  • 私が書くクラスはほとんどの場合、そのクラス名を動詞にしたpublicメソッドを1個だけ持たせるようにしています。

  • 私はサードパーティ依存関係をラップするクラスを書いて、それらの依存関係を手軽に差し替え可能にする具体的なコントラクト(契約)を確立するようにしています。
    最初のうちは、たとえばGetsHttpUrlクラスはhttparty gemへの委譲を、ParsesFeedクラスはFeedjira gemへの委譲を行うだけですが、これらのラッパークラスは今後のカスタマイズを否応無しにカプセル化してくれるようになります。

コードをこのようなスタイルで書くようにすれば、「実際のHTTPリクエスト」「実際のXMLフィード」「実際のデータベースレコード」の詳細を気にせずにmocktailでテストをモックすることで、インタラクティブなテストを非常に手軽に書けるようになります。

🔗 最初に書いたテスト

以下は私が最初に書いたテストです。ここではキャッシングヘッダーが未知で、かつフィードからも返されていない状態を前提としています。
なお、ここではたまたまRailsのActiveSupport::TestCaseを拡張していますが、Minitest::Testを使おうとTLDRを使おうと同じです。

require "test_helper"

class FetchesFeedTest < ActiveSupport::TestCase
  setup do
    @gets_http_url = Mocktail.of_next(GetsHttpUrl)
    @parses_feed = Mocktail.of_next(ParsesFeed)
    @persists_feed = Mocktail.of_next(PersistsFeed)

    @subject = FetchesFeed.new

    @feed = Feed.new(
      url: "http://example.com/feed.xml"
    )
  end

  def test_fetch_no_caching
    stubs {
      @gets_http_url.get(@feed.url, headers: {})
    }.with { GetsHttpUrl::Response.new(200, {}, "an body") }
    stubs { @parses_feed.parse("an body") }.with { "an parsed feed" }

    @subject.fetch(@feed)

    verify {
      @persists_feed.persist(
        @feed, "an parsed feed",
        etag_header: nil, last_modified_header: nil
      )
    }
  end
end

上で使っているmocktail固有のAPIについて説明します。

  • Mocktail.of_next(SomeClass)
    これにクラスを渡すと、そのクラスのフェイクインスタンスを返し、次にそのクラスがnewでインスタンス化されるときにも同じフェイクインスタンスを返します。このように、テストで構成するダブル(double: 身代わり)は、対象のインスタンス変数がコンストラクタで設定するものと同じになります。

  • stubs {...}.with {...} 
    第1のブロックは、subjectが期待するとおりに依存関係を呼び出し、そのスタブを満たす方法で呼び出された場合は、第2のブロックの結果を返します。

  • verify {...} 
    subjectによって呼び出されると期待されている通りに依存関係を呼び出します。呼び出されない場合はアサーションエラーをraiseします。

このテストは、「最初にsetupを実行する」「次にテスト対象の振る舞いを呼び出す」「最後にアサーションを行う」というarrange-act-assert(AAA)パターンにきちんと準拠していることがおわかりでしょう(当然のように思われるかもしれませんが、ほとんどのモックライブラリはこれに違反しています)。

🔗 次に書いたテスト

今度は、キャッシュヘッダーが既知だが古くなっている場合のテストを追加することで、テストをさらに複雑にしました。

def test_fetch_cache_miss
  @feed.etag_header = "an etag"
  @feed.last_modified_header = "an last modified"
  stubs {
    @gets_http_url.get(@feed.url, headers: {
      "If-None-Match" => "an etag",
      "If-Modified-Since" => "an last modified"
    })
  }.with {
    GetsHttpUrl::Response.new(200, {
      "etag" => "newer etag",
      "last-modified" => "laster modified"
    }, "an body")
  }
  stubs { @parses_feed.parse("an body") }.with { "an parsed feed" }

  @subject.fetch(@feed)

  verify { @persists_feed.persist(@feed, "an parsed feed", etag_header: "newer etag", last_modified_header: "laster modified") }
end

今度のテストは長くなりましたが、主な理由は、入力の引数から個別の依存関係まで流れていくドブ泥が増えたためです。

また、テストで使われている文字列が実際のetagや変更日付ではなく、"an etag"や"an last modified"のようなそれらしくない文字列になっていることにお気づきでしょうか。これは意図的にそうしているのです。

実際のデータに似せたテストデータは、一見意味がありそうに思えますが、ここではテスト用文字列そのものに意味があるわけではありません。無意味なテストデータは、無意味であることがひと目でわかるようにしておくべきです(文字列でわざと文法を間違えているのもそのためです)。

テストでは、リレーのバトンとして使われているだけの無意味な値は、そうであることをはっきり示す必要があります。つまり、等しいかどうかを確認するテストにパスするのであれば、"an etag"には文字通りどんな文字列を使ったって構わないのです。

🔗 3番目に書いたテスト

最後のテストは最も簡単です。キャッシュヒットが発生すれば、解析する必要もフィードを永続化する必要もなくなるので、そのまま脱出して終了できます。
実際、ここで実際に主張しているのは、永続化呼び出しが行われないことだけです。

def test_fetch_cache_hit
  @feed.etag_header = "an etag"
  @feed.last_modified_header = "an last modified"
  stubs {
    @gets_http_url.get(@feed.url, headers: {
      "If-None-Match" => "an etag",
      "If-Modified-Since" => "an last modified"
    })
  }.with { GetsHttpUrl::Response.new(304, {}, nil) }

  assert_nil @subject.fetch(@feed)

  verify_never_called { @persists_feed.persist }
end

なお、上のverify_never_calledはmocktailに含まれている機能ではなく、私が今朝自分用に書いた単なるテストヘルパーです(後述)。verify_never_calledはその名の通り、「メソッドが決して呼び出されない」ことを確認します。

ところで、このテストではPersistsFeed#persistが決して呼び出されないことを確認している一方で、ParsesFeedに対するアサーションは避けています。その理由がおわかりでしょうか?

一般に、「何かが起きなかった」というアサーションは時間の無駄だからです。
「システムが行わなかったこと」すべてについて本気でアサーションを網羅しようとしたら、いつまでたってもテストは終わりません。呼び出しが行われなかったことをテストすることがあるとすれば、誤った呼び出しによってリソースが浪費されたりデータが破損したりする可能性がある場合だけです。キャッシュヒット時に空のフィードが永続化されると、どちらのリスクも生じる可能性があります。

🔗 セットアップ

Mocktailを私が行ったようにRailsのコンテキストでセットアップするには、Gemfileファイルにgem "mocktail"を追加してから、test/test_helper.rbファイルに以下のコードを追加します。

module ActiveSupport
  class TestCase
    include Mocktail::DSL

    teardown do
      Mocktail.reset
    end

    def verify(...)
      assert true
      Mocktail.verify(...)
    end

    def verify_never_called(&blk)
      verify(times: 0, ignore_extra_args: true, ignore_arity: true, &blk)
    end
  end
end

個別のセットアップ項目は以下の通りです。

  • include Mocktail::DSL
    stubsverifyを呼び出すときに冒頭にMocktail.を書かずに済むようにするためのものです。

  • Mocktail.reset
    テスト間でmocktailが保持するすべての状態をリセットします。これは、シングルトンメソッドやクラスメソッドを偽装する場合に関係します。

  • verifyメソッドをオーバーライドしています。
    Rails 7.2以降はアサーションが書かれていないテストで警告を表示するようになりましたが、困ったことにMocktail.verifyがアサーションとして認識されません(意味的には確かにアサーションなのですが)。しかもこの設定を無効にするためのオプションがRailsから削除されたので、ここでは単にダミーのassert trueをラップする形でエラーを解消しています。

  • verify_never_called
    これは、verifyメソッドの込み入った設定を手軽に使うための単なるショートハンドです。
    timesオプションは正確な呼び出し回数を指定し、ignore_extra_argsオプションはverifyブロックで指定したよりも引数が多く渡された呼び出しに適用され、ignore_arityオプションは引数の個数が本物のメソッドシグネチャと合わないときのエラーを抑制します)

🔗 mocktailは最高のモックライブラリ

私もさまざまな点で意見が偏っていますが、特にTest Doubleの話になると独断と偏見が甚だしいので、Rubyで使えるモックライブラリの中でもmocktailが最高の出来であることについて並々ならぬ自信があります。mocktailには、本記事で取り上げなかった機能や便利な機能がいろいろ詰まっています。

もちろん、mocktailでテストを書くのに向いていないコードの書き方もたくさんあります(ただし、そうしたスタイルのコードはそもそも分離テストを書けるようになっていないので、個人的な意見を申し上げるなら、そうしたコードのテストではモックライブラリを使うべきではないと思います)。

それはともかく、皆さんも何かコードをでっちあげて楽しんでみてください。🥃

関連記事

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

Rails 7.1: assert_raisesで例外とメッセージをまとめてマッチ可能になった(翻訳)


CONTACT

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