単体テストとは、関数やクラスなどの小さな部品を1つずつ、外部(DBや他の部品)から切り離して確かめるテストです。結合テストとは、複数の部品やDBなどを実際につないで、組み合わせたときに正しく動くかを確かめるテストです。 単体テストは「部品そのものの正しさ」を、結合テストは「部品と部品のつなぎ目の正しさ」を確認する、と整理するとわかりやすくなります。

違いを言葉で説明する記事は多くありますが、この記事では Pythonで小さな注文アプリを作り、pytestで単体テストと結合テスト(PostgreSQLの実DBを使用)を実際に書いて実行 しました。実行時間の実測値と、わざと仕込んだ3種類のバグが「どちらのテストで見つかったか」の結果を載せています。V字モデルとの対応、現場での呼び方の違い、テストピラミッド、観点、エビデンスの残し方、AIでテストを書くときの注意まで、実務で迷いやすい点をまとめました。

単体テストと結合テストの違い早見表

比較項目 単体テスト 結合テスト
英語・略称 Unit Test(UT) Integration Test(IT)
対象 関数・クラス・モジュール単体 複数モジュールの組み合わせ、DB・外部APIとの連携
外部依存 モック(偽物)などで置き換える 実物(または実物に近い環境)を使う
見つかるバグ 計算ミス、条件分岐の漏れ、境界値の誤り SQLの誤り、データの受け渡し、トランザクション、設定ミス
実行速度(今回の実測) 6件で0.01〜0.02秒 2件で0.05〜0.12秒
準備の手間 少ない DBや接続先の用意が必要
失敗時の原因特定 しやすい(範囲が狭い) 範囲が広く、調べる箇所が増える
主な担当 実装した開発者 開発者またはテスト担当
V字モデルで対応する工程 詳細設計 基本設計(方式設計)

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「単体テストと結合テストの違い|pytestの実例と実行結果で比較」

検証に使った小さな注文アプリ

「計算する部品」と「DBに保存する部品」を分けた、3ファイルだけのアプリ を用意しました。検証環境は Python 3.11.15、pytest 9.1.1、psycopg 3.3.6、PostgreSQL 16.13 です。

flowchart LR
    S[OrderService<br>注文を受け付ける] --> P[calc_total<br>税込合計を計算]
    S --> R[OrderRepository<br>DBに保存]
    R --> DB[(PostgreSQL<br>orders / order_items)]

合計の計算(pricing.py)

from decimal import Decimal, ROUND_HALF_UP

TAX_RATE = Decimal("0.10")


def calc_total(items, discount_rate=Decimal("0")):
    """items: [(unit_price, quantity), ...] を受け取り、税込合計(円)を返す"""
    if any(q <= 0 for _, q in items):
        raise ValueError("quantity must be positive")
    subtotal = sum(Decimal(p) * q for p, q in items)
    discounted = subtotal * (1 - discount_rate)
    total = discounted * (1 + TAX_RATE)
    return int(total.quantize(Decimal("1"), rounding=ROUND_HALF_UP))

DBへの保存(repository.py)

注文ヘッダー(orders)と明細(order_items)を、1つのトランザクション(まとめて成功か、まとめて取り消しかの単位) で保存します。

class OrderRepository:
    def __init__(self, conn):
        self.conn = conn

    def save(self, customer_id, items, total):
        """注文ヘッダーと明細を1トランザクションで保存し、注文IDを返す"""
        with self.conn.transaction():
            order_id = self.conn.execute(
                "INSERT INTO orders (customer_id, total) VALUES (%s, %s) RETURNING id",
                (customer_id, total),
            ).fetchone()[0]
            for unit_price, quantity in items:
                self.conn.execute(
                    "INSERT INTO order_items (order_id, unit_price, quantity)"
                    " VALUES (%s, %s, %s)",
                    (order_id, unit_price, quantity),
                )
        return order_id

    def find_total(self, order_id):
        row = self.conn.execute(
            "SELECT total FROM orders WHERE id = %s", (order_id,)
        ).fetchone()
        return row[0] if row else None

全体をつなぐサービス(service.py)とテーブル定義

from .pricing import calc_total


class OrderService:
    def __init__(self, repo):
        self.repo = repo

    def place_order(self, customer_id, items, discount_rate=0):
        total = calc_total(items, discount_rate)
        order_id = self.repo.save(customer_id, items, total)
        return order_id, total
CREATE TABLE orders (
    id          SERIAL PRIMARY KEY,
    customer_id INTEGER NOT NULL,
    total       INTEGER NOT NULL CHECK (total >= 0)
);
CREATE TABLE order_items (
    id         SERIAL PRIMARY KEY,
    order_id   INTEGER NOT NULL REFERENCES orders(id),
    unit_price INTEGER NOT NULL,
    quantity   INTEGER NOT NULL CHECK (quantity > 0)
);

OrderService がリポジトリを外から受け取る形(依存性の注入)にしている のがポイントです。こうしておくと、単体テストではモックを、結合テストでは本物のリポジトリを渡せます。

単体テストを書く:外部を切り離して部品だけを確かめる

計算ロジックの単体テスト

正常系・境界値・異常系を、それぞれ1つのテストで1つずつ確かめます。

from decimal import Decimal

import pytest

from order_app.pricing import calc_total


def test_合計に消費税10パーセントが加算される():
    assert calc_total([(1000, 2), (500, 1)]) == 2750


def test_割引は税計算の前に適用される():
    assert calc_total([(1000, 1)], discount_rate=Decimal("0.2")) == 880


def test_1円未満は四捨五入される():
    # 105 * 1.1 = 115.5 -> 116
    assert calc_total([(105, 1)]) == 116


@pytest.mark.parametrize("quantity", [0, -1])
def test_数量が0以下ならエラー(quantity):
    with pytest.raises(ValueError):
        calc_total([(100, quantity)])

モックを使ったサービスの単体テスト

DBの代わりにモック(本物のふりをする偽物のオブジェクト)を渡し、「正しい値で保存を呼んだか」だけを確かめます。

from unittest.mock import Mock

from order_app.service import OrderService


def test_計算した合計をリポジトリに渡して保存する():
    repo = Mock()
    repo.save.return_value = 42
    service = OrderService(repo)

    order_id, total = service.place_order(customer_id=7, items=[(1000, 2)])

    assert (order_id, total) == (42, 2200)
    repo.save.assert_called_once_with(7, [(1000, 2)], 2200)

実行結果(環境情報の行は一部省略しています):

$ python3 -m pytest tests/unit -v
platform linux -- Python 3.11.15, pytest-9.1.1, pluggy-1.6.0
collected 6 items

tests/unit/test_pricing.py::test_合計に消費税10パーセントが加算される PASSED [ 16%]
tests/unit/test_pricing.py::test_割引は税計算の前に適用される PASSED     [ 33%]
tests/unit/test_pricing.py::test_1円未満は四捨五入される PASSED          [ 50%]
tests/unit/test_pricing.py::test_数量が0以下ならエラー[0] PASSED         [ 66%]
tests/unit/test_pricing.py::test_数量が0以下ならエラー[-1] PASSED        [ 83%]
tests/unit/test_service.py::test_計算した合計をリポジトリに渡して保存する PASSED [100%]

============================== 6 passed in 0.02s ===============================

結合テストを書く:本物のDBにつないで確かめる

結合テストでは、モックを使わずに実際のPostgreSQLへ接続し、保存して読み戻すところまで確かめます。 テストごとにテーブルを作り直すフィクスチャ(テストの前準備と後片付けをまとめたもの)を用意しました。

# tests/integration/conftest.py
import os
from pathlib import Path

import psycopg
import pytest

DSN = os.environ.get("TEST_DSN", "postgresql://user:password@localhost/testdb")
SCHEMA = (Path(__file__).parents[2] / "schema.sql").read_text()


@pytest.fixture
def conn():
    with psycopg.connect(DSN) as c:
        c.execute("DROP TABLE IF EXISTS order_items, orders")
        c.execute(SCHEMA)
        c.commit()
        yield c
        c.rollback()
# tests/integration/test_repository.py
import psycopg
import pytest

from order_app.repository import OrderRepository
from order_app.service import OrderService


def test_注文を保存して合計を読み戻せる(conn):
    service = OrderService(OrderRepository(conn))
    order_id, total = service.place_order(7, [(1000, 2), (500, 1)])

    assert OrderRepository(conn).find_total(order_id) == total == 2750
    count = conn.execute(
        "SELECT count(*) FROM order_items WHERE order_id = %s", (order_id,)
    ).fetchone()[0]
    assert count == 2


def test_明細の保存に失敗したら注文ヘッダーも残らない(conn):
    repo = OrderRepository(conn)
    # 2件目の明細は quantity=0 で CHECK 制約違反になる
    with pytest.raises(psycopg.errors.CheckViolation):
        repo.save(7, [(1000, 1), (500, 0)], 1650)
    conn.rollback()  # 未確定の分を捨ててから、DBに何が残ったかを見る

    assert conn.execute("SELECT count(*) FROM orders").fetchone()[0] == 0

2つ目のテストは、「途中で失敗したら全部取り消される」というトランザクションの性質を確かめています。 明細の quantity=0 はPythonのコードではなく、DBのCHECK制約で弾かれます。こうした振る舞いは、本物のDBにつながないと確認できません。

$ python3 -m pytest tests/integration -v --durations=0
collected 2 items

tests/integration/test_repository.py::test_注文を保存して合計を読み戻せる PASSED [ 50%]
tests/integration/test_repository.py::test_明細の保存に失敗したら注文ヘッダーも残らない PASSED [100%]

============================== slowest durations ===============================
0.04s setup    tests/integration/test_repository.py::test_注文を保存して合計を読み戻せる
0.02s setup    tests/integration/test_repository.py::test_明細の保存に失敗したら注文ヘッダーも残らない
0.01s call     tests/integration/test_repository.py::test_注文を保存して合計を読み戻せる

(3 durations < 0.005s hidden.  Use -vv to show these durations.)
============================== 2 passed in 0.08s ===============================

実行時間を比べる

同じ環境でそれぞれ5回ずつ実行し、pytestが表示する所要時間を記録しました。

回 単体テスト(6件) 結合テスト(2件)
1 0.01秒 0.06秒
2 0.02秒 0.12秒
3 0.01秒 0.08秒
4 0.01秒 0.07秒
5 0.01秒 0.05秒

1件あたりに直すと、単体テストは数ミリ秒、結合テストは数十ミリ秒で、結合テストのほうが1桁ほど時間がかかっています。 --durations の結果を見ると、結合テストの時間の多くは setup(DB接続とテーブル作成)でした。

今回はローカルのDBでテストも数件なので差は小さく見えますが、テストが数百・数千件に増えると、この差がそのまま待ち時間の差になります。 これが「単体テストを厚く、結合テストは要所に」と言われる理由の1つです(Python自体の起動時間は含まない数値です)。

このバグはどちらで見つかる?3つのバグを仕込んで実験

正しく動くコードに、わざと3種類のバグを1つずつ入れて、単体テストと結合テストを両方実行しました。

バグA:四捨五入を切り捨てにしてしまう(計算ミス)

calc_total の最後を return int(total) に変えました(出力は抜粋)。

$ python3 -m pytest -q tests/unit
    def test_1円未満は四捨五入される():
        # 105 * 1.1 = 115.5 -> 116
>       assert calc_total([(105, 1)]) == 116
E       assert 115 == 116
E        +  where 115 = calc_total([(105, 1)])
FAILED tests/unit/test_pricing.py::test_1円未満は四捨五入される - assert 115 ...
1 failed, 5 passed in 0.03s
$ python3 -m pytest -q tests/integration
2 passed in 0.08s

単体テストが検出し、結合テストはすり抜けました。 結合テストの金額(2750円)はたまたま端数が出ない値だったためです。細かい条件の組み合わせは、速い単体テストで網羅するのが効率的です。

バグB:SQLの列名を間違える

INSERT文の列名 quantity を qty に変えました(出力は抜粋)。

$ python3 -m pytest -q tests/unit
6 passed in 0.02s
$ python3 -m pytest -q tests/integration
E           psycopg.errors.UndefinedColumn: column "qty" of relation "order_items" does not exist
E           LINE 1: INSERT INTO order_items (order_id, unit_price, qty) VALUES (...
E                                                                  ^
1 failed, 1 passed in 0.08s

単体テストはすべて成功し、結合テストだけが検出しました。 単体テストではリポジトリをモックにしているので、SQL文は一度も実行されていないからです。

バグC:明細を保存する前にコミットしてしまう(トランザクションの不備)

with self.conn.transaction(): をやめ、ヘッダーを保存した直後に self.conn.commit() するように変えました(出力は抜粋)。

$ python3 -m pytest -q tests/unit
6 passed in 0.01s
$ python3 -m pytest -q tests/integration
>       assert conn.execute("SELECT count(*) FROM orders").fetchone()[0] == 0
E       assert 1 == 0
1 failed, 1 passed in 0.09s

明細の保存に失敗したのに、注文ヘッダーだけがDBに1件残ってしまうバグを、結合テストが検出しました。

実験結果のまとめ

仕込んだバグ 単体テスト 結合テスト
A:四捨五入の誤り(計算ミス) 検出 見逃し
B:SQLの列名ミス 見逃し 検出
C:トランザクションの不備 見逃し 検出

どちらか一方だけでは足りず、役割の違う2種類を組み合わせることで、はじめて両方の種類のバグを捕まえられる ことが、実験からよくわかります。

ほかのバグの例と、見つかりやすいテスト

バグの例 見つかりやすいテスト 理由
条件分岐の漏れ、境界値(0件、上限値)の誤り 単体 入力を自由に作れる
例外処理の漏れ 単体 モックで異常を簡単に起こせる
SQLの構文・列名・型の不一致 結合 実DBで実行して初めてわかる
NOT NULLや外部キーなどの制約違反 結合 制約はDB側にある
部品間の引数の順番・単位の食い違い 結合 両方の部品が本物である必要がある
設定ファイル・接続情報の誤り 結合 実際に接続して初めてわかる

V字モデルとの対応と、現場での呼び方

V字モデルでは、左側の設計工程と右側のテスト工程が1対1で対応します。 単体テストは詳細設計(部品の中身)を、結合テストは基本設計(部品のつなぎ方)を確かめる位置づけです。

flowchart LR
    RD[要件定義] --- UAT[受入テスト UAT<br>総合テスト ST]
    BD[基本設計] --- IT[結合テスト IT]
    DD[詳細設計] --- UT[単体テスト UT]
    PG[製造 実装]
    RD --> BD --> DD --> PG --> UT --> IT --> UAT
テスト 略称 確かめること 主な対応工程
単体テスト UT 部品が詳細設計どおりに動くか 詳細設計
結合テスト IT 部品どうしが正しく連携するか 基本設計
総合テスト(システムテスト) ST システム全体が要件を満たすか 要件定義(システムとしての要件)
受入テスト UAT 利用者が業務で使えるか 要件定義(利用者の要件)

工程を細かく分けるプロジェクトでは、「製造↔単体」「詳細設計↔結合」「基本設計↔総合」のように1段ずらして対応させることもあります。

SIの現場では、テスト工程の呼び方や区切り方がプロジェクトによって異なります。 たとえば結合テストを次のように分けることがあります。

呼び方の例 範囲
内部結合テスト(IT1) 1つのシステム・サブシステムの中の部品どうし
外部結合テスト(IT2) 他システムや外部サービスとの連携
連結テスト・連携テスト 外部結合テストとほぼ同じ意味で使われることがある

プロジェクトに参加したら、まず「このプロジェクトでのUT・ITの範囲」と「どの設計書を正としてテストするか」をテスト計画書で確認する と、認識のずれを防げます。

テストピラミッド:どのくらいの割合で書くか

テストピラミッドは、「速くて安い単体テストを土台に多く、遅くて準備が大変なテストほど上に少なく」という考え方です。

flowchart TB
    E2E["E2E・総合テスト<br>少なく(遅い・準備が大変)"]
    INT["結合テスト<br>要所を押さえる"]
    UNIT["単体テスト<br>多く(速い・原因がわかりやすい)"]
    E2E --- INT --- UNIT
層 数の目安 ねらい
単体 最も多い 計算・分岐・境界値を網羅する
結合 中くらい DB・外部API・部品間の「つなぎ目」を一通り通す
E2E・総合 少ない 主要な業務の流れが最後まで通るかを見る

今回の実験でいえば、端数処理のパターンを増やすなら単体テストに、SQLを1本追加したら結合テストに1件 という分担になります。

テスト観点:何を確かめるか

観点(テストで確かめるべき切り口)を事前に洗い出しておくと、漏れを防げます。

観点 単体テストの例 結合テストの例
正常系 標準的な入力で正しい合計 保存して読み戻した値が一致
境界値 数量1、端数0.5円 最大件数の明細を保存
異常系 数量0以下でエラー 制約違反で全体がロールバック
データの受け渡し 引数どおりに呼び出しているか 型・単位・文字コードが崩れないか
状態 — 二重登録、同時更新
性能(簡易) — 件数が増えたときの処理時間

エビデンス(テストの証跡)の残し方

テストを実施した証拠(エビデンス)を、あとから誰でも確認できる形で残しておきます。 手作業のスクリーンショットだけに頼らず、ツールの出力を使うと効率的です。

残すもの 方法の例
実行結果の一覧 pytest --junitxml=result.xml でJUnit形式のXMLを出力
実行ログ pytest -v の出力をファイルに保存
カバレッジ(どこを通ったか) pytest-cov プラグインでレポートを出力
実行環境 Python・pytest・DBのバージョン、コミットID
DBの状態 テスト前後のデータをSQLで抽出して保存

CI(自動でテストを実行する仕組み)で毎回同じ手順で実行し、結果ファイルを保存しておくと、エビデンスづくりの手間がほぼなくなります。

AIでテストを書くときの注意

AIはテストコードのひな形づくりや、観点の洗い出しに役立ちます。 一方で、次の点は人が確認するのが大切です。

注意点 起きがちなこと 確認のしかた
期待値の根拠 実装の結果をそのまま期待値にしてしまう 期待値は仕様・設計書から自分で計算する
モックの使いすぎ すべてモックになり、結合テストの役割がなくなる 「実物につなぐテスト」が残っているか見る
失敗することの確認 何を入れても通るテストになっている 今回のようにわざとバグを入れて失敗するか試す
観点の漏れ 正常系ばかりになる 観点表と照らし合わせる
環境依存 接続情報を直書きする 環境変数や設定ファイルから読む

特に「わざとバグを入れて、テストが本当に失敗するか確かめる」ことは、AIが書いたテストでも人が書いたテストでも有効です。 今回の実験のバグAのように、テストがあってもデータによってはすり抜けることがあるからです。

よくある質問

Q. 単体テストでDBを使ってはいけないのですか? A. 決まりではありません。部品をDBから切り離して確かめるのが一般的な単体テストですが、リポジトリ層の単体テストとしてテスト用DBを使う現場もあります。大切なのは、プロジェクト内で「単体」「結合」の範囲をそろえておくことです。

Q. 結合テストで失敗したら、まず何を見ればいいですか? A. まずエラーメッセージで「どのつなぎ目か」を特定します。今回のバグBのように、DBのエラー(列がない、制約違反など)はメッセージに原因がはっきり出ます。そのうえで、関係する部品の単体テストが通っているかを確認すると、範囲を絞り込めます。

Q. 単体テストと結合テスト、どちらから書くべきですか? A. 一般的には単体テストから書き、部品の正しさを固めてから結合テストに進みます。ただし、DB連携が中心の機能では、最初に結合テストを1本通して「つながること」を確かめてから、細かいパターンを単体テストで増やす進め方も有効です。

まとめ

  • 単体テストは部品そのもの、結合テストは部品どうしや外部とのつなぎ目を確かめる
  • 単体テストはモックで外部を切り離し、結合テストは実物(DBなど)につなぐ
  • 実測では、単体6件が0.01〜0.02秒、結合2件が0.05〜0.12秒。1件あたり1桁ほど差がある
  • 計算ミスは単体で、SQLの列名ミスやトランザクションの不備は結合で見つかった
  • V字モデルではUTが詳細設計、ITが基本設計に対応。内部結合・外部結合など呼び方は現場で確認する
  • テストピラミッドにならい、単体を厚く、結合は要所に
  • AIに書かせたテストも、わざとバグを入れて失敗するか確かめる

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「単体テストと結合テストの違い|pytestの実例と実行結果で比較」

株式会社RJCでは、テストの設計や自動化の工夫を、プロジェクトの中で仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?