はじめに
メソッドを抽出しただけ。
ループをStreamに書き換えただけ。
機能は何も変えていない。
それなのに、テストが真っ赤になる。
――そんな経験はありませんか?
これは 偽陽性(false positive) と呼ばれる現象です。
実際にはバグがないのに、テストが「バグがある」と報告してしまう。
いわばテストの「誤報」です。
偽陽性が少ないテストの性質をリファクタリング耐性と呼びます。
リファクタリング耐性は、良いテストを構成する4つの柱の1つであり、
テストスイートの信頼性を左右する重要な指標です。
この記事では、偽陽性がなぜ起きるのか、
その根本原因と「何をテストすべきか」の判別基準を整理していきます。
TL;DR
- テストの目的は「持続的成長の実現」。量ではなく質が問われる
- カバレッジは「テストが不足している」ことは示せるが、「テストが十分である」ことは示せない
- 良いテストの4本柱(退行への保護・リファクタリング耐性・素早いフィードバック・保守性)が質を測る物差しになる。4つの掛け算で価値が決まり、1つでもゼロなら価値はゼロ
セクション1: 偽陽性はなぜテストスイートを殺すのか
偽陽性が「たまに壊れるテスト」程度の問題なら、
手で直せば済む話です。
でも実際には、もっと深刻な悪循環を引き起こします。
偽陽性が生む悪循環
最初は「どうせまた偽陽性だろう」で始まります。
テスト結果を真剣に見なくなり、やがて本物のバグまで素通りさせてしまう。
テストが信用できないから、リファクタリングも怖くてできなくなる。
コードは手を入れられないまま劣化していき、さらに壊れやすいテストが増えていく。
偽陽性の問題は「テスト1件の修正コスト」ではありません。
テストスイート全体への信頼が崩壊する、チームレベルの問題です。
この悪循環を断ち切るには、偽陽性の根本原因を知る必要があります。
セクション2: 「観察可能な振る舞い」と「実装詳細」
偽陽性の原因は「実装詳細への結合」
テストが偽陽性を出すのは、
リファクタリングで変わる部分に結合しているからです。
リファクタリングとは、外部から見た振る舞いを変えずに
内部構造を改善する行為です。
つまりリファクタリングで変わるのは「内部構造」、
変わらないのは「外部から見た振る舞い」。
ここから自然に2つの分類が見えてきます。
- リファクタリングで変わる部分 = 実装詳細
- リファクタリングで変わらない部分 = 観察可能な振る舞い
実装詳細に結合しているテストは、
リファクタリングのたびに壊れます。
観察可能な振る舞いだけを検証していれば、
偽陽性は原理的に発生しません。
では、この2つをどうやって見分けるのでしょうか?
判別基準: 「クライアントのゴールに直結しているか?」
コードが「観察可能な振る舞い」であるためには、
次のどちらかを満たす必要があります。
- クライアントのゴール達成に直結する操作を公開している
(計算を行う、副作用を起こす、など) - クライアントのゴール達成に直結する状態を公開している
(システムの現在の状態を返す、など)
上記のどちらにも該当しないコードは、すべて「実装詳細」です。
ここでの「クライアント」とは、そのコードを呼び出す側のことを指します。
同じコードベース内の呼び出し元クラス、外部アプリケーション、UIなど、
文脈によって変わります。
もう1つ、重要な注意点があります。
「publicかprivateか」と
「観察可能な振る舞いか実装詳細か」は別の軸です。
publicなメソッドであっても、
クライアントのゴールに直結していなければ実装詳細になりえます。
操作の具体例で理解する
判別基準を具体例で見てみましょう。
あるECサイトで、商品名を変更する機能を考えます。
Productクラスには2つのpublicメソッドがあるとします。
┌──────────────────────────────────┐
│ Product (publicメソッド) │
├──────────────────────────────────┤
│ + setName(newName) │
│ + sanitizeName() │
├──────────────────────────────────┤
│ 不変条件: 商品名は50文字以内 │
└──────────────────────────────────┘
ProductService(クライアント)のゴールは
「商品名を変更すること」です。
// ProductService(クライアント)のコード
public void renameProduct(long productId, String newName) {
Product product = repository.findById(productId);
product.setName(newName);
product.sanitizeName(); // 50文字制限を適用
repository.save(product);
}
判別基準に当てはめてみます。
-
setName()→ クライアントのゴール「商品名の変更」に直結
→ 観察可能な振る舞い -
sanitizeName()→ クライアントのゴールに直結しない。
50文字制限という内部の不変条件を満たすための手段にすぎない
→ 実装詳細
sanitizeName()がpublicであること自体が問題です。
これが「実装詳細の漏洩」と呼ばれる状態です。
状態の具体例で理解する
もう1つ、状態の例も見てみましょう。
ReportGeneratorクラスが、
複数のセクションを組み合わせてレポートを生成するとします。
┌──────────────────────────────────────┐
│ ReportGenerator (publicメンバー) │
├──────────────────────────────────────┤
│ + generate(): String │
│ + getSections(): List<Section> │
└──────────────────────────────────────┘
クライアントのゴールは
「レポートを生成すること」です。
-
generate()→ クライアントのゴールに直結
→ 観察可能な振る舞い -
getSections()→ クライアントのゴールに直結しない。
内部でどんなセクションに分割しているかは実現方法の話
→ 実装詳細
getSections()の中身(セクション数や構成)を
テストで検証してしまうと、
内部構造をリファクタリングしたときにテストが壊れます。
検証すべきはgenerate()の出力結果だけです。
判別のヒューリスティック
ここまでの例から、実用的な経験則が1つ見えてきます。
クライアントが1つのゴールを達成するために、
呼び出す操作が2つ以上ある場合、
実装詳細が漏洩している可能性が高い。
理想的には、1つのゴールは1つの操作で達成できる状態が望ましいです。
先ほどのProductServiceの例では、
setName()とsanitizeName()の2つを呼ぶ必要がありました。
これは実装詳細の漏洩のサインです。
セクション3: API設計とテスト設計は表裏一体
判別基準はわかりました。
でも「実装詳細をテストしないように気をつける」だけでは、
意志の力に頼った解決策です。
もっと構造的に防ぐ方法があります。
well-designed API ── 実装詳細をテストできなくする
セクション2で見た判別基準を思い出してください。
publicかprivateかと、
観察可能な振る舞いか実装詳細かは別の軸でした。
この2つの軸を組み合わせると、
API設計の良し悪しが見えてきます。
│ 観察可能な振る舞い │ 実装詳細
─────────────┼───────────────────┼──────────────
public │ 良い設計 │ 悪い設計
│ │ (実装詳細の漏洩)
─────────────┼───────────────────┼──────────────
private │ 該当なし(※) │ 良い設計
│ │
※ privateにした時点でクライアントが直接使えなくなるため、
「クライアントのゴールに直結する」という条件を満たせません。
「観察可能な振る舞い = public」かつ
「実装詳細 = private」になっている状態が、
well-designed APIです。
well-designed APIにすれば、テストは物理的に
観察可能な振る舞いしか検証できなくなります。
つまり「テストで何を検証すべきか」の問題は、
「APIをどう設計すべきか」の問題と同じなのです。
先ほどのProductクラスを改善してみましょう。
public class Product {
private String name;
public void setName(String newName) {
this.name = sanitize(newName); // 内部でサニタイズ
}
public String getName() {
return this.name;
}
// privateに変更 ── クライアントから見えなくなる
private String sanitize(String input) {
if (input.length() > 50) {
return input.substring(0, 50);
}
return input;
}
}
sanitize()がprivateになったことで、
クライアントはsetName()の1回の呼び出しだけで
ゴールを達成できるようになりました。
テストもsetName()の結果だけを検証すれば十分です。
カプセル化 ── 不変条件を守る仕組みとしての再理解
well-designed APIを維持することは、
カプセル化の考え方と深く関連しています。
カプセル化というと「データを隠蔽すること」と
理解されがちですが、
本質はコードを不変条件の違反から守る行為です。
改善前のProductクラスを振り返ってみます。
sanitizeName()がpublicだと、
クライアントがsetName()だけ呼んで
sanitizeName()を呼び忘れる可能性があります。
すると「商品名は50文字以内」という不変条件が破られます。
改善前: 不変条件の違反が「できてしまう」
─────────────────────────────────────
client → product.setName("長い名前...")
→ // sanitizeName()を呼び忘れ
→ 50文字を超えた名前がDBに保存される
改善後: 不変条件の違反が「できない」
─────────────────────────────────────
client → product.setName("長い名前...")
→ 内部でsanitize()が自動実行
→ 必ず50文字以内になる
実装詳細を隠蔽し、操作とデータを束ねることで、
不変条件違反の可能性自体を排除する。
これがカプセル化の効果です。
カプセル化もテスト設計も、根底にある考え方は同じです。
「正しくないことができる可能性自体をなくす」。
意志の力で「気をつける」のではなく、
構造で「間違えようがない状態」を作ること。
API設計とテスト設計は、この同じ原則の表と裏です。
セクション4: システムの境界で判断する
ここまではクラスレベルの判別基準を見てきました。
- セクション2: メソッド単位で「観察可能な振る舞い」と「実装詳細」を判別する
- セクション3: API設計で実装詳細を構造的に隠蔽する
でも実際のアプリケーションは、
多数のクラスが協調して動いています。
この判別基準を、
システム全体のレベルにどう拡張すればいいのでしょうか?
ヘキサゴナルアーキテクチャの概要
典型的なアプリケーションは、
大きく2つの層で構成されます。
┌───────────────────────────────────────────┐
│ アプリケーションサービス層 │
│ (外部世界との橋渡し・ユースケース) │
│ │
│ ┌───────────────────────────────────┐ │
│ │ ドメイン層 │ │
│ │ (ビジネスロジック) │ │
│ └───────────────────────────────────┘ │
│ │
└───────────────────────────────────────────┘
- ドメイン層: ビジネスロジックを持つ中心部分
-
アプリケーションサービス層: ドメイン層と外部世界を繋ぐ層。
DBからデータを取得してドメインオブジェクトに渡し、
結果をDBに保存したり外部サービスに送信したりする
この2つの層がひとまとまりの「ヘキサゴン(六角形)」を形成します。
他のアプリケーション
(メール送信サービス、メッセージバス、外部APIなど)も
それぞれ独自のヘキサゴンとして表現され、
このヘキサゴン同士の相互作用の構造が
ヘキサゴナルアーキテクチャです。
ポイントは3つあります。
-
関心の分離: ドメイン層はビジネスロジックだけに責任を持ち、
外部との通信はアプリケーションサービス層が担う -
依存の方向は一方向: アプリケーションサービス層 → ドメイン層。
ドメイン層は外部世界を知らない -
外部との接続口: 外部アプリケーションは
アプリケーションサービス層を通じて接続する。
ドメイン層に直接アクセスすることはない
ここで大切なのは、
セクション2で学んだ判別基準が
各層にもそのまま適用できるということです。
ドメイン層にも「観察可能な振る舞い」と「実装詳細」があり、
アプリケーションサービス層にもそれがある。
判別基準はフラクタル的に、どのレベルにも使えます。
2種類の通信 ── intra-systemとinter-system
ヘキサゴナルアーキテクチャの視点を持つと、
アプリケーション内の通信は2種類に分類できます。
外部アプリケーション
(メールサービス等)
▲
│ inter-system通信
│ (アプリケーション間)
┌─────────────────────┼──────────────────────┐
│ アプリケーション │ │
│ サービス層 │ │
│ ┌───────────┘ │
│ │ │
│ ▼ intra-system通信 │
│ ┌─────────────┐ │
│ │ ドメイン層 │ │
│ └─────────────┘ │
└────────────────────────────────────────────┘
-
intra-system通信:
アプリケーション内部のクラス間の通信 -
inter-system通信:
アプリケーションと外部アプリケーションとの通信
そして、ここが核心です。
intra-system通信は実装詳細。
inter-system通信は観察可能な振る舞い。
なぜでしょうか?
セクション2の判別基準をそのまま当てはめてみます。
intra-system通信
あるドメインクラスが
別のドメインクラスをどう呼び出すかは、
クライアントのゴール達成に直結しません。
それは内部でどう実現するかという話であり、実装詳細です
inter-system通信
アプリケーションが外部システムとどう通信するかは、
外部から観察可能な振る舞いの一部です。
外部システムはその通信パターンを観察し、依存しています。
また、外部システムは自分のアプリケーションと同時にデプロイできないことがあるため、
通信のインターフェースは後方互換性を維持する必要があります
具体例: 商品購入のユースケース
ECサイトで商品を購入するユースケースで考えてみましょう。
┌────────────────────────────────────────────────┐
│ 自分のアプリケーション │
│ │
│ OrderService │
│ │ │
│ ├── inventory.decrease(productId, quantity) │
│ │ ↕ Inventory(ドメインクラス) │
│ │ → intra-system通信 → 実装詳細 │
│ │ │
│ └── emailGateway.sendReceipt(order) │
│ ↕ 外部メールサービス │
│ → inter-system通信 → 観察可能な振る舞い │
│ │
└────────────────────────────────────────────────┘
-
Inventoryクラスのdecrease()を呼ぶのは
intra-system通信です。
在庫の減算をどのクラスのどのメソッドで実現するかは、
内部の実装方法の問題です。
リファクタリングでInventoryとProductを
統合しても、外部から見た振る舞いは変わりません -
EmailGateway経由でメール送信するのは
inter-system通信です。
「購入したら確認メールが届く」という振る舞いは、
外部のクライアントから観察可能であり、
維持すべき契約の一部です
この分類がテスト設計に与える影響
intra-system通信が実装詳細であるという事実は、
テスト設計に直接的な影響を与えます。
テストで「クラスAがクラスBのメソッドXを呼んだか」を
検証している場合、それはintra-system通信、
つまり実装詳細を検証していることになります。
内部のクラス構成をリファクタリングしたら壊れる、
脆弱なテストです。
この通信の分類は、
テストダブル(Mock、Stubなど)の使い方にも直結します。
どの通信をMockで検証すべきかという判断基準は、
まさにこのintra/interの区別に基づいています。
おわりに
この記事を通じて見てきたのは、
たった1つの問い――
「クライアントのゴールに直結しているか?」
――がスケールを変えながら繰り返し現れるということでした。
メソッド単位の判別も、API設計の良し悪しも、
システム間通信の分類も、
すべてこの同じ問いから導き出せます。
テストを書く前に
「これは観察可能な振る舞いか、実装詳細か?」と自問する。
この習慣が、偽陽性を防ぎ、
テストスイートへの信頼を守ってくれるはずです。