Rails: mocktailでテストをモックする方法を詳しく解説します(翻訳)
私は数年前に、Test DoubleのMocktailというライブラリをRuby向けに書きました。リポジトリのREADEMEには、2つのインストール方法を選べる画像付きリンクを用意し、APIドキュメントも完備していますが、具体的なテスト例がなかったので、そのままでは現場で使いにくくなっていました。
久しぶりにmocktailで最初のテストを書いたところだったので、mocktailのかわいいREADMEに驚いた方や、mocktailを活用した単独の単体テストがどのようなものか知りたい方向けに、本記事で共有したいと思います。
🔗 本記事の目標
本日の私は、Atomフィードをフェッチするクラスを書いているところです。このクラスには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
stubs
やverify
を呼び出すときに冒頭に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でテストを書くのに向いていないコードの書き方もたくさんあります(ただし、そうしたスタイルのコードはそもそも分離テストを書けるようになっていないので、個人的な意見を申し上げるなら、そうしたコードのテストではモックライブラリを使うべきではないと思います)。
それはともかく、皆さんも何かコードをでっちあげて楽しんでみてください。🥃
概要
元サイトの許諾を得て翻訳・公開いたします。
日本語タイトルは内容に即したものにしました