こんにちは。コミュニケーションアプリ「LINE」のモバイルクライアントを開発している石川です。
この記事は、毎週木曜の定期連載 "Weekly Report" 共有の第 56 回です。 LINEヤフー社内には、高い開発生産性を維持するための Review Committee という活動があります。ここで集まった知見を、Weekly Report と称して毎週社内に共有しており、その一部を本ブログ上でも公開しています。(Weekly Report の詳細については、過去の記事一覧を参照してください)
期待八分目
以下の JsonStringWriter
は、JSON の文字列をローカルストレージに保存する ためのインターフェースです。保存を行う write
関数の引数は、とある理由により String
にしなければならないとします。
interface JsonStringWriter {
fun write(jsonString: String)
}
以下の FooClass
は、JsonStringWriter
の使用例です。
class FooClass(private val jsonStringWriter: JsonStringWriter) {
fun fooFunction() {
...
val jsonString = ...
jsonStringWriter.write(jsonString)
}
}
この FooClass
に対して、ユニットテストを書くとしましょう。以下のテスト関数 writeJsonString_withImportantValue
では、FooClass.fooFunction
の動作を検証しています。このユニットテストでは、ローカルストレージへの実際の書き込みを避けるため、Mockito で JsonStringWriter
のMock
を作成し、それを FooClass
のコンストラクタに渡しています。
@Test
fun writeJsonString_withImportantValue() {
val jsonStringWriter: JsonStringWriter = mock()
val subject = FooClass(jsonStringWriter)
subject.fooFunction()
verify(jsonStringWriter).write(
"""
{
"important_value": "cat",
"unrelated_object": {
"many": "other",
"unrelated": "values"
}
}
""".trimIndent()
)
}
このテストに何か問題はありますか?
偽りの期待
このテストでは、verify
で使用する期待される値 (expected value) として JSON 文字列を直接指定しているのですが、それによって、以下のような問題が引き起こされています。
- expected value に対する検証の欠如: expected value の文字列が、JSON として有効であることを保証できない。プロダクションコードとテストの両方で同じ間違い (例:
"cat"
のダブルクォートが欠落) がある場合、テストが通ってしまう。 - 不必要な厳格さ: 与えられた値が JSON として有効であれば、インデントや改行などのフォーマットの変更は仕様として許容されるべきかも知れない。しかし、現在のテストはスペースの数なども厳密にチェックしているため、false-positive なテストになっている。この状況で JSON 文字列の変換ロジックを更新すると、呼び出し元のすべてのテストも更新が必要になる。
- テストの目的の曖昧さ: テスト関数の名前から、このテストの目的は "important_value" が "cat" であることを確認することだと推測できる。"unrelated_object" はテストの対象外にも思えるが、一致してなければテストが通らない。そのため、"unrelated_object" の検証がテストの目的に含まれているかどうかが不明確。
これらの問題を解決するためには、期待値を抽象化するすると良いでしょう。以下の改善後のテストではargThat
を使用することで、引数を 2 つの観点で確認していることが明確になっています。
- 与えられた文字列が JSON として有効である。
- JSON 文字列にキー
"important_value"
があり、その値が"cat"
である。
@Test
fun writeJsonString_withImportantValue() {
val jsonStringWriter: JsonStringWriter = mock()
val subject = FooClass(jsonStringWriter)
subject.fooFunction()
verify(jsonStringWriter).write(
argThat { jsonString ->
val json = JsonParser.parseString(jsonString).getAsJsonObject()
json.get("important_value").asString == "cat"
}
)
}
ただし、このテストでもまだいくつかの問題が残っています。
- 不必要な実装詳細の知識: JSON パーサの細かい挙動は、ライブラリなどにより異なる可能性がある。しかしこのテストでは、
JsonStringWriter
が内部的にJsonParser
を使用している (もしくは、同等の挙動をする) ことを暗黙的に知っていることを前提にしている。 - コードのコピー:
JsonStringWriter
が広く使用されている場合、多くのテストで似たようなコードが書かれることになる。JsonStringWriter
の仕様を変更する度にテストの更新も必要になるため、結果的にバグを引き起こす可能性がある。
これらの問題を回避するためには、依存先のコードが複雑な場合は、テストダブルのモックを作成する と良いでしょう。テストダブルにおける「モック」とは、関数呼び出しと引数を検証できるテストオブジェクトのことを言います (Mockito など、一部のモックライブラリでは別の意味で "Mock
" の語を使っているため、注意してください)。以下の MockJsonStringWriter
は、テストダブルのモックの例です。
class MockJsonStringWriter : JsonStringWriter {
private var latestJson: JsonElement? = null
override fun write(jsonString: String) {
latestJson = try {
JsonParser.parseString(jsonString).getAsJsonObject()
} catch (e: ...) {
throw AssertionError("...", e)
}
}
fun verifyPropertyAsString(name: String, value: String) {
assertNotNull(latestJson, "...")
assertEquals(value, latestJson.get(name).asString, "...")
}
}
このモックには 2 つの検証機能があります。
- 与えられた文字列が JSON 文字列として有効である。
- 与えられた JSON 文字列に、指定された文字列プロパティがある。
このモックを使うことで、呼び出し元のテストは以下のよう単純化できます。JsonStringWriter
の実装と MockJsonStringWriter
の動作は一致させる必要がありますが、その責任は個々のテストコードが負う必要はありません。実装を変更したとしても、テストへの影響を MockJsonStringWriter
の内部に留めておくことができます。
@Test
fun writeJsonString_withImportantValue() {
val mockJsonStringWriter = MockJsonStringWriter()
val subject = FooClass(mockJsonStringWriter)
subject.fooFunction()
mockJsonStringWriter.verifyPropertyAsString("important_value", "cat")
}
このように、テストコードで期待される値 (expected value) を抽象化することで、テストの目的を明確化し、テスト自体の保守性を向上できることがあります。
一言まとめ
expected value を抽象化する。依存先のコードが複雑ならば、テストのダブルモックの作成を検討する。
キーワード: test double
, mock
, expected value