こんにちは。コミュニケーションアプリ「LINE」のモバイルクライアントを開発している石川です。
この記事は、毎週木曜の定期連載 "Weekly Report" 共有の第 68 回です。 LINEヤフー社内には、高い開発生産性を維持するための Review Committee という活動があります。ここで集まった知見を、Weekly Report と称して毎週社内に共有しており、その一部を本ブログ上でも公開しています。(Weekly Report の詳細については、過去の記事一覧を参照してください)
諸刃のテスト
データモデル FooModel
と、その情報をログに書き出すクラス FooModelLogger
があるとしましょう。この、FooModelLogger
は、FooModel
のプロパティの文字列を、ログとしてわかりやすいように装飾します。また、以下の実装では、依存するロジックをテストで注入できるように、コンストラクタ引数を定義しています。
class FooModel(val title: String, val description: String)
class FooModelLogger(
private val textLogger: TextLogger = TextLogger(),
private val joinString: String.(Any?) -> String = String::plus,
private val decorate: (String) -> String = { text -> LogTextUtility.decorateText(text, "*") },
private val getTitle: FooModel.() -> String = { this.title },
) {
fun log(model: FooModel) {
val titleToLog = model.getTitle()
val decoratedTitle = decorate(titleToLog)
val combinedOutput = decoratedTitle.joinString(model.description)
textLogger.log(combinedOutput)
}
}
object LogTextUtility {
fun decorateText(text: String, decoration: String): String = ...
}
このように依存先のコードを注入できるようにすることで、ユニットテストでは FooModelLogger
に直接含まれるロジックだけをテストできるようになります。
@Test
fun `logs title and description with a normal FooModelObject`() {
val mockTextLogger: TextLogger = mock()
val mockJoinString: String.(Any?) -> String = { _ -> this + "MockDescription" }
val mockDecorate: (String) -> String = { "@$it@" }
val mockGetTitle: FooModel.() -> String = { "MockTitle" }
val subject = FooModelLogger(
textLogger = mockTextLogger,
joinString = mockJoinString,
decorate = mockDecorate,
getTitle = mockGetTitle
)
val fooModel = FooModel("RealTitle", "RealDescription")
subject.log(fooModel)
verify(mockTextLogger).log("@MockTitle@MockDescription")
}
このコードに何か問題はありますか?
百里のテストも片刃から
この FooModelLogger
では、あまりにも多くのロジックを注入可能にしてしまっているために、以下の 2 つの問題を引き起こしています。
- テストの複雑化: 単純な操作まで注入可能にすると、テストを実行するために必要となるモックやスタブが増えてしまい、複雑なセットアップロジックが必要になる。また、テスト対象のコードが変更されると、テストにも大幅な更新が必要になることもあり、保守のコストが増加しやすい。
- 統合テストの不足: ロジックを過剰にモックやスタブに置き換えると、依存先のロジックとの相互作用が検証できなくなる。上記のテストでは、実際の
FooModel
の値は一切使われていないため、値と出力される文字列が正しく対応が取れているかの確認ができていない。特に、依存先のロジックの仕様が変わった場合など、モックやスタブの想定と実際の挙動がズレてしまった時でもテストが通ってしまい、バグを見逃してしまう可能性がある。
これを改善するためには、テストで注入するロジックを必要最低限に留めることが重要です。今回の例では、注入するのは Logger
だけで十分で、joinString
や getTitle
、LogTextUtility
のロジックは実際のコードを使うほうが、コードを簡潔に保てます。
class FooModelLogger(private val textLogger: TextLogger = TextLogger()) {
fun log(model: FooModel) {
val decoratedTitle = LogTextUtility.decorateText(model.title, "*")
val combinedOutput = decoratedTitle + model.description
textLogger.log(combinedOutput)
}
}
このように注入を必要最低限にすることで、テストも以下のように単純化できます。このテストでは、実際の FooModel
の値や、LogTextUtility
のロジックを使っているため、実際の動作に近い形でテストが行えます。
@Test
fun `logs title and description with a normal FooModelObject`() {
val mockTextLogger: TextLogger = mock()
val subject = FooModelLogger(mockTextLogger)
val fooModel = FooModel("ImprovedTitle", "ImprovedDescription")
subject.log(fooModel)
verify(mockTextLogger).log("*ImprovedTitle*ImprovedDescription")
}
本題とは少し外れますが、FooModelLogger
は TextLogger
をラップするクラスとして定義するのではなく、TextLogger
用の文字列を生成するユーティリティ関数として定義するのもよいでしょう。そうすることで、この関数を使う側のテストでも、FooModelLogger
のモックやスタブを用意する必要がなくなります。
object FooModelLogTextUtil {
fun toLogText(model: FooModel): String {
val decoratedTitle = LogTextUtility.decorateText(model.title, "*")
return decoratedTitle + model.description
}
}
諸刃はシャープに使う
もちろん、テストでモックやスタブを使うことが常に悪いということではありません。大切なのは、使うときにその目的を明確にすることです。例えば次のような場合は、むしろモックやスタブを使った方が良いことも多いでしょう。
- 実行環境や外部環境に依存する場合 (例: Locale 固有のロジック, Network API Client)
- 再現性を保証できない場合 (例: 乱数, 現在時刻)
- 実行に時間がかかる、またはリソースを多く消費する場合
- インスタンス化やセットアップ、リセットに複雑な手順が必要な場合
- 状態遷移やロジックが複雑で、単純なテストでは扱いにくい場合
- 依存先のコードが多くのレイヤーやモジュールにまたがり、テストが広範囲になりすぎる場合
- 依存先のコードの仕様が固まっているが、まだ実装中で完成していない場合
依存先のコードを注入してテストを純粋にしようとするあまり、テストで検証するべきことを見失わないように気をつける必要があります。
一言まとめ
テストでロジックを過剰に注入してしまうと、テストが複雑になり、相互作用の検証が漏れてしまう可能性がある。
キーワード: unit test
, test double
, injection