Test-aware Design
概要
ソフトウェアのテストにおいて、スタブやモックといったテストダブルの使用を完全に避けることは難しい。 一方であまり無計画にテストダブルを乱用すると、テストコードがテスト対象コンポーネントの内部詳細に密結合し、テストの保守性を下げやすい。 ここではテストダブルの使用を最小限にしつつ、内部実装に依存しにくいテストを構築するための方策を検討する。
- 方策1: テストダブルをちゃんと設計する。
- 方策2: アプリケーション自身にテストのための機能を持たせる。
免責事項
- 後半 (方策2) はアイディアのメモであり、実用性を試してはいない。
- サンプルコードは Ruby だが、特に Ruby に閉じた話ではない。
- よって以降では何らかの処理のまとまりを雑に「コンポーネント」と呼ぶが、大抵の言語ではクラスに相当する想定。
課題: 間接的な依存関係に対するテストダブル
テストダブルの用途は主に、以下のいずれかに分類できると思う。
- 1: 外部環境からの隔離: 外部サービスのAPIコールやメール送信など、テストのたびに走らせたくない処理を置き換える。
- テスト対象のコンポーネントが依存する他のコンポーネントを、以下の目的で置き換える。
- 2: 依存関係からの隔離: テストが依存コンポーネント側の実装ミスやパフォーマンス等に左右されるのを防ぐ。
- 3: 依存関係のコントロール: 依存コンポーネントの振る舞いを変え、エッジケースなどの各種パターンを再現する。
- 4: 省力化: 依存コンポーネントを正しくセットアップする手間やコストを省く。
- 5: 過程の検証: テスト対象の振る舞いをその結果から検証することが難しい場合に、結果ではなく過程の検証で代替する。
- 例えば期待通りにキャッシュを使えているかの検証として、 テスト対象コンポーネントがキャッシュにのみアクセスし、値の計算処理を呼び出していないことを確認するなど 1。
工夫次第でテストダブルの使用は減らせるだろうが、このうち特に「外部環境からの隔離」や「依存関係のコントロール」では、テストダブルを使う以上に現実的な手段がないことも少なくない。
とはいえどのような目的であれ、直接的な依存関係をテストダブルに置き換えるだけなら、そこにあまり課題はない。 適宜テストダブルを用意し、それを使ってテスト対象のコンポーネントを動かせばいい。
例えば下記は架空のブックマークサービスあるいはニュースアプリのコードで、ユーザーのお気に入り記事に基づいておすすめを提案する。 おすすめの提案には、AIや機械学習に特化したマイクロサービスのAPIを使う。
class Articles::AiRecommender
def initialize(ai_api: AiApi.instance)
@ai_api = ai_api
end
def recommend_for(user)
api_payload = build_api_params(user.config, user.favorites)
result = @ai_api.recommend_articles(api_payload)
build_recommendation_result(user, result)
end
end
テストではマイクロサービスへのAPIコールが走らないようにしたい。これは素直にテストダブルを使えば済む。
(コードはイメージ)
test "build recommendations by API based on user favorites" do
ai_api = new_double(AiApi)
ai_api.stub(:recommend_articles, success_response)
recommender = Articles::AiRecommender.new(ai_api: ai_api)
result = recommender.recommend_for(user)
assert_eq(result, expected_result)
end
面倒なのは、テスト対象コンポーネントの依存関係の依存関係のような、間接的な依存先が持つ処理をテストダブルに置き換えたいケースだ。 それを行うには内部実装の詳細に踏み込み、どのコンポーネントが何に依存してどんな処理をするのかを把握して、テストダブルを設定せねばならない。
# 例の続き: 先程の AiRecommender は単体でも使われる一方で、
# AI以外の複数のおすすめロジックと組み合わせた汎用おすすめリストもある。
class Articles::Recommendations
def initializer(
trending_finder: Articles::TrendingFinder.new,
category_recommender: Articles::CategoryRecommender.new,
ai_recommender: Articles::AiRecommender.new,
default_recommender: Articles::DefaultRecommender.new
)
# ...
end
def list_for(user)
result = Result.new
# ... トレンド記事、カテゴリベースのおすすめ記事を追加。
# AIによる提案記事を追加。あくまで補助機能のため、失敗しても中断しない。
# 成否両方のパターンをテストするには、 AiRecommender内部のAPI結果をコントロールする必要がある。
begin
result.articles += @ai_recommender.recommend_for(user).articles
rescue Articles::AiRecommender::Error => error
@logger.warn("failed to recommend articles by AI: #{error}")
result.falures << :ai_recommendation
result.articles += @default_recommender.recommend_for(user).articles
end
result.fix
end
end
# Articles::Recommendations
# depends on
# Articles::AiRecommender
# depends on
# AiApi
テスト自体は、 AiApi をテストダブルに置き換えれば難なくできる。
しかし本来ならこのAPIクライアントは Articles::Recommendations のインターフェイスには直接現れない。
にも関わらず、テストではその暗黙的な依存関係を把握してテストダブルに置き換える必要がある。
もしくは明示的な依存関係に追加し、APIクライアントをリレーする。
test "build recommended articles" do
ai_api = new_double(AiApi)
ai_api.stub(:recommend_articles, success_response)
# 何らかの方法でテストダブルを注入する。
Articles::Recommendations.new(
ai_api: ai_api,
# Or ai_recommender: Articles::AiRecommender.new(ai_api: ai_api),
# ...
)
# ...
end
# APIコールの失敗ケースもテストする。
test "build recommended articles with fallback when AI recommendation fails" do
ai_api = new_double(AiApi)
ai_api.stub(:recommend_articles, raise: ApiError.new(:intertnal_server_error))
Articles::Recommendations.new(
ai_api: ai_api,
# ...
)
# ...
end
このようにテストダブルに置き換えたい処理が依存関係の奥の方にある場合、そもそもテストダブルが必要であることにまず気づきづらかったり、単純にテストダブルを使うと依存関係の内部実装と密結合してしまったり、といった課題につながりやすい。
全部テストダブルにする?
もし開発チームが「常に直接の依存関係のみをテストダブルに差し替えてテストする」方針で合意するなら、前述の課題は発生しない。 直接の依存関係が全てテストダブルになるなら、その更に先の依存関係については気にしなくて済む。
test "build recommended articles with fallback when AI recommendation fails" do
ai_recommender = new_double(Articles::AiRecommender)
ai_recommender.stub(:recommend_for, raise: AiRecommender::Error.new('test'))
Articles::Recommendations.new(
ai_recommender: ai_recommender,
# ...
)
# ...
end
この方針は十分有用だと思うが、各コンポーネントを完全に隔離して行うテストが大半になると、コンポーネント間の結合において生じる問題を見逃しやすくなるし、テストの冗長性が減じてしまう。 テストダブルの使用を本当に必要なケースに絞ってテストできるなら、その方がより堅牢なテストになりやすいと自分は感じる。
頑張ってテストダブルを最小限にする?
必要な時だけ局所的にテストダブルを使う場合、いくつか方法がある。以下は何らかのAPIコールを置き換える例だ。
- APIクライアントのテストダブルを外から渡し、それを使うコンポーネントまでひたすらリレーしていく。
- クラスのコンストラクターで依存関係を受け取るとか。
- テスト実行前に、APIクライアントのインスタンスがテストダブルとなるよう設定する。
- DIコンテナーにテストダブルを登録するとか。
- RSpec の
allow(SomeApi).to receive(:new).and_return(stub)とか。
- APIコールを自動的に代替物に置き換える。
- APIリクエストをキャッチし、実際のリクエストを投げずフェイクのレスポンスを返すとか。
- フェイクのレスポンスを返すデモサービスを実際に動かし、そこと通信するとか。
しかしどの方法にせよ、テストダブルを正しく使うには以下の内部実装の知識が必要になってしまう 2。
- 依存関係のどこかにあるコンポーネントが、特定のAPI (クライアント) に依存している、という知識。
- そのAPI (クライアント) がどのように使われていて、どんな風に置き換えれば期待する挙動になるか、という知識。
テストダブルを使う箇所が増えるほど、その知識は広く漏れる。
あるいは、テスト時にはそのAPIコールがデフォルトでテストダブルに置き換わるようにしておく手もある。 これなら各テストが個別にテストダブルを設定する必要はなく、内部実装の知識もいらない。 実際これで十分なケースは多いが、中にはAPIコールの結果 (成否やレスポンス内容) が、依存関係を通じてテスト対象コンポーネントの振る舞いに影響を与えるケースもある。 このようなケースでAPIの結果に応じた振る舞いを検証したい場合には、何らかの方法で間接的な依存関係の中にあるAPIコールの結果をコントロールしなければならない。 そしてコントロールするには、APIクライアントの使われ方あるいはリクエスト・レスポンスの構造といった知識が欠かせない。 テスト対象コンポーネントの振る舞いがそのAPIの結果に依存するなら、テストコードが「処理内のどこかでそのAPIが呼ばれる」程度の知識を持つことは自然かもしれないが、具体的な実装方法にまで依存する必要は本来ないはずだ。
課題のまとめ
ここまでをまとめると、以下の状況でテストダブルを使う時に、関連する内部実装の知識が露出しがちで辛くないか、というのが今の懸念だ。
- テストダブルの使用は最小限とし、外部APIコールなど置き換えざるを得ない処理に対してのみ使う方針である。
- テスト対象コンポーネントの間接的な依存関係の中にテストダブルを必要とする処理があり、かつその処理の複数パターンをテストしたい。
現実的にはこのような状況が多くはなく、かつ内部詳細の露出は許容できるレベルなのかもしれない。 ここではそれを踏まえた上で、恐らくは現実的かつ一般的な方策1と、エクストリームな方策2を検討してみる。
方策1 (basic): テストダブルをちゃんと設計する
テストダブルの使用が内部実装への密結合につながるのは、テストダブルのインターフェイスあるいはその使い方によるところが大きい。 多くのプログラミング言語ではテストダブルを簡単に作るライブラリが存在していて、それを使うとほんの数行でテストダブルを作れたりする。 下記は Ruby の RSpec の例だ。
# enabled?, recommend_articles メソッドを置き換えるテストダブル。
ai_api = instance_double(
AiApi,
enabled?: true,
recommend_articles: fake_articles(params),
)
allow(AiApi).to receive(:new).and_return(ai_api)
テストダブル自体はその性質上、対象クラスと当然密結合になる。 問題なのはその密結合さをそのまま露出するテストダブルの構築コードが、各テストケースに散らばることだ。 よって頻繁に使われるテストダブルは、その内部詳細を隠蔽するテスト用コンポーネントとして定義されるのが望ましい。 例えば以下のように、内部実装には踏み込まず期待する振る舞いだけを指定可能なテストダブルがあれば、状況は大きく改善する。
# 処理が常に成功するデフォルトのテストダブル。大抵は何も考えずにこれを使えばいい。
MockAiApi.new
# 挙動をコントロールしたい場合には引数で指定する。
# ただしAPIクライアントのメソッド名等を直接露出せず、あくまで期待する振る舞いを指定させる。
MockAiApi.new(mode: :succeed_to_recommend, articles: fake_articles(params))
# 失敗についても同様。
MockAiApi.new(mode: :fail_to_recommend, reason: :service_down)
内部実装の知識はテストダブルの中に隠蔽され、テストダブルの使用者がそれを知る必要はなくなる。 各テストケースは、特に複数の振る舞いを検証したい場合には、依然として明示的にテストダブルを使う必要はある。 しかし内部実装との密結合が避けられていれば、課題はずっと小さくなる。
恐らくはこれが、前述した課題のあるべき解決策ではないかと思う。
次に、この方針を拡張したより過激な案を検討してみたい。
方策2 (extreme): アプリにテストのユースケースを組み込む
方策1ではテストダブルに振る舞いベースのインターフェイスを持たせることで、メインの課題を解消した。 しかし依然として上位コンポーネントが適切にテストダブルをセットアップ・注入する必要性は残る。 であればもう一歩踏み込んで、期待する振る舞いの再現をテストコードで頑張るのではなく、アプリケーション側のコンポーネント自体に提供させるのはどうだろう?
テスト時にはテストダブルに対してではなく対象コンポーネント自体に、期待する振る舞いを、正確にはその再現を指定する。 イメージとしては以下のように。
test "build recommended articles" do
recommendations = Articles::Recommendations.simulate(:success)
# ...
end
test "build recommended articles with fallback when AI recommendation fails" do
recommendations = Articles::Recommendations.simulate(
:partial_failure,
at: :ai_recommendation,
)
# ...
end
対してテスト対象のコンポーネントは、
- 指定された振る舞いを、実際の挙動と原則同様に再現する責務を持つ。
- 指定された振る舞いを実現できるよう、自身が依存するコンポーネントに対して再帰的に振る舞いを指定していく。
- メール送信等の厄介な処理だけは、テストダブルのように無害な形に置き換える。
# イメージ
class Articles::Recommendations
def self.simulate(mode, **config)
case mode
when :success
new(ai_recommender: Articles::AiRecommender.simulate(:success))
when :partial_failure
case config[:at]
when :ai_recommendation
new(ai_recommender: Articles::AiRecommender.simulate(:error, reason: :service_down))
# when ...
end
end
# when ...
end
end
class Articles::AiRecommender
def self.simulate(mode, **config)
# ... 同様に、指定された振る舞いに応じて AiApi を設定する。
end
end
各種振る舞いの具体的な再現方法を用意・管理する責務はテストコードからアプリケーションコードに移り、かつコンポーネント内に閉じる。 多くのコンポーネントは単に指定内容を変換・リレーするだけでよいはずで、何らかの厄介な処理を含むコンポーネントのみが、指定内容に応じて実際に振る舞いを変える。
こうすると、テストコードは対象コンポーネントに対して単に期待する振る舞いを指定すればよく、テストのための依存性注入さえ不要になる。 内部実装の知識は完全に各コンポーネント内に閉じられ、それがテストコードに漏れ出る心配はなくなる。
というわけで確かにこの方策でも当初の課題は解消しそうだ。 しかしもちろん、テストのようにアプリケーションの用途とは無関係なものをコード内に混ぜ込むのは、本来なら避けるべきだ。 課題への対処としては方策1 (basic) で充分そうだが、あえてアプリケーション自体にテストダブルの責務を持たせる意義はあるだろうか? デメリットは一旦置いといて、先にこの方策のメリットを考えてみる。
メリット・単体テスト以外への活用
まずは以下のメリットが挙げられる。
- テストコードが対象コンポーネントや依存関係の内部実装を詳しく知らずに済む。
- リッチなモックライブラリや依存性注入などの必要性が大きく減る。
- 再現すべき振る舞いがコンポーネントのインターフェイス (引数) として可視化され、ありえるパターンとパラメーターが明確になる。
それらに加えてもう1つこの仕組みが便利そうなのは、テストダブルの挙動をいわゆる単体テストのみならず、より上位の統合テストでも利用できる点だ。 テストダブルを必要とするコンポーネントがあると、それを使うAPIのテスト、またそのAPIを使うWebページのテスト、といった上位レイヤーでも都度テストダブルの考慮が必要になる。 しかしこれも「引数により再現したい振る舞いを指定する」という案を拡張すれば、同様に解決できる。 例えばWebサービスであれば、HTTPリクエストヘッダーやURLのクエリパラメーターを通じて、再現したい振る舞いを外から指定可能にするのはどうだろう。 エンドポイントのハンドラー (コントローラー) はその引数を解釈し、それを依存コンポーネントにリレーしていくことで、理想的にはあらゆる振る舞いを再現できる。 これなら上位レイヤーの統合テストでもテストダブルをセットアップする手間なくテストでき、エラーやエッジケースのテストもしやすい。
もし同じ仕組みを他の依存サービスにも適用できれば、更にできることは広がる。例えばAPIコール時に振る舞い再現用のパラメーターを呼び出し先にも渡す。 すると実際にサービス間通信まで行いつつ、厄介な処理だけを局所的にスキップしたり、エッジケースを再現したりしながら統合テストを実施できる。
加えてテスト以外の用途も考えられる。やはり通常の状況では発生しづらいエッジケースやエラーを開発環境で再現してデバッグしたり、ステージング環境で実際に動くデモをチームメンバーやユーザーに見せれたりしたら、結構便利ではなかろうか。
ただテスト環境以外で使う場合には以下の注意が必要だろう。
- 本番環境では当然、どんな振る舞いの再現を指定されても無視する必要がある。
- 開発環境であっても、テスト環境とは異なり実際のDBをリセット等せず使うなら、安易に処理をテストダブルに置き換えるとデータ不整合につながりうる。
デメリット・リスク
しかしこの方策には色々デメリットもある。少なくとも以下の問題があることは明らかだ。
- アプリケーション内にテスト用のコードが混ざることで、意図せぬ事故につながるリスクが生まれる。
- 引数による振る舞い再現のために、インターフェイスや内部実装が複雑化する。
だがより深刻な問題は、前述の例でいう simulate メソッドによる振る舞いの再現が、実際の振る舞いと一致する保証がない点かもしれない。
simulate の実装・振る舞いが実際のものから乖離していると、テストは通るのに実際に動かすとうまくいかない・挙動が違う、といった事態が容易に起きえる。
従来のようにテストダブルを外から注入する方針であっても、同様の懸念は原理的にはありえる。 しかしアプリケーションはあくまでテストについて何も関知しない方針であれば、コンポーネントの実挙動がテスト時と乖離するリスクはずっと低い。 コンポーネントの内部にはテストのためのコードが通常存在しないから、テストが通るなら「正しい依存関係さえ渡せば正しく動く」ことを自然に期待できる。 テストのために調整できる部分がアプリケーション側に少ない方が、テストにより得られる信頼性は高まる。 対してアプリケーション側がテストに応じて挙動を変えるとなると、本当に実際の挙動をテストできているのかどうか判然としなくなってしまう。
そのためもしテストのユースケースをアプリケーション自体に組み込むにしても、 各コンポーネントはあくまで「指定された振る舞いに応じて自身の依存関係に適切な振る舞いを指定する」だけとし、 指定内容に応じて条件分岐してロジックを変えるようなことは避けるべきだろう。 APIクライアントのように自身がテストダブルに差し替わるべきコンポーネントのみが、実際に処理を変えるようにする。 これを守りやすい形で方策2を実装できれば、従来のテストと実質的な信頼性には大差なく、かつ前述したようなメリットを得られる可能性がある。
テストのユースケースまでアプリケーションに持ち込むのは、やはり相応のリスクを孕む。 とはいえ今でも、テストのためだけに interface (/ trait / 抽象クラス / etc) を定義して実装を差し替え可能にする、程度のことは普通に行われる。 それが正当化されるのは、それによりアプリケーションに加わる複雑さのデメリットよりも、自動テストが容易になるメリットが上回り、引いては継続的・網羅的なテストによるアプリケーションの品質維持・拡張がしやすくなるからだと思う。 だからもしこの方策2がアプリケーションの保守性・拡張性を向上できるなら、有効な選択肢の1つになる可能性はある。 が、実際に試してはいないのでわからない。
過程の検証は結果の検証に置き換える
なおここまでは単なる処理の置き換え、いわゆるスタブ目的のテストダブルを念頭に置いた話だった。 しかしいわゆるモックやスパイによる過程の検証を行いたいケースでは、単に実処理がテストダブルに置き換わるだけでは駄目で、処理の過程を観測できることが求められる。 例えばAPIで外部サービスに何らかのデータを登録することがゴールの処理なら、APIクライアントをモックに置き換えた上で、コンポーネントが想定通りのパラメータでAPIクライアントの特定のメソッドを呼んでいるか、をテストしたりする。 そのようなケースではどうするか。
一番いいのは、可能な限り過程ではなく結果による検証にすり替えることだろう。 今の例でいえば、もしAPIのレスポンスに登録データのIDなど何かしら処理の成功を示す値が含まれるなら、それをコンポーネントの戻り値に含めればいい。 戻り値を検証してテストダブルが返したIDを持っていれば、かつテストダブルが正しいパラメータで呼ばれた時のみ成功レスポンスを返すのならば、 コンポーネントは想定通りにAPIを呼んだとわかる。
戻り値による検証が難しい場合には、簡易的なイベントシステムを使う方法もある。 DBのテーブルなりグローバル変数なりイベントを格納する場所を用意して、テストダブルは処理の結果をそこにイベントとして書き込む。 テストでは処理完了後に書き込まれたイベントの内容を確認することで、想定通りにテストダブルが使われたかを検証する。 こうすれば内部実装に依存する形での過程の検証は不要となり、テストダブルは本来の処理をただ置き換えるだけで済む。 するとアプリケーションコード側で振る舞いの再現を管理する前述の案が適用可能になる。
まとめ
- 課題: テストダブルを無計画に乱用すると、テストが内部実装に密結合して手間になりがち。
- 方策1: 内部実装に依存せずテストダブルを使えるよう、振る舞いベースのインターフェイスをテストダブルに持たせる。
- 方策2: アプリケーション自体にテストダブル相当の責務を持たせる。
方策2は極端な案だが、アプリケーション自体がエラーやエッジケースを含む各種振る舞いの再現方法を提供できたら、テストを始め開発全体で役に立そうではある。 しかしそのメリットがこの仕組みを実装・維持するコストを上回るかどうかはわからない。
-
https://martinfowler.com/articles/mocksArentStubs.html に出ていた例。 ↩︎
-
もしかして型安全な Effect System があれば、この課題は発生しないのだろうか。よく知らない。 ↩︎