閉じる

(Python)テスト容易性を上げる関数の書き方と避けたいNG例

テストしやすい関数は、保守しやすく不具合も見つけやすい関数です。

逆にテストしにくい関数は、バグが隠れやすく変更に弱くなります。

本記事ではPython初心者がつまずきやすいポイントを具体例で解説しながら、どんな関数がテストに強いか、どのように書き換えれば良いかを丁寧に説明します。

Python初心者向け:テストしやすい関数の条件

テストしやすさは魔法ではなく、設計の積み重ねです。

まずは前提としての条件を整理します。

ここを意識するだけで、テストコードは短く、分かりやすくなります。

以下は、テスト容易性につながる基本的な条件の整理です。

条件テストの観点具体的なポイント
入力と出力が明確何を渡せば何が返るかが明確だとテストケースが作りやすい型ヒント、ドキュメント、戻り値の一貫性
副作用なし呼び出すたびに動作が変わらないグローバル参照・更新、I/O、外部状態の更新がない
小さく単一責任1つのことだけをする関数は検証が簡単責務を分割し、短い関数に保つ
決定的に動く同じ入力に対して常に同じ結果現在時刻・乱数・環境に依存しない
エラー処理が一貫想定外入力に対してルールが決まっている例外か戻り値かを統一、型と意味を明確化

上の5点を満たすほど関数はテストしやすく、失敗時の原因特定も容易になります。

入力と出力が明確

引数や戻り値が曖昧だと、どのケースをテストすればよいか判断がつきません。

型ヒントとドキュメント文字列で契約を明示しましょう。

Python
# 面積を計算する純粋関数の例
def area_of_rect(width: float, height: float) -> float:
    """長方形の面積を返します。widthとheightは0以上の数値を想定します。"""
    return width * height

# デモ
print(area_of_rect(3.0, 4.0))  # 12.0が期待値
実行結果
12.0

どの引数に何を渡し、何が返るかが明確なので、テストケース(正常・境界・異常)を作りやすくなります。

副作用なし

副作用とはprintで出力する、ファイルを書き換える、グローバル変数を更新するなど、関数の外に影響を及ぼす動作です。

テストでは入力→出力の純粋な対応を好みます。

Python
# 文字列を整形して返す純粋関数
def normalize(text: str) -> str:
    """前後の空白を削除して小文字化します。外部状態は変更しません。"""
    return text.strip().lower()

# デモ
print(normalize("  Hello World  "))
実行結果
hello world

外部に何も書き込まず、戻り値だけに意味があるため、テストがシンプルです。

小さく単一責任

1つの関数が複数のことをし始めると、テストも複雑になります。

処理を小さく分割し、各関数が1つの責務を持つようにしましょう。

Python
# 価格計算を分割した例
def apply_discount(price: int, rate: float) -> int:
    """割引率rate(例: 0.1は10%)を適用して整数に丸めます。"""
    return int(round(price * (1 - rate)))

def format_price(price: int) -> str:
    """表示用に価格をフォーマットします。"""
    return f"{price:,}円"

# デモ
discounted = apply_discount(1200, 0.15)  # 15%引き
print(format_price(discounted))
実行結果
1,020円

ロジックの分割により、apply_discountだけをピンポイントでテストできます。

決定的に動く

同じ入力に対して出力が変わる関数はテストが難しくなります。

現在時刻、乱数、環境変数などに直接依存するのは避け、依存を外から渡すことで決定性を担保します。

具体例は後述のOK例を参照してください。

エラー処理が一貫

エラー時の扱いをバラバラにせず、方針を統一しましょう。

初心者には「異常入力は例外を送出し、戻り値は常に成功時の型だけにする」が扱いやすいです。

Python
def parse_int(text: str) -> int:
    """整数文字列をintに変換します。失敗時はValueErrorを送出します。"""
    text = text.strip()
    if not text or any(ch for ch in text if ch not in "+-0123456789"):
        raise ValueError(f"整数に変換できません: {text!r}")
    return int(text)

# デモ
try:
    print(parse_int(" 42 "))
    print(parse_int("abc"))  # ここで例外
except ValueError as e:
    print(f"エラー: {e}")
実行結果
42
エラー: 整数に変換できません: 'abc'

成功は値、失敗は例外という一定の契約があると、テスト側でwith pytest.raises(ValueError)のように記述しやすくなります。

テストしにくい関数のNGパターン

テストが難しくなる典型パターンを把握して避けましょう。

グローバル変数や共有状態に依存

グローバルのフラグやキャッシュを書き換える関数は、テスト順序で結果が変わります。

状態の汚染が起こると原因特定が困難です。

必要なら依存を引数にし、関数外の状態に触れない設計にします。

I/Oに直結

関数内で直接ファイルやネットワークにアクセスすると、テストが遅く不安定になります。

I/Oレイヤーを薄く分離し、ロジック関数では文字列や辞書などの純粋データを扱うようにしましょう。

現在時刻や乱数に依存

datetime.nowrandom.randomを直接呼ぶと結果が毎回変わり、再現性が失われます

時間や乱数は関数の引数として注入します。

隠れた設定依存

関数内で環境変数、設定ファイル、カレントディレクトリなどに依存すると、「動くマシン」と「動かないマシン」が生まれます。

テスト時は依存の明示化が鍵です。

責務が多い・長すぎる

データ取得、変換、検証、保存、表示を1つの関数に詰め込むと、テストは爆発的に難しくなります。

小さい関数の組み合わせへ分割します。

テスト容易性を上げる関数の書き方

設計の指針を、初心者にも実践しやすい形でまとめます。

依存を引数で注入

現在時刻や乱数、I/O関数などの外部依存は、「引数で受け取り、デフォルトを用意」するのが実用的です。

これで本番ではデフォルト、テストでは差し替えが可能になります。

Python
from datetime import datetime
from typing import Callable

def greeting(now_provider: Callable[[], datetime] = datetime.now) -> str:
    """現在時刻の提供者(now_provider)を受け取り、時間帯に応じた挨拶を返します。"""
    hour = now_provider().hour
    if hour < 12:
        return "おはよう"
    if hour < 18:
        return "こんにちは"
    return "こんばんは"

# デモ: テストでは固定時刻を返すプロバイダを渡す
morning = lambda: datetime(2024, 1, 1, 9, 0, 0)
night = lambda: datetime(2024, 1, 1, 21, 0, 0)
print(greeting(morning))
print(greeting(night))
実行結果
おはよう
こんばんは

ロジックとI/Oを分離

I/Oは薄い関数へ追い出し、ロジックは純粋データを受け取って処理します。

Python
# ロジック: JSON文字列 -> dict
import json
from typing import Any, Dict

def parse_user(json_text: str) -> Dict[str, Any]:
    """ユーザ情報のJSON文字列を辞書に変換します。"""
    data = json.loads(json_text)
    # 必須項目の検証もロジック側で行える
    if "name" not in data:
        raise ValueError("nameがありません")
    return data

# I/O: ファイルから読み、ロジックへ渡す
from pathlib import Path

def load_user_from_file(path: Path) -> dict:
    """ファイルから読み取り、ロジック関数に渡します。"""
    text = path.read_text(encoding="utf-8")
    return parse_user(text)

# デモ
demo_path = Path("demo_user.json")
demo_path.write_text('{"name": "Alice", "age": 30}', encoding="utf-8")
print(load_user_from_file(demo_path))
実行結果
{'name': 'Alice', 'age': 30}

テストではparse_userだけを直接呼べば、ファイル操作不要で高速かつ安定します。

小さな関数に分割

抽出した小さな関数は再利用とテスト再利用の両面で利点があります。

複数の大きな関数が同じ小さな関数を使えば、テストも1回書けばよくなります。

戻り値で結果を返す

printは表示専用のI/Oです。

テスト対象の関数は文字列や数値を戻り値として返すことで、比較が容易になります。

表示は呼び出し側で行いましょう。

デフォルト引数で差し替え可能にする

依存を引数で受けると毎回渡すのが手間に感じるかもしれません。

そこでデフォルト引数を本番用に設定しておけば、普段はそのまま呼び、テストだけ差し替えできます。

例外か戻り値かを統一

エラーがNoneの返却だったりFalseだったり例外だったりすると、テストが複雑化します。

原則は「失敗は例外」に寄せると、テストはwith pytest.raises(...)で統一できます。

戻り値は成功時の値だけにしましょう。

OK例とNG例

ここでは、テストを難しくする「アンチパターン」と、それをどのように修正すればテストが楽になるかを対比して示します。

これらの原則を理解することで、将来のメンテナンスが容易で信頼性の高いコードを書くことができるようになります。

NG: 関数内でdatetime.now()を直接使用

Python
from datetime import datetime

def time_based_message() -> str:
    """現在時刻に応じてメッセージを返す(悪い例: 直接nowを呼ぶ)。"""
    hour = datetime.now().hour  # 直接依存のためテスト不能・不安定
    return "早朝割引" if hour < 6 else "通常料金"

テスト時に時刻を固定できず、意図せず失敗や不安定な成功が起きます。

関数の結果が実行されるタイミングによって変わってしまうため、特定の条件を再現してテストすることが非常に困難です。

このようなコードは、CI/CD環境のような自動テストでは特に問題となり、信頼性を損ないます。

OK: 現在時刻を引数で受け取る

Python
from datetime import datetime

def time_based_message(now: datetime) -> str:
    """現在時刻(now)を引数で受け取るため、テストが決定的になります。"""
    return "早朝割引" if now.hour < 6 else "通常料金"

# デモ
print(time_based_message(datetime(2024, 1, 1, 5, 59, 59)))
print(time_based_message(datetime(2024, 1, 1, 6, 0, 0)))
実行結果
早朝割引
通常料金

入力を固定できる=期待結果を固定できるので、テストが簡単です。

外部に依存する部分(この場合は現在時刻)を関数の引数として受け取ることで、テスト時に任意の値を「注入(インジェクション)」できるようになります。これにより、常に同じ入力に対して同じ出力が得られる「決定的(Deterministic)」なテストが可能になります。

NG: ファイルパスを固定して読む

Python
import json

def load_app_config() -> dict:
    """ファイルパスが関数内で固定されており、テストしにくい例。"""
    # 実行環境に依存し、テストで差し替えできない
    with open("/etc/myapp/config.json", encoding="utf-8") as f:
        return json.load(f)

実ファイルが必要、権限やOS差に影響、CIで失敗などの温床になります。

このコードは、特定のファイルシステムに依存しているため、テスト環境に同じファイルが存在しないと実行できません。

また、ファイルの内容が変更されるとテストが意図せず失敗する可能性があり、外部要因に左右されやすいテストになってしまいます。

OK: パスや読み取り関数を引数で受け取る

Python
from pathlib import Path
from typing import Callable
import json

def load_app_config(path: Path, read_text: Callable[[Path], str] | None = None) -> dict:
    """
    パスと読み取り関数を引数で受け取り、デフォルトはPath.read_textを使用します。
    テストではread_textを差し替えてI/Oなしで検証できます。
    """
    if read_text is None:
        read_text = lambda p: p.read_text(encoding="utf-8")
    text = read_text(path)
    return json.loads(text)

# デモ: I/Oをしないテスト用ダミーの読み取り関数
fake_json = '{"debug": true, "retries": 3}'
dummy_reader = lambda _p: fake_json
print(load_app_config(Path("dummy.json"), read_text=dummy_reader))
実行結果
{'debug': True, 'retries': 3}

I/Oを外出しし、ロジックは純粋データを扱うことで高速・安定なテストになります。

「依存性の注入(Dependency Injection)」と呼ばれるこのパターンは、ファイル読み込みのようなI/O処理をモック(mock)やスタブ(stub)と呼ばれるテスト用のダミー関数に置き換えることを可能にします。

これにより、実際にディスクアクセスを行うことなく、メモリ上で完結する高速で安定したテストが実現します。

NG: 関数内でprintする

Python
def build_report(title: str, body: str) -> None:
    """表示までを関数が担当してしまう悪い例。"""
    print(f"# {title}\n{body}")  # 直接printのため、テストで比較しづらい

標準出力をキャプチャする必要があり、比較も文字列処理が複雑になります。

print関数は副作用(Side Effect)であり、関数の振る舞いを追跡しにくくします。

テストでは、出力された文字列をキャプチャし、期待する文字列と一致するかを検証する作業が必要になりますが、これは手間がかかる上に、わずかな空白や改行の違いで失敗する可能性があります。

OK: 文字列を返し表示は呼び出し側

Python
def build_report(title: str, body: str) -> str:
    """レポート文字列を組み立てて返します。表示は呼び出し側に委ねます。"""
    return f"# {title}\n{body}"

# デモ
report = build_report("売上レポート", "今月は前年比+12%でした。")
print(report)
実行結果
# 売上レポート
今月は前年比+12%でした。

戻り値を比較するだけでテストが完了します。

戻り値を比較するだけでテストが完了します。「関心事の分離(Separation of Concerns)」という原則に基づき、データを生成するロジックと、そのデータを表示する処理を別の関数に分けます。

これにより、build_report関数は純粋に文字列を返すだけの純粋関数(Pure Function)となり、戻り値を直接assert(検証)するだけでテストが完了します。

NG: random.random()を直接呼ぶ

Python
import random

def draw_lottery() -> str:
    """乱数に直接依存するため、テストが不安定な悪い例。"""
    return "当たり" if random.random() < 0.1 else "はずれ"

毎回結果が変わり、テストがパラパラ失敗する原因になります。

乱数に直接依存するコードは、実行するたびに異なる結果を返す可能性があるため、「非決定的(Non-Deterministic)」です。

テストが成功したり失敗したりする「パラパラテスト(Flaky Test)」の典型的な原因となり、コードの信頼性を判断する妨げになります。

OK: 乱数生成器を引数で受け取る

Python
from typing import Callable
import random

def draw_lottery(rand: Callable[[], float] = random.random) -> str:
    """
    0.0以上1.0未満を返す関数(rand)を受け取ります。
    デフォルトはrandom.randomですが、テストで差し替え可能です。
    """
    return "当たり" if rand() < 0.1 else "はずれ"

# デモ: テストでは決定的なダミー乱数を渡す
always_zero = lambda: 0.0
always_half = lambda: 0.5
print(draw_lottery(always_zero))  # 0.0 < 0.1 → 当たり
print(draw_lottery(always_half))  # 0.5 < 0.1 → はずれ
実行結果
当たり
はずれ

依存を注入するだけで決定性が得られ、テストが高速・安定になります。

乱数生成器を引数として渡すことで、テスト時には常に同じ値を返すダミー関数を渡すことができます。

これにより、draw_lottery関数のロジックが期待通りに動作するかを確実に検証でき、テストの信頼性が飛躍的に向上します。

まとめ

テスト容易性は、関数の書き方を少し工夫するだけで大きく向上します。

入力と出力を明確にし、副作用を避け、関数を小さく保ち、決定性を担保し、エラー処理を一貫させることが基本です。

具体的には依存の注入ロジックとI/Oの分離戻り値での伝達デフォルト引数での差し替えを活用しましょう。

NG例をOK例に書き換えるだけで、テストは短く、速く、壊れにくいものになります。

今回扱わなかったpytestの高度な機能(フィクスチャ、parametrize、mock、カバレッジ、doctestなど)は別記事で解説しますが、まずは本記事の原則を実践することが最も効果的な第一歩です。

この記事を書いた人
エーテリア編集部
エーテリア編集部

人気のPythonを初めて学ぶ方向けに、文法の基本から小さな自動化まで、実際に手を動かして理解できる記事を書いています。

クラウドSSLサイトシールは安心の証です。

URLをコピーしました!