はじめに
ユニットは全部緑。なのに本番で繋いだ瞬間にエラーが噴き出す。
バグはコードの中ではなく、コードどうしの「接続点」に潜むからです。
統合テストを「接続点を攻める道具」として再設計します。
TL;DR
- 統合テストの本質は「接続点」を攻めること。接続点は 内側(複数クラスの集合)と 外側(DB・外部API・フレームワーク)に分かれる
- 内側の接続点は「まとめて動かすテスト」、外側の接続点は「外部依存と本気で接続するテスト」で攻める。統合の順序戦略(トップダウン・ボトムアップ・サンドイッチ・ビッグバン)も範囲設計の一部
- 統合テストは高コスト。ピラミッドとコスト/ベネフィットで範囲を絞る。「カバーされているがテストされていない」罠も意識する
1. ユニットテストでは捕まえられない「接続点」の欠陥
ユニットテストが全部通っているのに繋ぐと壊れるのはなぜか。最初に、壊れる場所の構造を見ていきます。
1.1 中心事例: 在庫アラート配信サービス
この記事を通じて使う例として、在庫アラート配信サービスを設定します。
あるEC事業者が、在庫の枯渇しそうな商品を担当者に通知する内製サービスです。
構成要素は次のとおりです。
- 判定ルール群: 在庫数ルール、過去注文傾向ルール、季節指数ルールの3つを組み合わせて「アラートを出すか」を決める
-
集約クラス
AlertJudge: 判定ルール群を呼び出して結果をまとめる -
StockHistoryRepository: 在庫履歴を取得する。外部DBに接続する -
NotificationSender: 通知を送る。外部APIに接続する
全体の構成を図にすると次のようになります。
判定ルール群を AlertJudge が集約し、StockHistoryRepository 経由でDBから供給されたデータをもとに判定します。
その結果が NotificationSender から外部APIへ送られる構成です。
1.2 ユニットで通って、繋ぐと壊れる現象
このサービスを開発しているとき、各クラスにユニットテストを書きました。
在庫数ルール単体は緑、AlertJudge も判定ルールをモックして緑、StockHistoryRepository もDB部分をモックして緑です。
それでも、結合した瞬間に結果が想定と違うことが起きました。
たとえば次のような場面です。
- 各ルールは単体で見れば正しい。ところが3つのルールを
AlertJudgeで集約した結果、境界が重なってアラートが二重に発火する - DAO(DBアクセスクラス)はモックで通る。ところが本物のSQLにすると
ANDとORの優先順位の誤りでヒット件数がずれる -
NotificationSenderのテストでは想定したJSON形式を返すモックを使った。ところが本物のAPIは別形式のエラーレスポンスを返してきて例外で止まる
これらは、個々のクラスが「契約どおりに動く」ことを示すユニットテストでは原理的に捕まりません。
個々のクラスは正しい、けれど繋いだときの振る舞いまでは保証していない からです。
1.3 接続点を分類する
ユニットでは届かない欠陥が宿る場所を、ここでは 接続点 と呼びます。接続点は2種類に分かれます。
| 種類 | 場所 | 中心事例での具体例 |
|---|---|---|
| 内側の接続点 | 同じコードベース内、複数クラスの間 | 3つの判定ルールと AlertJudge の組み合わせ |
| 外側の接続点 | コードベースの外、外部依存との境界 |
StockHistoryRepository ↔ DB、NotificationSender ↔ 外部API |
内側の接続点は「複数のクラスをまとめて動かすと、想定どおりの結果になるか」が論点です。
外側の接続点は「外部依存と本気で繋いだとき、想定どおりに動くか」が論点です。
これらをまとめてDB・外部API・フレームワーク・ライブラリなどの外部依存と呼びます。
統合テストは、この接続点を攻める道具と捉え直すことができます。
なお、統合テストには「テスト工程としてのフェーズ」と「テストの種類としてのタイプ」の両面があります。
この記事では タイプとしての設計 に絞って話を進めます。
2. 統合テストの単位と順序を設計する
接続点に欠陥が宿ることが分かりました。
では、どの粒度でテストを書けばよいか、内側と外側それぞれの攻め方と、複数の接続点をどの順で繋いでいくかの戦略を見ていきます。
2.1 内側の接続点を攻める: 複数クラスをまとめて動かす
中心事例の AlertJudge と判定ルール群に戻ります。
個々の判定ルールはユニットテストで通っています。
AlertJudge も判定ルールをスタブ(呼び出される側の代役)で置き換えて通っています。
それでも、本物の判定ルールを本物の AlertJudge に渡すと何が起きるか は別物です。
ここで使うのが、複数のクラスをまとめて動かすテストです。
スタブで通すテストと、本物を束ねるテストの違いを並べてみます。
# ユニットテスト: 判定ルールはスタブで置き換える
def test_alert_judge_unit():
rule1 = StubRule(returns=True)
rule2 = StubRule(returns=False)
rule3 = StubRule(returns=True)
judge = AlertJudge([rule1, rule2, rule3])
result = judge.decide(stock_snapshot)
assert result.should_alert is True
# まとめて動かすテスト: 本物のルールクラスを束ねる
def test_alert_judge_with_real_rules():
judge = AlertJudge([
StockLevelRule(threshold=10),
OrderTrendRule(window_days=30),
SeasonalIndexRule(month=12),
])
snapshot = StockSnapshot(item_id="A001", stock=8, recent_orders=20)
result = judge.decide(snapshot)
assert result.should_alert is True
assert result.reasons == ["stock_level", "order_trend"]
下のテストでは、ルールの実装ロジックがそのまま集約クラスに渡ります。
スタブでは見えなかった「ルールの優先順位」「結果の集約方法」「複数ルールが同時にヒットしたときの挙動」が顕在化します。
注意点として、ここでは 外部依存(DB・外部API)は切り離す ことです。
内側の接続点を攻めるテストでDBやAPIまで巻き込むと、何が壊れたのか切り分けにくくなります。
2.2 外側の接続点を攻める: 外部依存と本気で接続する
次は外側の接続点です。
StockHistoryRepository が叩くDB、NotificationSender が叩く外部API。
これらをモックで済ますと、何が見えなくなるでしょうか。
- DBの場合: SQL文法、型変換、NULLの挙動、インデックスがある/ないで結果が変わる場合、トランザクション境界の挙動
- 外部APIの場合: HTTPステータスとレスポンス形式、タイムアウト、認証エラー、ペイロードサイズの上限
モックは「自分が想像する応答」を返すので、現実に起きる挙動を再現できません。
外側の接続点をテストするときの原則はシンプルです。
その外部依存と本気で接続して、本物の応答で検証する ことです。
DBであればテスト用に立てた実DBに、外部APIであればテスト用エンドポイントやローカルで動かすコンテナに繋ぎます。
これが、外側の接続点を攻めるテストの基本形です。
2.3 統合の順序戦略
接続点が複数あるとき、どの順序で繋いでテストするかも設計判断です。代表的な戦略は4つあります。
| 戦略 | どんな状況に向くか | 主なコスト |
|---|---|---|
| トップダウン | 上位コンポーネントが先に安定し、下位はスタブで代替可能 | 下位の代役(スタブ)を多く用意する必要がある |
| ボトムアップ | 下位コンポーネントから順に組み上げたい。下位が安定してから上位を載せる | 上位の代役(ドライバ)を用意する必要がある |
| サンドイッチ(両方向) | 中間層を新たに作る/差し替えるとき。両側から挟む | スタブ・ドライバの両方が必要 |
| ビッグバン | 大部分が既に安定し、最後の差分だけ一気に統合する | 障害発生時の切り分けが難しい |
ここで スタブ は「呼び出される側の下位コンポーネントの代役」、ドライバ は「呼び出す側の上位コンポーネントの代役」を指します。
中心事例で考えると、判定ルールが3つとも仕様変更で揺れている時期は、AlertJudge を先に安定させてルール側をスタブで止めるトップダウンが有効です。
逆にルールが既に確定していて、新しい集約クラスを試したい時期はボトムアップが向きます。
アーキテクチャの状況に合わせて選びます。
3. 外部依存の代表例: DBとSQLクエリをどうテストするか
外側の接続点は本気で接続して攻める、と決めました。その代表例であるDBとSQLは具体的にどう攻めるか、ここから実装に降りていきます。
3.1 SQLクエリの何をテストするか
SQLクエリは 述語の組み合わせ と捉えると見通しが良くなります。
述語とは、value >= 50 や country = 'JP' のような、真偽を返す条件式のことです。
中心事例の StockHistoryRepository に、こんなクエリがあるとします。
SELECT item_id, stock_count, recorded_at
FROM stock_history
WHERE stock_count <= :threshold
AND recorded_at BETWEEN :from AND :to
AND deleted_at IS NULL
このクエリには3つの述語が含まれます。これを攻める観点は、まず次の3つに集約できます。
| 観点 | 何を見るか | 中心事例での例 |
|---|---|---|
| 境界値 | 述語が真偽を切り替える境目(on/offポイント) |
stock_count <= 10 なら、9、10、11 を入れて結果を確認 |
| 条件の組み合わせ | 複数の述語の真偽の組み合わせを尽くす | 3つの述語の真偽を変えて、結果がどう変わるかを見る |
| NULLの扱い | NULLは三値論理(真・偽・不明)で挙動が変わる |
deleted_at が NULL の行と非NULLの行を両方混ぜる |
NULLは特殊で、NULL = NULL は真にならず「不明」を返します。これを意識せずクエリを書くと、想定外の行が結果に混じる/消えることがあります。
SQLには上記以外にも気をつけたい観点がいくつかあります。本記事で深掘りはしませんが、頭出しだけしておきます。
| 補足観点 | 何を見るか |
|---|---|
| グルーピング |
GROUP BY の単位で意図したまとまりになるか |
| 集約関数 |
SUM・AVG・COUNT で同じ値・異なる値の混ざりを見る |
| サブクエリ | サブクエリが空集合・NULL・複数行を返す場合の挙動 |
| 出力ドメイン | 結果が空になる場合、重複行が出る場合 |
3.2 clean state を仕組みで保つ
DBはメモリ上のオブジェクトと違って 状態を持ち続けます。前のテストが残した行が、次のテストの結果を変えてしまいます。
そこで、各テストの開始時に既知の状態にする原則を clean state(クリーンな初期状態)と呼びます。流れにすると次のとおりです。
ポイントは3つです。
- 接続のライフサイクル: テストごとに開いて閉じるか、スイート単位でまとめて開くか。テスト数が多ければスイート単位がコストを下げる
- truncateの位置: テスト開始時に行うのが分かりやすい。終了時に行う流派もあるが、「次のテストが始まるとき確実に空である」という保証は開始時のほうが直感的
- トランザクション境界: テストごとにコミットするか、ロールバックで戻すか。コミット派はテスト同士が干渉しない設計を強制でき、ロールバック派は速度が出る
3.3 テスト基盤に共通化する
接続管理・truncate・テストデータの作成を、毎回のテストメソッドに書くと、テストが「DB操作のノイズ」で埋まります。これは共通化の典型的な対象です。
共通化前のテストは次のような姿になりがちです。
def test_find_low_stock_in_range():
# ノイズ: 接続管理
conn = create_connection("test-db")
cursor = conn.cursor()
cursor.execute("TRUNCATE TABLE stock_history")
# ノイズ: テストデータ準備
cursor.execute("INSERT INTO stock_history VALUES ('A001', 8, '2026-01-01', NULL)")
cursor.execute("INSERT INTO stock_history VALUES ('A002', 15, '2026-01-02', NULL)")
conn.commit()
# ここからが本当のテスト
repo = StockHistoryRepository(conn)
result = repo.find_low_stock_in_range(threshold=10, from_="2026-01-01", to="2026-01-31")
assert len(result) == 1
assert result[0].item_id == "A001"
# ノイズ: 後片付け
conn.close()
共通化すると次のように変わります。
class StockHistoryRepositoryTest(SqlIntegrationTestBase):
# 接続管理・truncateは基底クラスが面倒を見る
def test_find_low_stock_in_range(self):
StockHistoryBuilder(self.conn) \
.add(item_id="A001", stock=8) \
.add(item_id="A002", stock=15) \
.build()
result = self.repo.find_low_stock_in_range(
threshold=10, from_="2026-01-01", to="2026-01-31"
)
assert [r.item_id for r in result] == ["A001"]
テストメソッドが「何を検証しているか」だけを語る形に近づきます。共通化すべき対象は次のあたりです。
- 接続の確立とクローズ
- テーブルの初期化(truncate と必要なスキーマ確認)
- 複雑なエンティティを組み立てるテストデータビルダー
- 頻出するアサーション
3.4 DB統合テストの判断ポイント
DB統合テストの設計には、いくつか判断が必要な論点があります。
| 論点 | 選択肢 | トレードオフ |
|---|---|---|
| 本番同等DB vs 軽量代用 | 同等は現実的、代用は高速・軽量 | 重要な振る舞いは本番同等で。型変換や関数の挙動は代用DBで化ける |
| データ量 | 1テストあたり1〜数行に絞る | 大量データはテストを遅く、読みにくくする |
| スキーマ進化への耐性 | マイグレーション(DBスキーマの段階的変更)をテスト側にも適用 | 結合点が増えるが、本番との乖離による破綻を防げる |
| テストデータビルダー | 複雑なエンティティ生成を局所化 | テストの可読性が大きく上がる。実装の手間はかかる |
特に「本番同等DBか、軽量代用DBか」は判断が割れるところです。
本番がPostgreSQLでテストがSQLiteといったケースでは、「テストは通るのに本番でSQLの関数が解釈されない」事故が起きえます。
重要な接続点は本番同等で動かす のが安全側の判断です。
外側の接続点はDB以外にも多様で、外部API・メッセージブローカー・ファイルシステム・キャッシュサーバーなどが該当します。押さえる原則は共通です。
- 本気で接続する
- 状態を仕組みで保つ
- 基盤に共通化する
4. 統合テストの範囲を絞る判断
書き方は見えました。次の問いは「どこまでやるか」です。統合テストは強力ですが、書くにも走らせるにもコストがかかります。
4.1 テストピラミッドと「重要な振る舞いだけを統合で」
テストの量を考えるときの目安として、ピラミッドの考え方があります。ユニットを土台に多めに置き、上の層ほど数を絞るという形です。
統合テストは中間層です。
ユニットで取りきれない接続点の重要な振る舞いだけ を対象にします。
「念のため全部やる」「ユニットで足りないからとにかく統合を増やす」と進めると、テストスイートが肥大化し、CIが遅くなり、壊れたときの調査コストも膨らみます。
中心事例で言えば、AlertJudge と判定ルールの組み合わせは「ビジネスの中核」なので統合で押さえる価値があります。
一方、各ルールの境界値や個別の計算ロジックはユニットで十分です。
4.2 コスト/ベネフィットで判断する
統合テストを1本書こうとするとき、4つの問いを立てます。
- 書くのにいくらかかるか(実装コスト)
- 動かすのにいくらかかるか(実行コスト・CI時間)
- どんなバグを捕まえうるか(想定する欠陥の種類)
- それは既にユニットで捕まえられないか(ユニットでの代替可能性)
判断のフローにすると次のようになります。
「ベネフィットが書くコストと走らせるコストの合計を上回るときだけ書く」というシンプルな原則です。
4.3 「カバーされているがテストされていない」罠
統合テストは複数のクラスやメソッドを一度に通すので、カバレッジ計測ツールから見ると「カバー率が一気に上がる」現象が起きます。ここに落とし穴があります。
たとえばこんなコードを考えます。
def calculate_alert_priority(stock_count, threshold):
if stock_count <= threshold * 0.5:
return "high"
if stock_count <= threshold:
return "medium"
return "low"
統合テストのなかで calculate_alert_priority が呼ばれて、結果として最終的なアラート判定が想定どおりになっていたとします。
カバレッジ計測上はこの関数の行がカバーされます。
ところが、この関数の中身を return "high" の1行に書き換えても、統合テスト全体は通り続ける、ということがありえます。
なぜならテストは関数の戻り値そのものをアサートしておらず、最後のアラート判定だけを見ているからです。
カバーされているが、その関数の振る舞いはテストされていない 状態です。
これを pseudo-tested と呼びます。
カバレッジ率は便利な指標ですが、「カバー = テスト済み」とは限らない、と意識しておく必要があります。
4.4 基盤への投資が範囲設計を支える
範囲を絞る判断は、テスト基盤への投資とセットで成立します。
| 基盤の状態 | 起きやすい範囲設計の歪み |
|---|---|
| 基盤が厚い(接続管理・初期化・データビルダーが整っている) | 統合テストを書くハードルが下がり、本当に必要な接続点に絞って投入できる |
| 基盤が薄い(毎回ノイズコードを書く必要がある) | 書くのが面倒で「念のため全部やる」か「逆に全くやらない」の極端に振れがち |
先ほど見たように、接続管理・初期化・データビルダーが基盤に寄っていれば、統合テストを書くハードルが下がります。
「どこまでやるか」を考えるとき、まず基盤がどこまで揃っているかを点検するとよいです。
おわりに
統合テストは「接続点」を攻める道具です。
内側の接続点(複数クラスの集合)と外側の接続点(DB・外部API・フレームワーク)を見分け、それぞれに合ったテストを設計することが出発点になります。
DBやSQLは外側の代表例です。
述語を境界・条件・NULLで攻め、clean stateを仕組みで保ち、接続管理やデータビルダーを基盤に共通化する。
原則を押さえれば、外部APIやメッセージブローカーなど他の外部依存にも同じ発想を適用できます。
そして、統合テストは強力だからこそ「全部やる」でも「念のため」でもなく、コスト/ベネフィットで選ぶ判断が必要です。
その判断こそが、統合テストを資産にしていく道だと自分は考えています。
