0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【リファクタリング】Replace Exception with Test(例外を事前チェックに置き換える)

Posted at

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)

  1. 例外を“通常フローの分岐”として使っている箇所を特定
  2. 例外の発生条件を特定し、事前テストに置き換える
  3. 事前テストで分岐(早期 return / ガード句)を導入
  4. 本当に異常なケースのための try/catch必要最小限だけ残す
  5. テスト更新(正常系・境界・異常系)

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 を併用

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?