はじめに
テストを書いていて、ふと疑問に思うことはありませんか。
「なぜこのテストはリファクタリングのたびに壊れるのに、あのテストはずっと安定しているのか?」
壊れやすいテストの原因を探ると、テストフレームワークの使い方やMockライブラリの問題に目が向きがちです。
しかし、実はテストの「スタイル」そのものに品質の差があります。
ユニットテストの書き方を分解すると、検証の対象は3つに分類できます。
- 戻り値を検証する
- 状態の変化を検証する
- 呼び出し(Mock)を検証する
多くの開発者はこの3つを無自覚に使い分けられているらしいです。
「とにかく動くテストが書ければいい」くらいの感覚で、検証方法の選択がテストの品質にどう影響するかなんて考えたことがなかったんです。
この記事では、3つのテストスタイルを定義して品質を比較し、最も優れたスタイルが「出力ベーステスト」であることを示します。
そしてもう一歩踏み込んで、出力ベーステストを増やすには
テスト技術ではなくプロダクションコードの設計を変える必要がある
という気づきを共有します。
個人的にはこれが一番大きな学びでした。
TL;DR
- 3つのテストスタイル(出力ベース・状態ベース・コミュニケーションベース)のうち、出力ベースが全ての面で最も有利
- 出力ベースが使えるのは純粋関数のみ。関数型アーキテクチャでその適用範囲を広げられる
- 優先順位: 出力ベース → 状態ベース → コミュニケーションベース(Mock)は最小限に
テストスタイルの全体像 ― 3つの検証アプローチ
テストを書くとき、「何を検証するか」には選択肢があります。
戻り値を見るのか、状態の変化を見るのか、他のコンポーネントへの呼び出しを見るのか。
この選択がテストの品質を大きく左右します。
まずは3つのスタイルを具体的に見ていきましょう。
┌─────────────────────────────────────────────────────────┐
│ 3つのテストスタイル │
├───────────────┬───────────────┬─────────────────────────┤
│ 出力ベース │ 状態ベース │ コミュニケーションベース │
│ │ │ │
│ 入力 → 出力 │ 操作 → 状態 │ テスト対象 → Mock │
│ 戻り値を検証 │ 変化後を検証 │ 呼び出しを検証 │
└───────────────┴───────────────┴─────────────────────────┘
出力ベーステスト:戻り値だけを見る
出力ベーステスト(output-based testing)は、テスト対象に入力を与え、返される戻り値だけを検証するスタイルです。
前提として、テスト対象がグローバル状態や内部状態を変更しないことが求められます。
つまり、そのメソッドの結果は戻り値に全て表れている、という状態です。
たとえば、商品の配列を受け取って割引率を計算するメソッドを考えてみましょう。
# プロダクションコード
class PriceEngine:
def calculate_discount(self, products: list[Product]) -> float:
discount = len(products) * 0.01
return min(discount, 0.2)
# テスト
def test_discount_for_two_products():
engine = PriceEngine()
products = [Product("Apple"), Product("Banana")]
discount = engine.calculate_discount(products)
assert discount == 0.02
このテストが検証しているのは戻り値(割引率)だけです。
内部コレクションに商品が追加されたか、DBに保存されたかは関係ありません。
入力と出力の関係がシンプルで、テストも短く書けます。
出力ベーステストは「関数型テスト(functional testing)」とも呼ばれます。
この名前は関数型プログラミングに由来しており、副作用のないコードと深く関連しています。
詳しくは後半で扱います。
状態ベーステスト:操作後の状態を見る
状態ベーステスト(state-based testing)は、操作を実行した後の状態の変化を検証するスタイルです。
ここでいう「状態」は、テスト対象自身の状態、協力オブジェクトの状態、DBやファイルシステムといった外部依存の状態のいずれも含みます。
先ほどの出力ベーステストと対比してみましょう。
# プロダクションコード
class Order:
def __init__(self):
self.products: list[Product] = []
def add_product(self, product: Product) -> None:
self.products.append(product)
# テスト
def test_add_product():
order = Order()
product = Product("Apple")
order.add_product(product)
assert len(order.products) == 1
assert order.products[0] == product
add_product() に戻り値はありません。
代わりに、注文オブジェクトの products リストが変化したことを検証しています。
テストの関心が「戻り値」ではなく「操作後の状態」にあるのが特徴です。
コミュニケーションベーステスト:呼び出しを見る
コミュニケーションベーステスト(communication-based testing)は、テスト対象と協力オブジェクトのやり取りを検証します。
Mockを使って「どのメソッドがどんな引数で呼ばれたか」を確認するスタイルです。
# テスト
def test_greet_sends_email(mocker):
email_service = mocker.Mock()
controller = GreetingController(email_service)
controller.greet("alice@example.com")
email_service.send.assert_called_once_with(
"alice@example.com", "Welcome!"
)
このテストでは、メソッドの戻り値もオブジェクトの状態も検証していません。
検証しているのは「メール送信サービスが正しい引数で呼ばれたか」というやり取りです。
全ての呼び出しをMockで検証すべきかというと、そうではありません。
Mockが正当化されるのは、アプリケーション境界を越える副作用(メール送信、ファイル書き込みなど、エンドユーザーに見える副作用)のみです。
内部の協力オブジェクト間のやり取りまでMockで検証すると、テストが実装の詳細に密結合し、リファクタリングのたびに壊れるようになります。
補足:学派とスタイルの対応関係
ユニットテストには「Classical学派」と「London学派」という2つの流派があります。
この2つの学派は、好むテストスタイルに違いがあります。
- Classical学派: 状態ベースを好む
- London学派: コミュニケーションベースを好む
- 共通: どちらの学派も出力ベーステストは使う
テストに関する議論で意見が食い違うとき、実はスタイルの好み(学派の違い)が根っこにあるケースは少なくありません。
この対応関係を知っておくと、議論の見通しがよくなります。
なぜ出力ベースが最良なのか ― 4本柱による比較
3つのスタイルを定義したところで、次に知りたいのは「どれが最も優れているのか」です。
感覚的な好みではなく、テスト品質の4本柱(リグレッション防護・リファクタリング耐性・フィードバック速度・保守性)を基準に比較します。
4本柱とは「良いユニットテスト」を評価するための指標で、この4つのバランスが取れているほどテストの品質が高いと言えます。
┌──────────────────────────────────────────────┐
│ テスト品質の4本柱 │
├──────────────────────────────────────────────┤
│ 1. リグレッション防護 ... バグを検出する力 │
│ 2. リファクタリング耐性 ... 偽陽性の少なさ │
│ 3. フィードバック速度 ... テスト実行の速さ │
│ 4. 保守性 ... 理解・変更のしやすさ │
└──────────────────────────────────────────────┘
リグレッション防護とフィードバック速度 ― 3スタイルに差はない
まず結論から言うと、リグレッション防護とフィードバック速度ではスタイル間に目立った差はありません。
- リグレッション防護: テストが実行するコードの量・複雑さ・ドメイン的な重要度で決まるため、どのスタイルでも同等のレベルを達成できる
- フィードバック速度: 外部依存を使わないユニットテストの範囲であれば、3スタイルで大差ない。コミュニケーションベースはMockの生成にわずかなオーバーヘッドがあるが、テストが数万件規模にならない限り無視できるレベル
つまり、スタイル間の品質差は残りの2本柱で現れます。
リファクタリング耐性 ― 出力ベースが最も安全
リファクタリング耐性とは、リファクタリング時に偽陽性を出さない度合いです。
偽陽性(false positive)とは、プロダクションコードは正しく動いているのに、テストだけが「失敗」と報告してしまう現象のことです。
実装の内部構造を変えただけでテストが赤くなった経験があるなら、それが偽陽性です。
偽陽性は、テストが実装の詳細に結合しているときに発生します。
この「実装詳細への結合のしやすさ」がスタイルごとに異なります。
- 出力ベース: テスト対象メソッドの戻り値のみに依存するため、実装詳細との結合が最小。偽陽性のリスクが最も低い
- 状態ベース: 戻り値に加えてオブジェクトの状態にも依存する。テストが触れるAPIの面積が大きくなる分、実装の漏れ出しに結合する確率が上がる
- コミュニケーションベース: 呼び出しパターンの検証は最も偽陽性を生みやすい。特に内部の協力オブジェクトとのやり取りを検証してしまうと、リファクタリングのたびにテストが壊れる
どのスタイルでも、「観察可能な振る舞いだけをテストする」という原則を守れば偽陽性は減らせます。
ただし、その原則を守るために必要な注意の量がスタイルによって異なるのがポイントです。
出力ベースは構造的に実装詳細から離れているため、意識しなくても偽陽性が起きにくい。
コミュニケーションベースは、慎重にMockの対象を選ばないと簡単に偽陽性が生まれます。
保守性 ― 出力ベースが圧倒的に有利
保守性は「テストの理解しやすさ(テストのサイズ)」と「テストの実行しやすさ(外部依存の数)」で決まります。
出力ベーステスト
入力を渡して出力を検証するだけなので、テストが短く簡潔です。
外部依存もありません。
保守性が最も高いスタイルです。
状態ベーステスト
状態検証のコードが膨らみやすいという性質があります。
たとえば、記事にコメントを追加した後、コメントの各プロパティ(テキスト、著者、作成日時、承認状態...)を個別に検証するケースを考えてみてください。
アサーション部分だけで4行以上になることも珍しくありません。
この膨張はヘルパーメソッドや値オブジェクト化で緩和できますが、それぞれ使える条件があります。
- ヘルパーメソッド: 複数テストで再利用される場合のみ、作成・保守のコストに見合う
- 値オブジェクト化: クラスが本質的に「値」である場合のみ有効。そうでなければ、テストのためだけにプロダクションコードを汚染することになる
コミュニケーションベーステスト
テストダブルのセットアップとインタラクションのアサーションが場所を取ります。
さらに、MockがMockを返す多層構造(Mockチェーン)があると、テストコードはさらに肥大化します。
保守性が最も低いスタイルです。
比較結果のまとめ
3スタイルの比較結果を整理します。
リグレッション防護とフィードバック速度は3スタイルで同等のため、差がついた2項目に絞っています。
| 評価軸 | 出力ベース | 状態ベース | コミュニケーションベース |
|---|---|---|---|
| リファクタリング耐性の維持に必要な注意 | 低い | 中程度 | 高い |
| 保守コスト | 低い | 中程度 | 高い |
出力ベーステストが全面的に最良です。
可能な限り出力ベースを使うのが理想ですが、「常に使える」わけではありません。
出力ベーステストは**副作用のないコード(純粋関数)**にしか適用できない、という制約があります。
では、純粋関数とは何でしょうか。
出力ベーステストの前提条件 ― 純粋関数という制約
出力ベーステストが最良だとわかっても、全てのコードに適用できるわけではありません。
この壁を理解するために、出力ベーステストが前提としている「純粋関数」の概念を押さえましょう。
純粋関数とは何か
純粋関数(pure function / mathematical function)とは、隠れた入出力を持たない関数のことです。
具体的には、以下の2つの条件を満たします。
- 全ての入力と出力がメソッドシグネチャ(メソッド名・引数・戻り値型)に明示されている
- 同じ入力に対して常に同じ出力を返す
先ほどの calculate_discount() を思い出してください。
def calculate_discount(self, products: list[Product]) -> float:
discount = len(products) * 0.01
return min(discount, 0.2)
入力は商品のリスト、出力は割引率。
どちらもメソッドシグネチャに全て表れています。
何度呼んでも同じ入力には同じ結果を返します。
これは純粋関数です。
純粋関数かどうかを判定するシンプルな基準があります。
その関数呼び出しを戻り値そのもので置き換えても、
プログラムの動作が変わらないか?
具体例で確かめてみましょう。
calculate_discount([a, b]) は 0.02 を返すとします。
このとき、以下の2つのコードは完全に等価です。
# 関数を呼び出して使う
total = price * (1 - calculate_discount([a, b]))
# 呼び出しを戻り値(0.02)そのもので置き換える
total = price * (1 - 0.02)
calculate_discount() は何度呼んでも同じ入力には同じ 0.02 を返し、
それ以外に何もしません(ファイル書き込みも、状態変更も、外部参照もない)。
だからこそ、呼び出しと戻り値を交換してもプログラムの挙動は一切変わりません。
この「呼び出しを戻り値に置き換えても動作が変わらない性質」を
**参照透過性(referential transparency)**と呼び、
これを満たす関数が純粋関数です。
隠れた入出力の正体 ― 副作用・例外・外部状態
純粋関数でないコードには「隠れた入出力」が存在します。
代表的なものは3つあります。
┌───────────────────────────────────────────────────┐
│ 隠れた入出力の3パターン │
├─────────────────┬─────────────────────────────────┤
│ 副作用 │ シグネチャに表れない出力 │
│ (hidden output) │ 例: 状態の変更、ファイルへの書き込み │
├─────────────────┼─────────────────────────────────┤
│ 例外 │ シグネチャが示す契約を迂回する出力 │
│ (hidden output) │ 例: コールスタックのどこでもcatch可能│
├─────────────────┼─────────────────────────────────┤
│ 外部状態への参照 │ シグネチャに表れない入力 │
│ (hidden input) │ 例: 現在時刻の取得、DB問い合わせ │
└─────────────────┴─────────────────────────────────┘
具体例で見てみましょう。
class Article:
def __init__(self):
self._comments: list[Comment] = []
def add_comment(self, text: str) -> Comment:
comment = Comment(text)
self._comments.append(comment) # ← 副作用(隠れた出力)
return comment
一見すると add_comment() は Comment を返しているだけに見えます。
しかし内部では _comments リストも変更しています。
戻り値だけでは全ての出力を表現できないため、これは純粋関数ではありません。
隠れた入出力があるコードは、テストの際に「メソッドシグネチャに表れない変化」まで追いかける必要があり、テストが複雑になります。
ここまでの整理 ― 出力ベーステストの適用条件
出力ベーステストが適用できる条件を整理すると、こうなります。
テスト対象が純粋関数であること(隠れた入出力がないこと)
しかし、副作用のないアプリケーションは現実には存在しません。
DB更新、メール送信、ファイル書き込み――副作用こそがアプリケーションの存在意義です。
ここで問いが変わります。
「全てを純粋関数にする」のは不可能ですが、「純粋関数の割合をどうやって最大化するか」なら考えられます。
その答えが、次のセクションで扱う関数型アーキテクチャです。
関数型アーキテクチャ ― 出力ベーステストを可能にする設計
副作用を完全に排除することはできません。
しかし、ビジネスロジックと副作用を分離することはできます。
この分離を体系的に行う設計が関数型アーキテクチャ(functional architecture)です。
出力ベーステストの適用範囲を劇的に広げる鍵になります。
基本原則:判断するコードと実行するコードを分ける
関数型アーキテクチャの基本的な考え方は、コードを2種類に分離することです。
- 判断するコード(decision-making): 「何をすべきか」を決める。副作用を持たないので、純粋関数として書ける
- 実行するコード(acting on decisions): 判断の結果を外部に反映する。DB更新、ファイル書き込みなど
この分類が、関数型アーキテクチャの2つの層を生みます。
┌───────────────────────────────────────────┐
│ 関数型アーキテクチャ │
│ │
│ ┌───────────────────────────────────┐ │
│ │ 可変シェル (mutable shell) │ │
│ │ │ │
│ │ 1. 全ての入力データを収集する │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌─────────────────────────┐ │ │
│ │ │ 機能コア │ │ │
│ │ │ (functional core) │ │ │
│ │ │ │ │ │
│ │ │ 2. 判断を生成する │ │ │
│ │ │ (純粋関数) │ │ │
│ │ └─────────────────────────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ 3. 判断を副作用に変換する │ │
│ │ (DB更新、ファイル書き込みなど) │ │
│ └───────────────────────────────────┘ │
│ │
└───────────────────────────────────────────┘
- 機能コア(functional core): 判断を行う層。全てのビジネスロジックがここに集中する。副作用を一切持たない
- 可変シェル(mutable shell): 機能コアに入力を渡し、返された判断を副作用として実行する層
協調の流れは「収集 → 判断 → 実行」の3ステップです。
- 可変シェルが全ての入力データを収集する
- 機能コアが判断を生成する
- 可変シェルが判断を副作用に変換する
ここで重要なのは、可変シェルは可能な限り「愚か」であるべきという点です。
機能コアが返す「判断」には、可変シェルが追加の意思決定なしに行動できるだけの情報が含まれていなければなりません。
if文や分岐が可変シェルに入り込んだ時点で、ビジネスロジックが漏れ出していることになります。
テスト戦略としては、機能コアを出力ベーステストで網羅的にカバーし、可変シェルは少数の統合テストでカバーする、という使い分けになります。
ヘキサゴナルアーキテクチャとの比較
関数型アーキテクチャの位置づけを理解するために、よく知られたヘキサゴナルアーキテクチャと比較してみます。
| 観点 | ヘキサゴナルアーキテクチャ | 関数型アーキテクチャ |
|---|---|---|
| 分離の対象 | ドメイン層 と アプリケーションサービス層 | 機能コア と 可変シェル |
| 依存の方向 | 一方向(ドメイン層は外側に依存しない) | 一方向(機能コアは外側に依存しない) |
| ドメイン層内の副作用 | OK(自身の状態変更は許容) | NG(一切の副作用を排除) |
共通しているのは「関心の分離」と「依存の一方向性」です。
違うのは副作用の扱いです。
ヘキサゴナルアーキテクチャでは、ドメインクラスが自分自身の状態を変更することは許容されます。
「ドメイン層の境界を越えなければOK」というスタンスです。
関数型アーキテクチャでは、機能コアから一切の副作用を排除します。
自分自身の状態変更すら許容しません。
この意味で、関数型アーキテクチャはヘキサゴナルアーキテクチャの極端版(サブセット)と位置づけることができます。
カプセル化とイミュータビリティ
OOP(オブジェクト指向プログラミング)では、状態の不整合からコードを守るためにカプセル化を使います。
APIの面積を減らし、変更可能な部分を精査することで、不整合を防ぎます。
関数型プログラミングは同じ問題を別の角度から解決します。
そもそも変更できないなら、不整合は起きない。これがイミュータビリティの発想です。
イミュータブルなオブジェクトは生成時に一度だけバリデーションすればよく、以降は自由に受け渡せます。
OOPは「可動部品をカプセル化」することで、
関数型プログラミングは「可動部品を最小化」することで、
コードを理解可能にしているわけです。
実例で見る3段階のリファクタリング ― 監査システム
ここまでの概念が実際のコードでどう変わるかを確認しましょう。
ファイルベースの監査システムを題材に、3段階でリファクタリングし、テストの品質が設計の改善に連動して向上する過程を追います。
題材:監査システムの仕様
監査システムは、訪問者の名前と時刻をテキストファイルに記録するシステムです。
1ファイルあたりのエントリ数に上限があり、上限を超えたら新しいファイルを作成します。
audit_1.txt audit_2.txt
───────────── ─────────────
Alice;2024-04-01 David;2024-04-03
Bob;2024-04-02 Eve;2024-04-04
Charlie;2024-04-02 (上限に達したら次のファイルへ)
ステージ1:初期版 ― ファイルシステムに直接依存
初期版の AuditManager はファイルシステムを直接操作します。
class AuditManager:
def __init__(self, max_entries: int, directory: str):
self._max_entries = max_entries
self._directory = directory
def add_record(self, visitor: str, time_of_visit: datetime):
file_paths = os.listdir(self._directory)
sorted_files = self._sort_by_index(file_paths)
new_record = f"{visitor};{time_of_visit}"
if not sorted_files:
new_file = os.path.join(self._directory, "audit_1.txt")
write_file(new_file, new_record)
return
current_file = sorted_files[-1]
lines = read_all_lines(current_file)
if len(lines) < self._max_entries:
lines.append(new_record)
write_file(current_file, "\n".join(lines))
else:
new_index = len(sorted_files) + 1
new_file = os.path.join(
self._directory, f"audit_{new_index}.txt"
)
write_file(new_file, new_record)
このコードをテストしようとすると、ファイルシステムに直接依存しているため、以下の問題が発生します。
- テストの前にファイルを配置し、後で読み取って確認し、片付ける必要がある
- ファイルシステムは共有依存なので、テストの並列実行が困難
- テスト環境でディレクトリの存在やアクセス権を管理する保守コストがかかる
正直なところ、これはユニットテストというより統合テストの領域です。
ステージ2:Mock版 ― インターフェースで分離
よくある改善策は、ファイル操作をインターフェースに抽出してDI(依存性注入)することです。
class AuditManager:
def __init__(self, max_entries: int, directory: str,
file_system: IFileSystem):
self._max_entries = max_entries
self._directory = directory
self._file_system = file_system
def add_record(self, visitor: str, time_of_visit: datetime):
file_paths = self._file_system.get_files(self._directory)
# ... 以降、self._file_system 経由でファイル操作
テストではMockを使ってファイルシステムへの呼び出しをキャプチャします。
def test_new_file_created_when_limit_reached(mocker):
file_system = mocker.Mock()
file_system.get_files.return_value = ["audit_1.txt"]
file_system.read_all_lines.return_value = [
"Alice;2024-04-01", "Bob;2024-04-02", "Charlie;2024-04-02"
]
sut = AuditManager(3, "/audit", file_system)
sut.add_record("David", datetime(2024, 4, 3))
file_system.write_all_text.assert_called_once_with(
"/audit/audit_2.txt", "David;2024-04-03"
)
ファイルシステムへの直接依存がなくなり、テスト速度と保守性は改善されました。
ただし、Mockのセットアップがそれなりに複雑で、テストの可読性は高いとは言えません。
この例でのMock使用は正当です。
ファイルはエンドユーザーに見える副作用であり、アプリケーション境界を越える通信だからです。
ステージ3:関数型版 ― 機能コアと可変シェルに分離
さらに踏み込んだ改善が、関数型アーキテクチャへのリファクタリングです。
副作用をインターフェースの裏に隠すのではなく、クラスの外に完全に追い出します。
# 機能コア(純粋関数 ― 副作用なし)
class AuditManager:
def __init__(self, max_entries: int):
self._max_entries = max_entries
def add_record(self, files: list[FileContent],
visitor: str,
time_of_visit: datetime) -> FileUpdate:
sorted_files = self._sort_by_index(files)
new_record = f"{visitor};{time_of_visit}"
if not sorted_files:
return FileUpdate("audit_1.txt", new_record)
current_file = sorted_files[-1]
if len(current_file.lines) < self._max_entries:
new_lines = current_file.lines + [new_record]
return FileUpdate(
current_file.name, "\n".join(new_lines)
)
else:
new_index = len(sorted_files) + 1
return FileUpdate(
f"audit_{new_index}.txt", new_record
)
# 可変シェル(副作用だけ ― 分岐ロジックなし)
class Persister:
def read_directory(self, directory: str) -> list[FileContent]:
# ファイルを読み込んで FileContent のリストを返す
...
def apply_update(self, directory: str, update: FileUpdate):
# FileUpdate の内容をファイルに書き込む
...
# 接着層(機能コアと可変シェルをつなぐ)
class ApplicationService:
def __init__(self, directory: str, manager: AuditManager,
persister: Persister):
self._directory = directory
self._manager = manager
self._persister = persister
def add_record(self, visitor: str, time_of_visit: datetime):
files = self._persister.read_directory(self._directory)
update = self._manager.add_record(
files, visitor, time_of_visit
)
self._persister.apply_update(self._directory, update)
テストは劇的に簡潔になります。
def test_new_file_created_when_limit_reached():
files = [
FileContent("audit_1.txt", [
"Alice;2024-04-01",
"Bob;2024-04-02",
"Charlie;2024-04-02",
])
]
sut = AuditManager(max_entries=3)
update = sut.add_record(
files, "David", datetime(2024, 4, 3)
)
assert update.file_name == "audit_2.txt"
assert update.content == "David;2024-04-03"
Mockは一切ありません。
仮想的なファイル状態を入力として渡し、返される FileUpdate を検証するだけです。
入力と出力だけの世界なので、テストが何を検証しているかが一目でわかります。
3段階の比較
| 評価軸 | 初期版 | Mock版 | 関数型版 |
|---|---|---|---|
| リグレッション防護 | Good | Good | Good |
| リファクタリング耐性 | Good | Good | Good |
| フィードバック速度 | Bad | Good | Good |
| 保守性 | Bad | Moderate | Good |
Mock版から関数型版への改善で変わったのは保守性です。
入力と出力だけのテストは、Mockのセットアップがあるテストより圧倒的に読みやすい。
正直、この3段階を比較してみて「テストの品質を上げるために変えるべきはテストコードではなくプロダクションコードの設計だった」という実感が強くなりました。
関数型アーキテクチャの限界 ― 万能ではない理由
関数型アーキテクチャが万能なら、全てのコードをこの設計にすればいいはずです。
しかし、そう単純にはいきません。
実務で採用する際に知っておくべき制約とトレードオフがあります。
全ての入力を事前に収集できないケース
関数型アーキテクチャは「入力を全て集める → 判断する → 副作用を実行する」という流れを前提としています。
しかし、判断の途中で追加データが必要になるケースがあります。
たとえば、監査システムで「訪問回数が閾値を超えた場合にのみDBからアクセスレベルを取得する」という要件を想像してみてください。
この場合、判断の途中でDBアクセスが発生するため、「収集 → 判断 → 実行」のきれいな流れが崩れます。
対応策は2つありますが、いずれもトレードオフです。
- パフォーマンスを犠牲にする: アクセスレベルを事前に無条件で取得する。「収集 → 判断 → 実行」の分離は維持されるが、不要なDB呼び出しが発生する
- 分離を犠牲にする: 「DB呼び出しが必要かどうか」の判断をアプリケーションサービスに持たせる。パフォーマンスは維持されるが、意思決定の一部が機能コアの外に漏れる
なお、機能コアにDBインターフェースを直接渡す(IDatabase を注入する)のは避けるべきです。
隠れた入力が生まれ、純粋関数でなくなってしまいます。
パフォーマンスへの影響
注意が必要なのは、テストではなくシステム全体のパフォーマンスです。
テスト自体は出力ベーステストもMock版と同等に高速ですが、プロダクションコード側に影響があります。
- 「読む → 決める → 書く」のアプローチでは、初期版やMock版よりも多くの外部依存呼び出しが必要になることがある
- 監査システムの例でいえば、初期版はファイルを1つだけ読めば済んでいたが、関数型版では全ファイルの内容を読み込む必要がある
パフォーマンスと保守性のトレードオフであり、システムの特性に応じた判断が求められます。
コードベースの規模増加
機能コアと可変シェルの明確な分離には、追加のクラスやデータ構造が必要です。
監査システムの例でも、以下のクラスが新たに登場しました。
-
FileContent(ファイルの内容を表す値オブジェクト) -
FileUpdate(書き込み指示を表す値オブジェクト) -
Persister(可変シェル) -
ApplicationService(接着層)
この初期投資としてのコード量増加は、全てのプロジェクトで見合うわけではありません。
ビジネス的に重要でないコードや、十分にシンプルなコードに適用すると、かえって過剰な複雑さを持ち込みます。
関数型アーキテクチャは戦略的に適用すべきものです。
おわりに
この記事の核心は、「テストの品質を上げたければ、テスト技術ではなくプロダクションコードの設計を見直す」という発想の転換です。
3つのテストスタイルを比較した結果、出力ベーステストが最も品質が高いことがわかりました。
そして出力ベーステストを増やすには、テスト側を工夫するのではなく、プロダクションコードを関数型アーキテクチャで設計する必要があります。
とはいえ、関数型アーキテクチャは万能ではありません。
パフォーマンスやコードベースの規模とのトレードオフがあり、全てのプロジェクトに適用すべきものでもありません。
OOP言語では、出力ベースと状態ベースの組み合わせにコミュニケーションベースが少量混ざるのが現実的な着地点です。
実践的な行動指針としては、以下の順で考えるのがよさそうです。
- まず、自分が書いているテストがどのスタイルかを意識する
- コミュニケーションベースを減らし、出力ベースを増やせないか検討する
- 出力ベースが増やせないなら、プロダクションコードの設計に改善の余地があるサインかもしれない