1. 概要(Overview)
Replace Exception with Test は、
「通常フロー」で起きうる分岐を 例外で制御しない ようにし、
事前条件のチェック(テスト) によって処理を分けるリファクタリングです。
目的:
- 例外を“エラー/異常系”に限定し、通常フローの可読性と性能を改善
- ネストした
try/catchを排除して 意図を明確化 - 事前条件を満たさない呼び出しを早期にガード
2. 適用シーン(When to Use)
- “存在しない/空/範囲外”などあり得る通常ケースを
try/catchで捌いている - コレクションのアクセス、検索結果の未存在、入力値の妥当性などで例外乱用している
- パフォーマンス・ログが、例外発生の多さで汚れている
よくある匂い:
- Exception-driven flow(例外駆動の通常フロー)
- Deeply nested try/catch(多重 try/catch)
3. 手順(Mechanics / Steps)
- 例外を“通常フローの分岐”として使っている箇所を特定
- 例外の発生条件を特定し、事前テストに置き換える
- 事前テストで分岐(早期 return / ガード句)を導入
- 本当に異常なケースのための
try/catchは必要最小限だけ残す - テスト更新(正常系・境界・異常系)
4. Kotlin 例(Before → After)
4.1 インデックスアクセス:例外 → 事前チェック
Before(例外で通常分岐)
fun firstCharOrNull(s: String): Char? =
try { s[0] } catch (_: IndexOutOfBoundsException) { null }
After(ガード句/事前チェック)
fun firstCharOrNull(s: String): Char? =
if (s.isNotEmpty()) s[0] else null
// または s.firstOrNull()
4.2 Map 検索:例外 → 安全 API
Before
fun findPrice(key: String, map: Map<String, Int>): Int =
try { map.getValue(key) } catch (_: NoSuchElementException) { -1 }
After
fun findPrice(key: String, map: Map<String, Int>): Int =
map[key] ?: -1
4.3 文字列→数値:例外 → 例外を投げない API
Before
fun parsePort(text: String): Int =
try { text.toInt() } catch (_: NumberFormatException) { 80 }
After
fun parsePort(text: String): Int =
text.toIntOrNull() ?: 80
4.4 事前条件の明示(呼び出し側でガード)
Before(例外で弾く)
fun withdraw(balance: Int, amount: Int): Int {
require(amount > 0) { "amount must be positive" }
if (amount > balance) throw IllegalStateException("insufficient")
return balance - amount
}
After(呼び出し側でテスト → 通常フローは例外なし)
fun canWithdraw(balance: Int, amount: Int): Boolean =
amount > 0 && amount <= balance
fun withdraw(balance: Int, amount: Int): Int = balance - amount
// 呼び出し側
if (canWithdraw(bal, amt)) {
newBal = withdraw(bal, amt)
} else {
// 通常フローの分岐として処理(UI表示など)
}
例外は“契約違反/システム異常”に限定。
「残高不足」は業務上よくあるケースなら通常分岐が適切。
4.5 Result/Either で通常分岐を明示
sealed interface LoadUserResult {
data class Ok(val user: User): LoadUserResult
data object NotFound: LoadUserResult
data class NetworkError(val cause: Throwable): LoadUserResult
}
fun loadUser(id: Long): LoadUserResult = /* 例外を飲み込まず分類して返す */
when (val r = loadUser(1L)) {
is LoadUserResult.Ok -> show(r.user)
LoadUserResult.NotFound -> showEmpty()
is LoadUserResult.NetworkError -> retry(r.cause)
}
5. 効果(Benefits)
-
通常フローの可読性向上(
try/catchが減る) - パフォーマンス/ログの健全化(例外は重く、スタックトレースも騒がしい)
- 事前条件が明確になり、契約の意図が伝わる
- テストが分岐単位で書きやすい
6. 注意点(Pitfalls)
-
TOCTOU(チェックしてから使うまでに状態が変わる)に注意:
- I/O・並行環境では最終的な安全装置としての try/catchを境界で残す
- “本当に異常”なケースまでテスト化してしまうと異常を見逃す
- 入力不正・整合性違反は
require/checkで即失敗が適切な場合も
- 入力不正・整合性違反は
- 検証コストが高い場合は、例外の方が総合的にシンプルなこともある
まとめ
- Replace Exception with Test は、“起こりうる通常ケース”を例外で制御しないためのリファクタリング。
- 判断基準:それは異常か? それとも通常フローの一分岐か?
- 指針:
- 通常分岐 → 事前チェック/安全API/
Result型 - 契約違反・不正・環境障害 → 例外
- I/O/並行境界 → ガード + 最小限の try/catch を併用
- 通常分岐 → 事前チェック/安全API/