はじめに
テストを書いていて「このコード、テストしにくいな」と感じることはありませんか?
- Mockが何層にもなって、テストの準備だけで疲弊する
- privateメソッドに重要なロジックがあるのに、直接テストできない
- プロダクションコードをちょっと変えただけで、テストが連鎖的に壊れる
こうした場面に出くわすと、テスト側の工夫で乗り越えようとするのは自然な発想です。
privateメソッドをpublicに変えてしまおうか、Mockをもう1層追加しようか、テストの中で計算ロジックを再現しようか——。
ただ、こうした対処はいずれも「テスト側での回避策」であり、根本的な解決にはなりません。
テストが書きにくい原因の多くは、テスト技術の問題ではなくプロダクションコードの設計にあります。
この記事では、コードの設計を変えることでテストの問題を解決する正攻法と、テスト側で回避しようとしたときに陥るアンチパターンを対比しながら学んでいきます。
TL;DR
- テストが書きにくいのは、プロダクションコードの設計が発している「シグナル」
- コードを2軸で4分類し、ドメインモデル/アルゴリズム象限を重点的にテストする。過度に複雑なコードはHumble Objectパターンで分離する
- アンチパターン(privateの公開、具象クラスのMock等)は全て「設計の問題をテスト側で回避しようとした結果」
コードを「2軸」で分類する — テストすべき場所の見極め
テストを書くとき、「どのコードにテストを書くべきか?」という判断基準がないと、闇雲にカバレッジを上げることになりがちです。
コードを分類する枠組みを持つことで、テストの投資先を見極められるようになります。
コードを評価する2つの軸
コードの「テストしやすさ」と「テストする価値」を判断するために、2つの軸を使います。
軸1: 複雑さ/ドメイン重要度
この軸は「テストによる回帰保護の価値がどれだけ高いか」を表します。
2つの要素がありますが、テストの価値という観点では同じ方向に働くため、1つの軸にまとめます。
-
複雑さ = コード内の分岐点の数
- if文やループの条件分岐が多いほど、バグが潜む余地が大きく、テストで守る価値が高い
- この分岐点の数を定量化した指標をサイクロマティック複雑度と呼ぶ
-
ドメイン重要度 = ビジネス上の重要性
- 分岐が少なくても、注文金額の計算のようにビジネスクリティカルなコードはテストの価値が高い
軸2: 協力者の数
この軸は「テストの保守コストがどれだけかかるか」を表します。
- 協力者とは、そのコードが依存する可変な依存先やプロセス外の依存先(DBやメッセージキューなど)のこと
- 協力者が多いほど、テストのセットアップが大変になり、保守コストが上がる
- static経由でアクセスする暗黙の依存も協力者に含まれる
- 一方、値オブジェクトのような不変な依存は協力者に含まれない(セットアップが簡単なため)
「協力者」は少し抽象的な表現ですが、要するに「テストを書くときにMockやスタブで差し替えたくなるもの」と考えるとイメージしやすいです。DBアクセス、外部APIの呼び出し、メッセージキューへの送信などが典型的な協力者です。
2軸が生む4つの象限
2軸を組み合わせると、コードを4つの象限に分類できます。
協力者が少ない 協力者が多い
┌──────────────────┬──────────────────┐
複雑/ │ ドメインモデル/ │ 過度に複雑な │
重要 │ アルゴリズム │ コード │
│ │ │
│ → テストの主戦場 │ → 存在してはなら │
│ 費用対効果◎ │ ない象限 │
├──────────────────┼──────────────────┤
単純/ │ 些末なコード │ コントローラー │
些末 │ │ │
│ → テスト不要 │ → 統合テストで │
│ │ 少数カバー │
└──────────────────┴──────────────────┘
それぞれの象限を見ていきます。
- ドメインモデル/アルゴリズム(左上): 回帰保護の価値が高く、協力者が少ないので保守コストも低い。ユニットテストの主戦場
- 些末なコード(左下): パラメータなしのコンストラクタや単純なプロパティなど。テストの価値がほぼゼロなのでテスト不要
- コントローラー(右下): 自身は単純だが、多くの協力者を束ねる。統合テストで少数カバーする対象
- 過度に複雑なコード(右上): テストの価値は高いのに、協力者が多くてテストが困難。この象限のコードはリファクタリングで左上と右下に分離すべき
ここから導かれる原則はシンプルです。
重要なコードほど、協力者を少なくせよ
自分のコードを4分類で診断してみる
具体的にイメージするために、ECサイトの注文処理を例に考えてみます。
public class OrderService {
public void placeOrder(int userId, List<CartItem> items) {
// DBからユーザー情報を取得
User user = database.getUserById(userId);
// 割引率の計算(会員ランクに基づく複雑なロジック)
double discount = calculateDiscount(user.getRank(), items);
// 在庫チェック(外部在庫サービスに問い合わせ)
inventoryService.checkAvailability(items);
// 注文金額の確定
double totalAmount = applyDiscount(items, discount);
// DBに注文を保存
database.saveOrder(new Order(userId, items, totalAmount));
// 通知サービスに注文完了を通知
notificationService.sendOrderConfirmation(userId);
}
}
このコードを2軸で診断すると、次のようになります。
- 割引計算や金額確定などのビジネスロジックを含む → 複雑さ/ドメイン重要度が高い
- DB、在庫サービス、通知サービスへの直接依存 → 協力者が多い
結果として「過度に複雑なコード」象限に位置しています。
ビジネスロジックと外部依存へのアクセスが1つのメソッドに同居しているのが原因です。
このように、ビジネスロジックとDB操作・外部サービス呼び出しが1つのメソッドに同居するスタイル(トランザクションスクリプト)は、小規模なプロジェクトでは問題になりにくいですが、コードベースが成長するにつれてテストが困難になっていきます。
正攻法: Humble Objectパターンでコードを分離する
4分類で「過度に複雑なコード」を見つけたら、次のステップはリファクタリングです。
しかし、ロジックと外部依存が絡み合ったコードをどう分離するか——その具体的な方法を提供するのがHumble Objectパターンです。
パターンの考え方 — 「深さ」と「幅」の分離
Humble Objectパターンの核心は、テストしにくい依存に結合したコードから、テスト可能なロジックを抽出することです。
抽出後に残ったコードは、ロジックを持たない薄いラッパー(humble = 謙虚な存在)になり、テスト不要になります。
このパターンを直感的に理解するために、「コードの深さ」と「コードの幅」というメタファーを使います。
【深いコード】 【幅広いコード】
┌──────┐ ┌──────────────────────────┐
│ if │ │ DB API Queue Cache │
│ if │ │ ↓ ↓ ↓ ↓ │
│ if │ 複雑なロジック │ 単純な呼び出しの連鎖 │
│ if │ (分岐が深い) │ (協力者が多い) │
│ if │ │ │
└──────┘ └──────────────────────────┘
→ これが1つのクラスに同居しているのが問題
→ 深さと幅を別のクラスに分離する
- 深いコード = 複雑なロジック(分岐が深い)→ ドメインモデル/アルゴリズム象限へ
- 幅広いコード = 多くの協力者との連携 → コントローラー象限へ
1つのクラスが「深くて幅広い」のが問題であり、この2つの責務を別のクラスに分離するのがHumble Objectパターンの本質です。
このパターンは、ヘキサゴナルアーキテクチャ、関数型アーキテクチャ、MVCなど多くのアーキテクチャパターンの根底にある共通原理です。「ビジネスロジックと外部とのやり取りを分離する」という考え方は、設計の世界では繰り返し登場するテーマといえます。
段階的なリファクタリングの流れ
先ほどの注文処理を例に、Humble Objectパターンで段階的にリファクタリングする流れを見ていきます。
各段階の設計判断を中心に、概要レベルで紹介します。
Step 1: オーケストレーション層の分離
プロセス外依存(DB、外部サービス)とのやり取りをOrderServiceから切り離し、ドメインクラスはロジックに集中させます。
-
Orderクラス: 割引計算、金額確定などのビジネスロジックだけを持つ -
OrderController(アプリケーションサービス): データの取得・保存・通知などのオーケストレーションを担当
Step 2: ファクトリの導入
DBの生データからドメインオブジェクトを復元するロジックも、コントローラーの責務ではありません。
ファクトリクラスに抽出して、コントローラーは「データの受け渡しだけ」に徹するようにします。
Step 3: 責務の抽出
1つのドメインクラスに複数の責務が混在している場合は、別クラスに切り出します。
たとえば「会員ランクに基づく割引計算」が注文クラスに直接書かれていたら、DiscountCalculatorとして独立させます。
これにより、単一責任の原則に沿った設計になります。
リファクタリング後の各クラスを4分類に当てはめると、次のようになります。
協力者が少ない 協力者が多い
┌──────────────────┬──────────────────┐
複雑/ │ Order │ │
重要 │ DiscountCalc │ (空!) │
│ Factories │ │
├──────────────────┼──────────────────┤
単純/ │ │ OrderController │
些末 │ │ │
└──────────────────┴──────────────────┘
「過度に複雑なコード」象限が空になっていることがポイントです。
ドメインクラスはロジックだけを持ち、コントローラーはオーケストレーションだけを持つ。
この分離が実現できれば、ドメインクラスのテストは外部依存なしで書けるようになります。
正攻法の対極 — 具象クラスのMock(アンチパターン)
Humble Objectパターンを知らないと、「過度に複雑なコード」に対してテスト側で対処しようとします。
その典型例が具象クラスのMockです。
たとえば、配送統計を計算するクラスを考えてみます。
public class DeliveryStatistics {
// 外部サービスからデータを取得する
public List<Delivery> getDeliveries(int customerId) {
return externalService.fetchDeliveries(customerId);
}
// 統計を計算する(ドメインロジック)
public StatResult calculate(List<Delivery> deliveries) {
// 重量・コストの集計ロジック
}
}
このクラスには「外部サービスとの通信」と「統計計算のドメインロジック」の2つの責務が同居しています。
テスト時にgetDeliveries()だけをMock化して、calculate()は実物を使う——という手法は一見合理的に見えます。
しかし、これは単一責任の原則に違反しているサインです。
1つのクラスに2つの責務が混在しているから、部分的にMockする必要が生まれているのです。
正しい対処は、クラスを2つに分割することです。
-
DeliveryGateway: 外部サービスとの通信を担当(インターフェース経由でMock可能) -
DeliveryStatistics: 統計計算のドメインロジックに専念(Mockなしでテスト可能)
これはまさにHumble Objectパターンです。
「具象クラスの一部メソッドだけをMockする」テクニック自体が悪いのではなく、そのテクニックが必要になること自体が設計の問題を示しています。Mockする範囲を広げるのではなく、Mockが不要になるように設計を変えるのが根本的な解決策です。
privateメソッドとカプセル化 — テストのために設計を壊さない
テストを書いていると、こうした誘惑に駆られることがあります。
- 「このprivateメソッドに重要なロジックがあるのに、テストできない」
- 「テストのためにprivateフィールドをpublicにしたい」
しかし、テストのためにカプセル化を壊すのは本末転倒です。
テストは本番コードと同じインターフェースでSUT(テスト対象)に接触すべきであり、テストだけが「特権」を持つべきではありません。
privateメソッドのテスト — 正しい対処と誤った対処
原則として、privateメソッドを直接テストするのは避けるのが望ましいです。
テストの対象は、公開APIを通じた観察可能な振る舞いです。
privateメソッドを公開してテストすると、テストが実装の詳細に結合します。
その結果、内部のリファクタリングのたびにテストが壊れるようになり、リファクタリング耐性が失われます。
では、「privateメソッドにある重要なロジックをテストできない」と感じたときはどうすればよいのでしょうか?
状況に応じて2つのケースに分けて考えます。
- 死んだコード — 実はどこからも有効に使われていない
- 抽象の欠落 — 別クラスに切り出すべきロジックが隠れている
ケース1: 死んだコード
公開APIからのテストで十分にカバーできないprivateメソッドは、実はどこからも有効に使われていないコードかもしれません。
このケースでは、そのコードの削除を検討します。
ケース2: 抽象の欠落
privateメソッドが複雑すぎて、公開APIを通じたテストでは十分にカバーできない場合があります。
これは「別のクラスに切り出すべきロジックがprivateに隠れている」サインです。
たとえば、注文クラスに複雑な価格計算ロジックがprivateメソッドとして隠れているとします。
【リファクタリング前】 【リファクタリング後】
┌─────────────────┐ ┌─────────────────┐
│ Order │ │ Order │
│ │ │ │
│ + getDesc() │ → │ + getDesc() │
│ - calcPrice() │ │ │
│ (複雑!) │ └────────┬────────┘
└─────────────────┘ │ 使う
┌───────┴────────┐
│ PriceCalculator│
│ │
│ + calc() │
│ (独立テスト可) │
└────────────────┘
PriceCalculatorとして抽出すれば、独立してテストできます。
privateメソッドをpublicにする必要はありません。
例外的にprivateメソッドのテストが許容されるケースもあります。たとえばORMフレームワークの契約を満たすためのprivateコンストラクタは、privateでありながら外部契約の一部です。こうしたケースではpublicにしてもテストの脆弱性は生みません。ただし、あくまで例外です。
private stateの公開 — カプセル化を壊す誘惑
privateメソッドと同じ考え方が、privateフィールドにも当てはまります。
たとえば、顧客クラスに_statusというprivateフィールドがあるとします。
promote()メソッドのテストで、ステータスの変化を直接確認したくなる場面です。
public class Customer {
private Status _status = Status.REGULAR; // テストで見たい...
public void promote() {
_status = Status.PREFERRED;
}
public double getDiscount() {
return _status == Status.PREFERRED ? 0.05 : 0.0;
}
}
_statusフィールドをpublicにすれば確認は簡単ですが、これはアンチパターンです。
本番コードが_statusを直接参照していないなら、テストも参照すべきではありません。
代わりに、本番コードが実際に依存している観察可能な振る舞いを検証します。
この例では、昇格前後の割引率の変化(getDiscount()の戻り値)を確認すれば十分です。
この問題と同根のアンチパターンに、コード汚染があります。
コード汚染とは、テスト専用のコードを本番コードに混入させることです。
たとえば、LoggerクラスにisTestEnvironmentというBooleanフラグを追加して、
テスト時にはログ出力を無効化する——といった手法です。
// アンチパターン: テスト用スイッチが本番コードに混入
public class Logger {
private boolean isTestEnvironment;
public Logger(boolean isTestEnvironment) {
this.isTestEnvironment = isTestEnvironment;
}
public void log(String message) {
if (!isTestEnvironment) {
writeToFile(message); // テスト時はスキップ
}
}
}
この実装の問題は、本番コードにテスト用の条件分岐が入り込むことです。
フラグの設定を間違えれば、本番環境でログが出力されない事故が起こりえます。
正しい対処は、インターフェースを導入して本番用とテスト用の実装を分離することです。
// 正しい対処: インターフェースで分離
public interface ILogger {
void log(String message);
}
// 本番用
public class FileLogger implements ILogger { ... }
// テスト用
public class FakeLogger implements ILogger { ... }
インターフェース自体もコード汚染の一種ではありますが、Booleanスイッチと比べるとバグの余地がありません。
インターフェースはただの契約であり、実行可能なコードを含まないためです。
テストの書き方に潜む落とし穴 — ドメイン知識の漏洩と時間依存
ここまでは「プロダクションコードの設計を変える」ことで解決する問題を扱ってきました。
しかし、コード設計が適切でも、テストの書き方自体に落とし穴があります。
ここでは、設計とテストの境界にある2つの問題を取り上げます。
- ドメイン知識の漏洩 — テスト内でプロダクションコードのアルゴリズムを再現してしまう
- 時間依存 — 現在時刻に依存するコードがテストを不安定にする
ドメイン知識のテストへの漏洩
テストのassertで、プロダクションコードのアルゴリズムを再現してしまう問題です。
// アンチパターン: テスト内でアルゴリズムを再現
@Test
void testCalculateTotal() {
double expected = customer.getQuantity() * product.getPrice();
double actual = calculator.calculateTotal(customer, product);
assertEquals(expected, actual);
}
一見正しく見えますが、これはプロダクションコードのコピペと同じです。
なぜ問題かというと、2つの理由があります。
- バグを検出できない: プロダクションコードとテストが同じアルゴリズムを使っているため、同じ間違いを犯すリスクがある
- リファクタリング耐性がゼロ: アルゴリズムが変われば、テストも同じように書き換える(ただのコピペ更新になる)
正しい対処は、期待値をハードコードすることです。
// 正しい対処: 期待値をハードコード
@Test
void testCalculateTotal() {
double actual = calculator.calculateTotal(customer, product);
assertEquals(150.0, actual); // 事前に計算済みの値
}
テストはブラックボックスの視点で書きます。
「入力→期待される出力」だけを記述し、途中の計算ロジックをテスト内に持ち込まないことが大切です。
複雑なアルゴリズムの場合、期待値をどう算出するかが問題になります。ドメインエキスパート(業務に詳しい人)に計算結果を確認してもらう、レガシーコードの出力を基準値として使うなどの方法があります。
時間に依存するコードのテスト
現在時刻に依存する機能(有効期限チェック、タイムスタンプ付与など)は、テスト実行のタイミングによって結果が変わるため、偽陽性の温床になります。
アンチパターン: アンビエントコンテキスト
staticなグローバル変数で「現在時刻」を差し替える方法です。
// アンチパターン: グローバルな状態で時刻を差し替え
public class DateTimeServer {
private static Supplier<LocalDateTime> provider;
public static void init(Supplier<LocalDateTime> p) {
provider = p;
}
public static LocalDateTime now() {
return provider.get();
}
}
// テスト時
DateTimeServer.init(() -> LocalDateTime.of(2026, 1, 1, 0, 0));
この方法には2つの問題があります。
- 本番コードの汚染: テスト用のコードが本番に混入する
- テスト間の干渉: staticフィールドはテスト間で共有されるため、テストの実行順序によって結果が変わる可能性がある
正しい対処: 明示的な依存注入
時間の依存を明示的に注入する方法です。注入の方法は2つあります。
-
サービスとして注入:
ClockやTimeProviderのようなインターフェース経由で注入する -
値として注入:
LocalDateTimeを直接引数で渡す
// サービスとして注入
public class OrderController {
private final Clock clock;
public OrderController(Clock clock) { this.clock = clock; }
}
// 値として注入(こちらを優先)
public class Coupon {
public boolean isValid(LocalDateTime currentTime) {
return currentTime.isBefore(this.expiresAt);
}
}
値としての注入を優先します。
扱いが簡単で、テストでのスタブも容易だからです。
実務的な妥協として、コントローラー層ではサービスとして受け取り、ドメイン層には値として渡すのがバランスの良いアプローチです。
コントローラーが複雑になるとき — トレードオフと緩和策
Humble Objectパターンでロジックとオーケストレーションを分離できたとしても、実務ではコントローラーに条件分岐が入り込むケースがあります。
ここでは、その構造的な理由と緩和策を見ていきます。
分離の理想と現実 — 3つの属性のトレードオフ
ロジックとオーケストレーションの分離がきれいにいくのは、「データ取得→判断→保存」の3段階に分けられる場合です。
しかし、途中で追加データの取得や条件分岐が必要なケースでは、以下の3属性を同時に満たすことができません。
ドメインモデルの コントローラーの
テスタビリティ 単純さ
○ ○
/ \ / \
/ \ 同時に / \
/ \ 2つまで / \
○───────○ ○
パフォーマンス
| 選択肢 | テスタビリティ | コントローラーの単純さ | パフォーマンス |
|---|---|---|---|
| 外部呼び出しを全て端に寄せる | ○ | ○ | × |
| ドメインモデルに外部依存を注入 | × | ○ | ○ |
| 判断プロセスを細分化する | ○ | × | ○ |
- 外部呼び出しを全て端に寄せる: ドメインモデルはきれいだが、不要なDB呼び出しが発生する
- ドメインモデルに外部依存を注入: パフォーマンスは良いが、ドメインモデルが「過度に複雑なコード」に逆戻りする
- 判断プロセスを細分化する: コントローラーに条件分岐が入るが、ドメインモデルのテスタビリティとパフォーマンスを両立できる
多くの場合、パフォーマンスは無視できないため、**「判断プロセスを細分化する」**が現実的な選択になります。
コントローラーの複雑さが増すデメリットは、以下の2つのパターンで緩和できます。
CanExecute/Executeパターン
ビジネスルールの事前検証ロジックがコントローラーに漏れ出すと、ドメインモデルのカプセル化が崩れます。
たとえば、「メールアドレスが確認済みのユーザーはメールを変更できない」というルールを考えてみます。
この検証をコントローラーに書くと、検証なしで変更メソッドを呼べてしまう状態が生まれます。
// コントローラーに検証が漏れた状態
if (!user.isEmailConfirmed()) {
user.changeEmail(newEmail, company); // 呼べてしまう
}
CanExecute/Executeパターンは、この問題を解決します。
public class User {
public String canChangeEmail() {
if (isEmailConfirmed) {
return "確認済みのメールは変更できません";
}
return null; // 変更可能
}
public void changeEmail(String newEmail, Company company) {
// canChangeEmail()の成功が前提条件
if (canChangeEmail() != null) {
throw new IllegalStateException();
}
// ... 変更ロジック
}
}
このパターンには2つの利点があります。
-
コントローラーの判断が消える: コントローラーは
canChangeEmail()を呼ぶだけ。if文はあるが「判断」ではなく「結果への対応」にすぎない -
不正な呼び出しの防止:
changeEmail()の事前条件としてcanChangeEmail()が組み込まれているため、検証をスキップできない
テストの観点では、ドメインクラスのcanChangeEmail()とchangeEmail()をユニットテストで検証すれば十分です。
ドメインイベントによる変更の追跡
「メールアドレスが実際に変更されたときだけ外部に通知する」のような条件つき副作用は、コントローラーに判断を持たせると複雑化の原因になります。
ドメインイベントは、ドメインクラス内で「何が起きたか」を記録し、コントローラーはその記録を処理するだけ、という役割分担を実現します。
public class User {
private List<DomainEvent> events = new ArrayList<>();
public void changeEmail(String newEmail, Company company) {
if (email.equals(newEmail)) return;
// ... 変更ロジック
// イベントとして記録
events.add(new EmailChangedEvent(userId, newEmail));
}
public List<DomainEvent> getEvents() {
return Collections.unmodifiableList(events);
}
}
コントローラーはイベントを処理するだけです。
// コントローラー側
user.changeEmail(newEmail, company);
for (DomainEvent event : user.getEvents()) {
if (event instanceof EmailChangedEvent e) {
messageBus.send(e.getUserId(), e.getNewEmail());
}
}
テストの観点では、ドメインイベントの生成をユニットテストで検証できます。
Mockが不要になるのが大きなメリットです。
ドメインイベントは「これから行われるプロセス外依存への呼び出し」の抽象化です。
実際の外部呼び出しよりも抽象のほうがテストしやすい——これはHumble Objectパターンと同じ発想です。
まとめ
テストの書きにくさは、プロダクションコードの設計が発しているシグナルです。
テスト側で回避しようとするとアンチパターンに陥ります。
この記事で扱った正攻法とアンチパターンを対応表にまとめます。
| テストが書きにくい状況 | 誤った対処(アンチパターン) | 正しい対処(設計変更) |
|---|---|---|
| ロジックと外部依存が同居 | 具象クラスの一部をMock | Humble Objectで分離 |
| privateメソッドに重要ロジック | privateをpublicに変更 | 抽象の欠落を認識し別クラスに抽出 |
| 内部状態を検証したい | privateフィールドを公開 | 観察可能な振る舞い経由でテスト |
| テスト用の条件分岐が必要 | 本番コードにテスト用スイッチ | インターフェースで分離 |
| 期待値の計算が複雑 | テスト内でアルゴリズムを再現 | 期待値をハードコード |
| 現在時刻への依存 | アンビエントコンテキスト | 明示的な依存注入(値を優先) |
コントローラーが複雑になる場合は、CanExecute/Executeパターンやドメインイベントで緩和できます。
「テストが書きにくいと感じたら、まず4分類でコードの現在地を確認する。
過度に複雑なコードがあれば、Humble Objectパターンで分離する」
——この判断回路を実務で回していくことが、テストの価値を最大化する最も確実な方法だと自分は考えています。