0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

『単体テストの考え方/使い方』から学ぶ、良い単体テストを作るには?

0
Last updated at Posted at 2026-09-30

はじめに

「テストを書いて」と言われて困ったことはありませんか?

  • どこをどうテストすればいいか分からない
  • このテスト、本当に意味があるのかな?と感じる
  • コードを少し直しただけでテストが全滅し、直すのが苦痛…

私自身も同じ悩みを抱えていましたが、『単体テストの考え方/使い方』を読み、テストに対する考え方が変わりました。
この記事では、同書から得た学びをもとに、「現場で役立つ、コードの変更に強い意味のあるテストの書き方」を解説します。

⏱️ 読了時間の目安: 約 15 分

この記事を読むと

  • ✅ 「良いテスト」と「悪いテスト」を見極めるポイントが分かります
  • ✅ リファクタリングしても壊れない、保守性の高いテストを書く考え方が掴めます
  • ✅ 何をテストして、何をテストすべきでないかの基準が身につきます

参考図書: 『単体テストの考え方/使い方』(Vladimir Khorikov 著)

対象読者と前提知識

対象読者

  • テストを書くことに苦手意識や迷いがあるエンジニア
  • 単体テストを書いてはいるものの、「これでいいのか」とモヤモヤしている方
  • これから現場でテストを書いていく予定の初中級者

前提知識

  • 基本的なプログラミング知識(※サンプルは Python で記述)
  • テストフレームワーク(pytest や unittest など)の基本的な存在を知っていればOKです

1. なぜ、単体テストを行うのか?

結論から言うと、単体テストの目的は「ソフトウェア開発プロジェクトを持続可能なものにすること」です。

【テストがないプロジェクト】
開発初期 ⚡高速  ➔  半年後 🐢失速(デグレが怖くてコードが触れない)

【良いテストがあるプロジェクト】
開発初期 🚶一定  ➔  半年後 🏃快適(安心して修正・リファクタリングできる)

プロジェクトが大きくなるにつれ、コード修正による思わぬバグ(デグレ)のリスクは跳ね上がります。長期的に安心してコードを改修し続けるために、テストは必須です。

ただし、「テストが書いてあれば何でも良い」わけではありません。
価値の低いテストや壊れやすいテストは、メンテナンスコストばかりかかり、開発の足を引っ張る「負債」になってしまいます。

2. 優れたテスト・スイートの3つの特徴

成果を出しているチームのテスト群(テスト・スイート)には、明確な共通点があります。

特徴 内容
① 自然な組み込み コードを書いたらすぐにテストを実行する習慣が日常化している
② 核となる部分に集中 全コード網羅(カバレッジ100%)を目指さず、ビジネスロジックを重点的にテストする
③ コストの高いテストを回避 外部連携やDB接続など、壊れやすく保守コストが高い部分は単体テストの優先度を下げる

💡 ビジネスロジックとは?
「会員ランクに応じた割引率の計算」「在庫がゼロならエラーにする」など、アプリのコアとなる仕様や、業務上のルールのことです。こここそがテストの最優先エリアです。

3. 単体テストの定義

本著では、単体テストを以下の3要素で定義しています。

  1. 1単位の「振る舞い」を検証すること
  2. 実行時間が短いこと
  3. 他のテストケースから「隔離」された状態で実行されること

これらの要素が具体的にどういうことか、一つずつ見ていきましょう。

1. 1単位の「振る舞い」を検証する

ここでいう「1単位」とは、関数やクラスといったプログラムの部品単位ではありません。「ユーザーから見て意味のある、まとまった機能(振る舞い)」を指します。

以下の例で違いを確認してみましょう。

  • ❌ 内部の仕組みをテストしている例(避けるべきテスト)

    • テスト内容: ショッピングカートの内部で、商品データを格納する「リスト(配列)」に正しくデータが追加されたか?を確認する
    • 何が問題か: これは「コードがどう実装されているか」という内部事情に依存しています。もし後から「やっぱりリストではなくマップ構造に変えよう」と内部を書き換えただけで、動いている機能は同じなのにテストが壊れてしまいます。
  • ⭕️ 「振る舞い」をテストしている例(理想的なテスト)

    • テスト内容: ショッピングカートに商品を2つ入れたとき、合計金額が正しく計算されて画面に表示されるか?を確認する
    • なぜ良いのか: 内部でリストを使っていようがマップを使っていようが関係なく、「外から見たときの正しい結果(仕様)」だけを検証しています。そのため、内部のコードをどのようにリファクタリングしても、テストは壊れにくくなります。

このように、「内部でどうやって処理しているか」ではなく、「外から見て、期待通りの結果(仕様)になっているか」をテストするのが、単体テストにおける「振る舞い」の検証です。

2. 実行時間が短いこと

単体テストは、実行に何秒もかかるようなものではなく、基本的には一瞬で終わるスピードが求められます。
テストの実行や準備が遅くなってしまう主な原因には、次のようなものがあります。

  • 実際のデータベースや外部APIとの通信(ネットワークやディスクの処理を伴うため遅い)
  • テストを動かすための事前準備が多すぎる(「ユーザーを作って、住所を登録して、さらにポイントを付与して...」といった大量のデータを、テストのたびに準備・登録するのに時間がかかる)

こうした「準備や実行に時間のかかるテスト」が増えてしまうと、「テストが終わるのを待つのが面倒になり、だんだんテストを実行しなくなってしまう」という本末転倒な事態に陥ってしまいます。
だからこそ、大がかりな準備を必要とせず、サクサクと一瞬で終わる単体テストを基本にすることが大切です。コードを少し直したらすぐに結果が返ってくる状態を保つことで、開発のテンポを落とさずに安心して作業を進めることができます。

3. 他のテストケースから「隔離」された状態で実行されること

テストをたくさん書いていくと、あるテストのせいでデータが書き換わり、別のテストが突然失敗してしまう…といったことが起きがちです。

ここでいう「隔離」とは、それぞれのテストが完全に独立しており、お互いに影響や邪魔をし合わない状態を保つことを指します。どんな順番でテストを動かしても結果がブレない、安定した環境が大切です。

4. 良い単体テストを支える「4つの柱」

良い単体テストかどうかを判断するには、以下の4つの基準を使います。

  1. 退行(バグ)に対する保護: バグが発生したときに、しっかりテストが落ちて検知できるか?
  2. リファクタリングへの耐性: コードの見た目や構造を綺麗に整えた際、バグがないのにテストが落ちる(偽陽性)ことがないか?
  3. 迅速なフィードバック: テストが数秒以内で素早く終わるか?
  4. 保守のしやすさ: テストコード自体が読みやすく、準備や後片付けが簡単か?

テストの前に押さえておきたい「パブリック」と「プライベート」について

今後の解説をスムーズに進めるために、クラスのメソッドの役割を整理しておきます。

  • パブリックメソッド(公開関数)

    • クラスの外(利用者や他のクラス)から呼び出すことを許可されている窓口です。アプリの「振る舞い(機能)」そのものを表します。
  • プライベートメソッド(非公開関数・Pythonでは _ で始まるものなど)

    • 外部からは隠されており、パブリックメソッドを動かすための「実装の詳細」を担います。

5. 悪いテスト例 vs 良いテスト例

まずは基本的な「リファクタリング耐性がない悪いテスト」と「優れたテスト」を比較します。

買い物カートのプログラム

class ShoppingCart:
    def __init__(self):
        # 内部で商品を保持するリスト
        self._items = []

    def add_item(self, name: str, price: int):
        self._items.append({"name": name, "price": price})    
        
    @property
    def total_amount(self) -> int:
        return sum(item["price"] for item in self._items)

❌ 悪いテスト例:実装の詳細に依存している

def test_add_item_bad():
    cart = ShoppingCart()
    cart.add_item("Apple", 100)
    
    # 【問題点】内部変数 _items の中身を直接チェックしている!
    assert len(cart._items) == 1
    assert cart._items[0]["name"] == "Apple"

【なぜ悪いのか?】
後からリファクタリングを行い、内部構造をリスト(_items)から辞書型(_item_dict)に変更したとします。
アプリの機能(合計金額の計算)は壊れていないにもかかわらず、このテストは落ちます(=偽陽性)。
これが繰り返されると、開発者はテストエラーを無視しても開発作業に問題はない、と判断してテスト結果を信じなくなってしまいます。その結果、本当に不具合があるテストエラーも無視して不具合込みでアプリがリリースされる事故が発生します。

AAAパターン
テストコードを書く際、世界的に推奨されているのが AAA(Arrange-Act-Assert)パターン です。すべてのテストをこの3ステップで構造化すると、一気に読みやすくなります。

  • Arrange(準備): テスト対象オブジェクトの生成や、必要な引数の準備
  • Act(実行): テスト対象のメソッドや機能の実行
  • Assert(検証): 結果が意図通りかどうかの確認

⭕ 良いテスト例:AAAパターンを採用し、外部からの振る舞いを検証している

def test_add_item_good():
    # 1. Arrange(準備)
    cart = ShoppingCart()
    
    # 2. Act(実行)
    cart.add_item("Apple", 100)
    
    # 3. Assert(検証): 公開されている「合計金額」という振る舞いをチェック!
    assert cart.total_amount == 100

【なぜ良いのか?】
内部の変数をどう書き換えようが、「商品を足したら合計金額が増える」というビジネス仕様が満たされていれば、テストは成功し続けます。
これが、「リファクタリング耐性の高い、壊れにくいテスト」です。

6. パターン別:良いテストと悪いテストの比較

ここからは、具体的なアンチパターン(悪い例)と、その改善案(良い例)をさらに3つのパターンで見ていきましょう。

パターン1:プライベートメソッドのテスト

商品の割引料金を計算する処理を例にします。

class Order:
    def __init__(self, price: int, is_premium_member: bool):
        self.price = price
        self.is_premium_member = is_premium_member

    # 外部から呼ばれる公開関数(パブリックメソッド)
    def checkout(self) -> int:
        discount = self._calculate_discount() # 内部関数を呼び出す
        return self.price - discount

    # 内部関数(プライベートメソッド)
    def _calculate_discount(self) -> int:
        if self.is_premium_member:
            return int(self.price * 0.2) # 20%オフ
        return 0

❌ 悪いテスト例(プライベートメソッドを直接テストする)

def test_calculate_discount_for_premium():
    order = Order(1000, is_premium_member=True)
    
    # ❌ 内部関数を直接呼び出してテストしている!
    assert order._calculate_discount() == 200

【なぜ悪いのか】
もし将来、「割引計算を _calculate_discount ではなく DiscountCalculator クラスに移そう」とリファクタリングした場合、このテストは落ちてしまいます。

⭕ 良いテスト例(パブリックメソッド経由で結果を検証する)

def test_checkout_for_premium_member():
    order = Order(1000, is_premium_member=True)
    
    # ⭕ 公開されている「会計(checkout)」の振る舞いだけをテストする
    assert order.checkout() == 800

【なぜ良いのか?】
利用者が本当に知りたい「最終的にいくら払えばいいの?」という 結果(振る舞い) を保証しています。


パターン2:純粋関数化

関数の振る舞いに影響するのに「引数になっていない値」を「隠れた入力」と呼びます。隠れた入力があると、同じ引数を与えてもテスト結果が変わるため、安定した単体テストが書きにくくなります。

主な隠れた入力の例:

  • 現在時刻(datetime.now() など)
  • 乱数(random)
  • 環境変数やグローバル設定
  • 外部APIやDBの状態

解決方法(原則)

  1. 隠れた入力を関数の明示的な引数にする(依存性注入)。
  2. テストではその引数に固定値を渡して純粋関数として検証する。

純粋関数とは

純粋関数とは、同じ入力を与えたら常に同じ結果を返す関数です。

❌ 悪い実装例(隠れた入力あり)

from datetime import datetime

def is_eligible(registered_at: datetime) -> bool:
    now = datetime.now()      # 隠れた入力
    delta_days = (now - registered_at).days
    return delta_days <= 30

❌ 悪いテスト例(隠れた入力がある関数のテスト)

def test_is_eligible_bad():
    registered_at = datetime(2026, 5, 1)
    
    # 【問題点】
    # このテストを実行する「今日の日付」によって結果が変わってしまう!
    # 明日になったら突然テストが失敗する可能性がある。
    assert is_eligible(registered_at) is True

⭕ 良い実装例

隠れた入力を外から渡す

from datetime import datetime

def is_eligible(registered_at, current_time):
    return (current_time - registered_at).days <= 30

⭕ 良いテスト例

def test_is_eligible():
    registered_at = datetime(2026, 5, 1)
    current_time = datetime(2026, 5, 20)
    assert is_eligible(registered_at, current_time) is True

【なぜ良いのか?】
is_eligible() は現在時刻を内部から取得しなくなったため、同じ入力に対して常に同じ結果を返す純粋関数として扱えるようになります。


パターン3:価値のないテスト(ドメインロジックがないコード)

単体テストのカバレッジ(網羅率)を100%に近づけようとすると、よく発生するアンチパターンです。

class Product:
    def __init__(self, name: str):
        self.name = name  # ただの代入

❌ 悪いテスト例(自明なコードのテスト)

def test_product_name():
    product = Product("Coffee")
    
    # ❌ ただ代入して取り出すだけの処理をテストしている
    assert product.name == "Coffee"

【問題点】
これでは「プログラミング言語の機能が正しく動くか」をテストしているのと同じです。
テストすべきは、ビジネスロジックを持たないデータ保持だけのコードではなく、複雑なルールを持った領域です。
※ただし、nameに「文字数制限」や「禁止文字チェック」などの入力値の検証やビジネスルールがある場合は別です。その場合は必ずテストしてください。

⭕ 良い対応(テストしない)

ロジックを持たない「ただのプロパティの出し入れ」は、思い切ってテストを書かない(優先度を下げる)のが得策です。テストの価値は「ビジネスロジック(複雑な計算やルール)」を守ることにあります。

7.本番レベルのコードで学ぶテスト設計

ここからは、より実務に近い「ECサイトの注文確定・決済処理(クーポン適用・外部API連携・現在時刻)」を題材に、より高度なテスト設計を見ていきましょう。

from dataclasses import dataclass
from datetime import datetime
from typing import List, Protocol

@dataclass
class CartItem:
    product_id: str
    name: str
    price: int
    quantity: int

class PaymentGateway(Protocol):
    def charge(self, amount: int, token: str) -> bool:
        ...

# ==========================================
# 1. ビジネスロジックを担うクラス(I/Oを持たない純粋な処理)
# ==========================================
class OrderCalculator:
    """金額計算やクーポン適用といった純粋なビジネスロジックを担当するクラス"""
    
    def calculate_total(self, items: List[CartItem], coupon_code: str, current_time: datetime) -> int:
        subtotal = sum(item.price * item.quantity for item in items)
        discount = self._calculate_discount(subtotal, coupon_code, current_time)
        total_amount = subtotal - discount
        
        return max(total_amount, 0) # 0未満にはならないようにする

    def _calculate_discount(self, subtotal: int, coupon_code: str, current_time: datetime) -> int:
        if coupon_code == "SUMMER2026" and current_time.month == 6:
            return int(subtotal * 0.15)  # 6月なら15%オフ
        elif coupon_code == "WELCOME":
            return 500  # 一律500円引き
            
        return 0


# ==========================================
# 2. I/O(外部API連携)とビジネスロジックを結合するサービスクラス
# ==========================================
class OrderCheckoutService:
    """ビジネスロジックの結果を受けて、外部API(決済)を呼び出すオーケストレーション層"""
    
    def __init__(self, calculator: OrderCalculator, payment_gateway: PaymentGateway):
        self._calculator = calculator
        self._payment_gateway = payment_gateway

    def checkout(self, items: List[CartItem], coupon_code: str, payment_token: str, current_time: datetime) -> int:
        # ビジネスロジックの実行(純粋な計算)
        total_amount = self._calculator.calculate_total(items, coupon_code, current_time)

        # I/O処理(外部決済APIの呼び出し)
        if total_amount > 0:
            success = self._payment_gateway.charge(total_amount, payment_token)
            if not success:
                raise ValueError("決済処理に失敗しました。")

        return total_amount

⭕ 本番レベルのテスト実装例

from datetime import datetime
import pytest
from order_service import OrderCalculator, OrderCheckoutService, CartItem

# ==========================================
# A. ビジネスロジック(計算・クーポン)単体のテスト
# (テストダブル不要)
# ==========================================
def test_calculator_with_summer_coupon():
    calculator = OrderCalculator()
    items = [
        CartItem(product_id="P1", name="ワイヤレスイヤホン", price=10000, quantity=1)
    ]
    fixed_time = datetime(2026, 6, 15, 10, 0, 0)

    # 実行
    total = calculator.calculate_total(items, coupon_code="SUMMER2026", current_time=fixed_time)

    # 検証(純粋関数的なので、戻り値をassertするだけ)
    assert total == 8500


# ==========================================
# B. 全体の結合(I/Oとビジネスロジックの連携)のテスト
# (決済Gatewayのテストダブルが必要になる部分)
# ==========================================
class FakePaymentGateway:
    def __init__(self, should_succeed: bool = True):
        self.should_succeed = should_succeed
        self.charged_amount = 0

    def charge(self, amount: int, token: str) -> bool:
        if not self.should_succeed:
            return False
        self.charged_amount = amount
        return True

def test_checkout_process_invokes_payment_gateway():
    # 1. Arrange
    calculator = OrderCalculator()
    fake_gateway = FakePaymentGateway(should_succeed=True)
    service = OrderCheckoutService(calculator=calculator, payment_gateway=fake_gateway)
    
    items = [
        CartItem(product_id="P1", name="ワイヤレスイヤホン", price=10000, quantity=1)
    ]
    fixed_time = datetime(2026, 6, 15, 10, 0, 0)
    
    # 2. Act
    final_amount = service.checkout(
        items, 
        coupon_code="SUMMER2026", 
        payment_token="tok_12345", 
        current_time=fixed_time
    )
    
    # 3. Assert
    assert final_amount == 8500
    assert fake_gateway.charged_amount == 8500 # 外部APIに正しく伝わったかも検証

💡 テストダブル(Test Double)とは?
テスト用の偽オブジェクト(モックやスタブ)の総称です。本書の定義にならい、これらをまとめて「テストダブル」と表記します。

【なぜ良いのか?】

  • ビジネスロジックに「テストダブル」が不要になる(テストがシンプル):
    計算やクーポン判定といった純粋なビジネスロジック(OrderCalculator)を独立させているため、テストに複雑なテストダブルを使う必要がありません。「引数を渡して、期待する戻り値をアサートするだけ」という最もシンプルで高速な単体テストが実現できます。

  • テストダブルが必要な境界線がひと目でわかる:
    「どの部分が純粋な計算処理(テストダブル不要)」で、「どの部分が外部API通信を伴うI/O処理(テストダブルが必要)」なのかが、クラスの役割分担として明確に分離されています。これにより、「なぜこのテストでテストダブルを使っているのか」というテストの意図が読者(チームメンバー)に迷いなく伝わります。

  • 単一責任の原則(SRP)による保守性の向上:
    「金額や割引を計算するルール」を変更したいときは OrderCalculator だけを修正し、「決済手段や外部APIとの通信仕様」を変更したいときは OrderCheckoutService だけを修正すればよいため、変更に強い保守性の高いコードになっています。

8. 内部関数(プライベートメソッド)はテストするべきか?

現場で実際にテストを書こうとすると、次のような疑問が自分は沸きました。

  • 「公開関数(パブリックメソッド)だけをテストして、本当に安全なの?」
  • 「複雑な計算をしている内部関数(プライベートメソッド)はテストしなくていいの?」

結論として、プライベートメソッドは「直接」テストしないのが原則です。
しかし、「テストしなくていい」という意味ではありません。

どういうことかというと、パブリックメソッド(公開関数)を経由して、間接的にすべてのプライベートメソッドを通るようにテストするのが正解です。

なぜプライベートメソッドを直接テストしてはいけないのか?

プライベートメソッドは「実装の詳細」そのものだからです。
もしプライベートメソッドを直接テストしてしまうと、後で「やっぱりこの関数を2つに分けよう」「別の書き方にしよう」とリファクタリングした瞬間、アプリの動作は正常なのにテストだけがエラーになる(偽陽性)という状況に陥ります。

「でも、パブリックメソッド経由だと複雑すぎてテストしきれない!」という場合は?

もし「パブリックメソッドからだと、プライベートメソッドの特定の分岐(エッジケースなど)をテストするのが難しすぎる」と感じた場合、それはテストの手法が悪いのではなく、クラスの設計(コードの構造)が悪いサインです。

その内部ロジックは、今のクラスに持たせるには重すぎます。
別の新しいクラスとして切り出し、その新クラスの「パブリックメソッド」としてテストするのが正しい解決策です。

まとめ:何をテストし、何をテストしないか

単体テストを書くときに意識すべき重要ポイントを振り返ります。

  1. 目的を忘れない
    テストの目的はプロジェクトの「持続可能性」を高めること。
  2. AAAパターンを意識する
    準備(Arrange)・実行(Act)・検証(Assert)を明確に分け、テストの可読性を上げる。
  3. プライベートメソッドは直接テストしない
    パブリックメソッドの入力と出力を使って、間接的に内部ロジックを網羅する。
  4. 「プロセス」ではなく「結果」をテストする
    テストダブルの乱用を避け、実行後の返り値だけを検証する。テストダブルは外部システムの代役に留める。
  5. 純粋なビジネスロジック」と「I/O処理」を分離する
    時間や外部APIなどの非決定的要素(隠れた入力)を引数で受け取る設計(純粋関数化)や、I/Oを切り離したレイヤー設計にすることで、テストダブル不要で高速なテストが実現できる。
  6. 価値のないテストは書かない
    ただのプロパティの出し入れなど、ビジネスロジックを持たないコードへの無駄なカバレッジ稼ぎはしない。テストの価値は「複雑なルールや仕様」を守ることにあります。

良い単体テストとは、「コードを守るもの」ではなく「振る舞い(仕様)を守るもの」です。

これからの設計・テスト作成では、ぜひ「このテストは実装の詳細に依存していないか?」「ビジネスロジックとI/Oが綺麗に分離されているか?」を意識してみてください。安心してリファクタリングできる頼もしいテストコードが書けるようになるはずです!

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?