Skip to content
トピック
Python
Python Assert: もっと賢くデバッグする

Python Assert: もっと賢くデバッグする

更新日

あなたはデータ処理関数をデバッグしています。CSV を読み込んで出力を書き出すその途中で、整数の列が浮動小数点数に変わり、本来空であってはいけないリストが空になり、正のはずのユーザー ID が -1 になってしまいました。あちこちに print() 文を散りばめ、スクリプトを再実行し、出力を目を細めて確認し、これを繰り返します。1 時間後、ようやくバグが見つかります。たった 1 つの関数が不正な入力を黙って受け入れ、壊れたデータを下流へ流していたのです。本当の失敗はクラッシュの 50 行も前で起きていたのに、誰も教えてくれませんでした。

これは Python 開発における普遍的な問題です。バグは静かに伝播します。None が 3 つの関数呼び出しをすり抜けてから AttributeError を引き起こすことがあります。負の配列インデックスが回り込んで、別の要素を参照してしまうことがあります。本来 5 個のキーがあるはずの辞書に 4 個しかなく、その欠けたキーが微妙なロジックエラーを生み、ようやく本番で表面化することもあります。エラーが見える頃には、どこで実際に問題が起きたのかという文脈をすべて失っています。

Python の assert 文は、失敗したその場でバグを捕まえ、何が悪かったのかを明確に示すことでこの問題を解決します。悪いデータがいつか明らかなクラッシュを引き起こすことを期待するのではなく、自分の前提条件を明示的に宣言し、Python に即座に検証させるのです。

Assert 文とは何か?

assert 文は条件をテストします。条件が True なら何も起こりません。False なら Python は即座に AssertionError を送出します。

assert 2 + 2 == 4      # 何も起こらず成功
assert 2 + 2 == 5      # AssertionError を送出

基本構文は次のとおりです。

assert condition
assert condition, "何が問題だったかを説明するエラーメッセージ"

内部的には、Python は assertif 文に変換します。厳密には次と同等です。

# assert condition, message
# は次と同等:
if __debug__:
    if not condition:
        raise AssertionError(message)

__debug__ 変数はデフォルトで True です。Python を -O(最適化)フラグ付きで実行したときだけ False になります。つまり、アサーションは本番環境で完全に無効化される可能性があります。これは重要な意味を持つため、後で詳しく説明します。

アサーションが失敗すると、次のようになります。

x = -1
assert x >= 0, f"Expected non-negative value, got {x}"

出力:

Traceback (most recent call last):
  File "example.py", line 2, in <module>
    assert x >= 0, f"Expected non-negative value, got {x}"
AssertionError: Expected non-negative value, got -1

トレースバックは前提が破られた正確な行を指し、メッセージは何が問題だったのかを明確に示します。これを、負の値が未検証のまま伝播した結果、20 行後に発生する謎の IndexError と比べてみてください。

基本的な Assert の使い方

シンプルなアサーション

最も単純なアサーションは 1 つの条件をチェックします。

# 変数が None ではないことを確認
config = load_config()
assert config is not None
 
# リストが空でないことを確認
items = get_items()
assert len(items) > 0
 
# 数学的性質を確認
result = calculate_discount(price=100, percent=20)
assert result == 80

カスタムメッセージ付きアサーション

必ずメッセージを含めてください。メッセージがないと、失敗時にほとんど文脈が得られません。

# 悪い例: メッセージなし
assert len(users) > 0
 
# 良い例: 具体的なメッセージ
assert len(users) > 0, "User list is empty -- database query may have failed"
 
# 良い例: 実際の値を含める
assert temperature >= -273.15, f"Temperature {temperature}C is below absolute zero"

メッセージは assert の 2 番目の引数で、カンマで区切ります。ランタイム値を含む f-string など、文字列を生成する任意の式を使えます。

def process_batch(items, batch_size):
    assert batch_size > 0, f"batch_size must be positive, got {batch_size}"
    assert len(items) >= batch_size, (
        f"Not enough items: need {batch_size}, have {len(items)}"
    )
    # バッチを処理...

かっこ付きアサーション — よくある落とし穴

多くの Python 開発者が引っかかる、微妙なバグがあります。

# WARNING: このアサーションは決して失敗しません!
assert(condition, "error message")

これは (condition, "error message") というタプルを作ってしまいます。空でないタプルは常に真と見なされるため、このアサーションは常に成功します。Python は警告まで出します。

SyntaxWarning: assertion is always true, perhaps remove parentheses?

正しい形式は次のとおりです。

# 正しい: かっこは使わない
assert condition, "error message"
 
# これも正しい: 条件だけをかっこで囲む
assert (condition), "error message"
 
# これも正しい: 暗黙の行継続を使う複数行
assert (
    very_long_condition_that_needs_wrapping
), "error message"

複雑な条件での Assert

複数条件の組み合わせ

andornot を使って条件を組み合わせられます。

def create_user(name, age, email):
    assert name and isinstance(name, str), f"Invalid name: {name!r}"
    assert 0 < age < 150, f"Invalid age: {age}"
    assert "@" in email and "." in email, f"Invalid email format: {email}"
 
    # ユーザー作成を続行...

isinstance による型チェック

開発中にデータ型を検証するには isinstance を使ったアサーションが有効です。

def calculate_mean(values):
    assert isinstance(values, (list, tuple)), (
        f"Expected list or tuple, got {type(values).__name__}"
    )
    assert all(isinstance(v, (int, float)) for v in values), (
        "All values must be numeric"
    )
    assert len(values) > 0, "Cannot calculate mean of empty sequence"
 
    return sum(values) / len(values)

本番での型チェックには、Python type hints と mypy のような静的型チェッカーの利用を検討してください。アサーションは開発中のバグ検出のためのものであり、実行時の型強制のためではありません。

コンテナとコレクションのチェック

# 辞書に必要なキーがあるか確認
required_keys = {"name", "email", "role"}
assert required_keys.issubset(user_data.keys()), (
    f"Missing keys: {required_keys - user_data.keys()}"
)
 
# リストに重複がないか確認
ids = [item.id for item in items]
assert len(ids) == len(set(ids)), (
    f"Duplicate IDs found: {[x for x in ids if ids.count(x) > 1]}"
)
 
# すべての要素が条件を満たすか確認
scores = [85, 92, 78, 95, 88]
assert all(0 <= s <= 100 for s in scores), (
    f"Scores out of range: {[s for s in scores if not 0 <= s <= 100]}"
)

Assert を使ったよくあるデバッグパターン

関数の事前条件

事前条件は、関数が何かを行う前に有効な入力を受け取っていることを確認します。関数の先頭に置きます。

def transfer_money(from_account, to_account, amount):
    # 事前条件
    assert from_account != to_account, "Cannot transfer to the same account"
    assert amount > 0, f"Transfer amount must be positive, got {amount}"
    assert from_account.balance >= amount, (
        f"Insufficient funds: balance={from_account.balance}, transfer={amount}"
    )
 
    from_account.balance -= amount
    to_account.balance += amount

関数の事後条件

事後条件は、関数が返す前に正しい出力を生成したことを確認します。return 文の直前に置きます。

def sort_descending(items):
    result = sorted(items, reverse=True)
 
    # 事後条件
    assert len(result) == len(items), "Sort changed the number of elements"
    assert all(result[i] >= result[i+1] for i in range(len(result)-1)), (
        "Result is not sorted in descending order"
    )
 
    return result

ループ不変条件

ループ不変条件は、ループの各反復で条件が真であることを確認します。オフバイワンエラー、無限ループ、ロジックバグを検出します。

def binary_search(sorted_list, target):
    low = 0
    high = len(sorted_list) - 1
 
    while low <= high:
        # ループ不変条件: target が存在するなら sorted_list[low:high+1] にあるはず
        assert low >= 0 and high < len(sorted_list), (
            f"Bounds out of range: low={low}, high={high}, len={len(sorted_list)}"
        )
 
        mid = (low + high) // 2
        if sorted_list[mid] == target:
            return mid
        elif sorted_list[mid] < target:
            low = mid + 1
        else:
            high = mid - 1
 
    return -1

クラス不変条件

クラス不変条件は、操作のたびにオブジェクトの内部状態が整合していることを確認します。

class BoundedQueue:
    """最大容量を持つキュー。"""
 
    def __init__(self, capacity):
        assert capacity > 0, f"Capacity must be positive, got {capacity}"
        self._items = []
        self._capacity = capacity
        self._check_invariant()
 
    def _check_invariant(self):
        assert 0 <= len(self._items) <= self._capacity, (
            f"Queue size {len(self._items)} violates capacity {self._capacity}"
        )
 
    def enqueue(self, item):
        assert len(self._items) < self._capacity, "Queue is full"
        self._items.append(item)
        self._check_invariant()
 
    def dequeue(self):
        assert len(self._items) > 0, "Queue is empty"
        item = self._items.pop(0)
        self._check_invariant()
        return item
 
    def __len__(self):
        return len(self._items)

Assert と Raise の違い: いつ使うべきか

これは Python のエラー処理における最重要の区別の 1 つです。assertraise は根本的に異なる目的を持ちます。

Featureassertraise
Purposeプログラマの誤り(バグ)を捕まえるランタイムの状況(予期された失敗)を扱う
Can be disabledYes, with -O flagNo, always active
Use for input validationNeverYes
Use for external dataNeverYes
Typical exceptionAssertionErrorValueError, TypeError, RuntimeError, etc.
When it firesコードに問題があるとき入力/環境に問題があるとき
Audience開発者ユーザーまたは呼び出し側コード
Presence in productionShould not be relied uponRequired

assert を使う場面: 内部不変条件と開発者の前提

def _calculate_tax(income, brackets):
    # 開発者の前提: brackets はソート済み
    assert all(
        brackets[i][0] <= brackets[i+1][0]
        for i in range(len(brackets) - 1)
    ), "Tax brackets must be sorted by threshold"
 
    # brackets がソートされていないなら、それはユーザー入力の問題ではなくコードのバグ
    ...

raise を使う場面: 入力検証と予期されるエラー条件

def create_account(username, password):
    if not username or len(username) < 3:
        raise ValueError("Username must be at least 3 characters")
    if len(password) < 8:
        raise ValueError("Password must be at least 8 characters")
 
    # これらはユーザー入力の問題であり、プログラマのバグではない。
    # 本番でも必ずチェックされなければならない。
    ...

重要な違い: python -O your_script.py を実行すると、すべての assert 文は完全に削除されます。入力検証に assert を使うと、最適化モードでは検証が消えてしまいます。これは理論上の危険ではありません。多くのデプロイツールや本番環境が -O フラグを使用します。例外処理パターンをさらに深く知りたい場合は、Python try/except guide を参照してください。

経験則

「このチェックを完全に取り除いたとき、ユーザーがセキュリティ問題やデータ破壊を引き起こせるか?」と自問してください。もし答えが yes なら raise を使います。チェックがコード内の開発ミスだけを捕まえるなら assert を使います。

テストにおける Assert

アサーションは Python におけるテストの基盤です。unittestpytest も、期待される振る舞いを検証するためにアサーションを利用します。

pytest のアサーション

pytest は特別なアサーションメソッドではなく、通常の assert 文を使います。これは大きな利点の 1 つで、メソッド名を覚える代わりに自然な Python を書けます。

# test_math.py
def test_addition():
    assert 2 + 2 == 4
 
def test_string_methods():
    greeting = "hello world"
    assert greeting.upper() == "HELLO WORLD"
    assert greeting.split() == ["hello", "world"]
 
def test_list_operations():
    items = [1, 2, 3]
    items.append(4)
    assert len(items) == 4
    assert items[-1] == 4

pytest の Assert Rewriting

pytest を特別にしているのが assert rewriting です。通常の assert が失敗すると、Python は単に AssertionError を表示するだけです。pytest はインポート時に assert 文を書き換え、豊富な失敗メッセージを提供します。

def test_comparison():
    result = {"name": "Alice", "age": 30}
    expected = {"name": "Alice", "age": 31}
    assert result == expected

pytest の出力:

FAILED test_example.py::test_comparison - AssertionError: assert {'age': 30, 'name': 'Alice'} == {'age': 31, 'name': 'Alice'}
  Differing items:
  {'age': 30} != {'age': 31}

pytest の書き換えがなければ、AssertionError だけで詳細は見えません。この仕組みは、pytest が import hook を使って assert 文をより詳細なチェックへ変換し、中間値を捕捉しているから実現します。

よく使う pytest のアサーションパターン

# 例外が送出されることを確認
import pytest
 
def test_division_by_zero():
    with pytest.raises(ZeroDivisionError):
        1 / 0
 
def test_invalid_input():
    with pytest.raises(ValueError, match="must be positive"):
        create_user(age=-5)
 
# 近似等価性を確認(浮動小数点)
def test_float_calculation():
    result = 0.1 + 0.2
    assert result == pytest.approx(0.3)
 
# 値がコレクションに含まれることを確認
def test_membership():
    valid_statuses = {"active", "inactive", "pending"}
    user_status = get_user_status(user_id=42)
    assert user_status in valid_statuses
 
# カスタムメッセージ付き
def test_data_integrity():
    records = load_records()
    assert len(records) > 0, "No records loaded -- check database connection"

unittest におけるアサーション

unittest モジュールは、通常の assert ではなくメソッドベースのアサーションを提供します。これらは pytest の書き換えを必要とせず、より良いエラーメッセージを出せます。

import unittest
 
class TestStringMethods(unittest.TestCase):
    def test_upper(self):
        self.assertEqual("hello".upper(), "HELLO")
 
    def test_contains(self):
        self.assertIn("world", "hello world")
 
    def test_raises(self):
        with self.assertRaises(TypeError):
            "hello" + 5

どちらの方法も有効です。pytest の通常の assert は読みやすく Python らしい書き方です。unittest のメソッドベースのアサーションは、import 時の書き換えなしで詳細なメッセージを提供します。

インタラクティブにアサーションをテストする

テストのアサーションをインタラクティブに作成・デバッグするときは、RunCell (opens in a new tab) のようなツールを使うと、Jupyter Notebook 内の個別のテストセルを即座に実行し、すぐにフィードバックを得られます。これは、複雑なアサーション条件を段階的に組み立てるときに特に便利です。フルのテストスイートにまとめる前に、各アサーションを単独で検証できます。

Assert を使うべきでない場面

この節は非常に重要です。assert の誤用は、本番でのみ現れる微妙で危険なバグを生みます。

入力検証に Assert を使ってはいけない

# WRONG: このチェックは python -O で消える
def withdraw(amount):
    assert amount > 0, "Amount must be positive"
    self.balance -= amount
 
# RIGHT: このチェックは常に実行される
def withdraw(amount):
    if amount <= 0:
        raise ValueError("Amount must be positive")
    self.balance -= amount

外部ソースのデータに Assert を使ってはいけない

ユーザー、ファイル、ネットワーク、データベース、API 由来のデータは、どれも壊れている可能性があります。これらのチェックは常に実行されなければなりません。

# WRONG: assert によるネットワークデータ検証
def handle_api_response(response):
    assert response.status_code == 200
    data = response.json()
    assert "results" in data
 
# RIGHT: 外部データに対する適切なエラーハンドリング
def handle_api_response(response):
    if response.status_code != 200:
        raise RuntimeError(f"API returned status {response.status_code}")
    data = response.json()
    if "results" not in data:
        raise ValueError("API response missing 'results' field")

セキュリティチェックに Assert を使ってはいけない

# CATASTROPHICALLY WRONG: assert によるセキュリティチェック
def delete_user(requesting_user, target_user_id):
    assert requesting_user.is_admin, "Only admins can delete users"
    database.delete(target_user_id)
 
# RIGHT: 無効化できないセキュリティチェック
def delete_user(requesting_user, target_user_id):
    if not requesting_user.is_admin:
        raise PermissionError("Only admins can delete users")
    database.delete(target_user_id)

python -O では、assert 版は任意のユーザーに他人のユーザーを削除させてしまいます。これは実際のセキュリティ脆弱性です。

作用のある Assert を使ってはいけない

アサーションは無効化されうるため、その式の中に副作用を含めてはいけません。

# WRONG: pop() は副作用であり、-O で消える
assert items.pop() == expected_value
 
# RIGHT: 副作用とアサーションを分離する
value = items.pop()
assert value == expected_value

-O フラグ: アサーションが消える仕組み

Python には、アサーションに影響する 2 つの最適化レベルがあります。

python script.py        # 通常: __debug__ は True, アサーション有効
python -O script.py     # 最適化: __debug__ は False, アサーション削除
python -OO script.py    # 追加最適化: アサーション削除 + docstring 削除

Python が -O で実行されると、インタープリタは __debug__False に設定し、すべての assert 文をバイトコードから完全に削除します。単にスキップされるのではなく、存在しなくなります。条件は評価されず、エラーメッセージも生成されません。

次のように確認できます。

# check_debug.py
print(f"__debug__ = {__debug__}")
 
if __debug__:
    print("Assertions are ACTIVE")
else:
    print("Assertions are DISABLED")
 
assert False, "This should raise an error"
$ python check_debug.py
__debug__ = True
Assertions are ACTIVE
Traceback (most recent call last):
  File "check_debug.py", line 8
AssertionError: This should raise an error
 
$ python -O check_debug.py
__debug__ = False
Assertions are DISABLED
# No error! The assert was completely removed.

実際に -O が使われる場面

  • Docker イメージ: 多くの本番 Dockerfile は PYTHONOPTIMIZE=1python -O を使う
  • デプロイツール: 一部の WSGI サーバーは Python を最適化モードで実行する
  • 性能重視アプリケーション: アサーションを削除することで、厳しいループを高速化できる
  • ライブラリコード: 利用者が最適化レベルを制御するため、ライブラリはアサーションが有効だと仮定してはいけない

コードへの影響

アサーションは、建設中の足場のようなものだと考えてください。構造を作っている間は支えになりますが、建物が完成したら取り外されます。アサーションがあってもなくても、コードは正しく動作しなければなりません。

# このコードはアサーションの有無にかかわらず正しく動作します:
def safe_divide(a, b):
    assert isinstance(a, (int, float)), f"Expected number, got {type(a)}"
    assert isinstance(b, (int, float)), f"Expected number, got {type(b)}"
 
    if b == 0:
        raise ValueError("Division by zero")
    return a / b

アサーションは開発中のバグ検出を助けます。raise は本番で想定されるエラー条件を扱います。両者は異なる役割を持っています。

カスタムアサーションヘルパー

同じアサーションパターンを何度も書くなら、再利用可能なヘルパー関数に切り出しましょう。

シンプルなアサーション関数

def assert_positive(value, name="value"):
    """値が正の数であることをアサートする。"""
    assert isinstance(value, (int, float)), (
        f"{name} must be a number, got {type(value).__name__}"
    )
    assert value > 0, f"{name} must be positive, got {value}"
 
 
def assert_valid_probability(p, name="probability"):
    """値が有効な確率(0 から 1)の範囲にあることをアサートする。"""
    assert isinstance(p, (int, float)), (
        f"{name} must be a number, got {type(p).__name__}"
    )
    assert 0 <= p <= 1, f"{name} must be between 0 and 1, got {p}"
 
 
def assert_same_length(*sequences, names=None):
    """すべてのシーケンスの長さが同じであることをアサートする。"""
    lengths = [len(s) for s in sequences]
    if names:
        details = ", ".join(f"{n}={l}" for n, l in zip(names, lengths))
    else:
        details = ", ".join(str(l) for l in lengths)
    assert len(set(lengths)) == 1, (
        f"Length mismatch: {details}"
    )
 
 
# 使用例
def calculate_weighted_average(values, weights):
    assert_same_length(values, weights, names=["values", "weights"])
    assert_valid_probability(sum(weights) / len(weights), "average weight")
 
    return sum(v * w for v, w in zip(values, weights)) / sum(weights)

デコレータベースのアサーション

decorators を使えば、関数本体を煩雑にせずに前提条件/事後条件チェックを追加できます。

import functools
 
def preconditions(**checks):
    """関数引数に対する前提条件をアサートするデコレータ。"""
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            import inspect
            sig = inspect.signature(func)
            bound = sig.bind(*args, **kwargs)
            bound.apply_defaults()
 
            for param_name, check_func in checks.items():
                value = bound.arguments[param_name]
                assert check_func(value), (
                    f"Precondition failed for '{param_name}': "
                    f"got {value!r}"
                )
            return func(*args, **kwargs)
        return wrapper
    return decorator
 
 
@preconditions(
    x=lambda v: isinstance(v, (int, float)) and v >= 0,
    n=lambda v: isinstance(v, int) and v > 0
)
def nth_root(x, n):
    """n 乗根を計算する。"""
    return x ** (1 / n)
 
 
# これは成功
print(nth_root(27, 3))  # 3.0
 
# これは明確なメッセージ付きで失敗
print(nth_root(-1, 2))  # AssertionError: Precondition failed for 'x': got -1

アサーション群のためのコンテキストマネージャ

関連する複数の条件をまとめてチェックし、すべての失敗を一度に報告したい場合。

class AssertionGroup:
    """複数のアサーション失敗を収集し、まとめて報告する。"""
 
    def __init__(self, description=""):
        self.description = description
        self.failures = []
 
    def check(self, condition, message):
        if not condition:
            self.failures.append(message)
 
    def verify(self):
        if self.failures:
            header = f"{self.description}: " if self.description else ""
            details = "\n  - ".join(self.failures)
            assert False, f"{header}{len(self.failures)} checks failed:\n  - {details}"
 
 
# 使用例
def validate_user_record(record):
    checks = AssertionGroup("User record validation")
    checks.check("name" in record, "Missing 'name' field")
    checks.check("email" in record, "Missing 'email' field")
    checks.check(
        record.get("age", 0) > 0,
        f"Invalid age: {record.get('age')}"
    )
    checks.check(
        "@" in record.get("email", ""),
        f"Invalid email: {record.get('email')}"
    )
    checks.verify()  # すべての失敗をまとめて例外送出

try/except で AssertionError を扱う

AssertionError は他の例外と同様に捕捉できますが、アプリケーションコードではほとんど良い考えではありません。

try:
    assert len(data) > 0, "Data is empty"
    process(data)
except AssertionError as e:
    print(f"Assertion failed: {e}")
    # 失敗を処理...

AssertionError を捕捉するのが理にかなう場合

いくつか正当な用途があります。

1. テストフレームワーク: pytest や unittest は、テスト失敗をクラッシュではなく報告するために AssertionError を捕捉します。

2. 長時間動作するプロセスでアサーション失敗をログに残す場合:

import logging
 
logger = logging.getLogger(__name__)
 
def process_records(records):
    failed = []
    for record in records:
        try:
            assert_valid_record(record)
            process(record)
        except AssertionError as e:
            logger.error(f"Skipping invalid record: {e}")
            failed.append(record)
 
    if failed:
        logger.warning(f"{len(failed)} records failed validation")
    return failed

本番の logging パターンでは、AssertionError の捕捉は適切な例外処理と組み合わせ、プロセス全体を止めずに失敗を可視化できるようにすべきです。

3. 非重要経路での段階的劣化:

def generate_report(data):
    report = {"data": data, "charts": []}
 
    try:
        assert len(data) >= 10, "Not enough data for chart"
        chart = create_chart(data)
        report["charts"].append(chart)
    except AssertionError:
        report["charts_note"] = "Insufficient data for visualization"
 
    return report

AssertionError を捕捉すべきでない場合

バグを黙らせるために AssertionError を捕捉してはいけません。アサーションの目的は、バグを大きく見えるようにすることです。

# WRONG: アサーションを黙らせるのは目的を台無しにする
try:
    assert user.is_valid()
except AssertionError:
    pass  # どうでもいい?

実例

データパイプラインの検証

アサーションは、変換後も特定の性質を保たなければならないデータ処理パイプラインで非常に有用です。

import pandas as pd
 
def clean_sales_data(df):
    """売上データをクリーンアップして検証する。"""
    assert isinstance(df, pd.DataFrame), f"Expected DataFrame, got {type(df)}"
    assert len(df) > 0, "DataFrame is empty"
 
    initial_rows = len(df)
 
    # 重複を削除
    df = df.drop_duplicates(subset=["order_id"])
    assert len(df) > 0, "All rows were duplicates"
 
    # 必要な列を検証
    required = {"order_id", "product", "quantity", "price"}
    assert required.issubset(df.columns), (
        f"Missing columns: {required - set(df.columns)}"
    )
 
    # 数値列をクリーンにする
    df["quantity"] = pd.to_numeric(df["quantity"], errors="coerce")
    df["price"] = pd.to_numeric(df["price"], errors="coerce")
 
    # 不正な数値を含む行を削除
    df = df.dropna(subset=["quantity", "price"])
 
    # 事後条件: すべての価格と数量が正であること
    assert (df["price"] > 0).all(), (
        f"Found {(df['price'] <= 0).sum()} non-positive prices"
    )
    assert (df["quantity"] > 0).all(), (
        f"Found {(df['quantity'] <= 0).sum()} non-positive quantities"
    )
 
    # 合計を計算
    df["total"] = df["quantity"] * df["price"]
    assert (df["total"] > 0).all(), "Totals must be positive"
 
    print(f"Cleaned {initial_rows} -> {len(df)} rows")
    return df

DataFrame を扱うデータサイエンスワークフローでは、PyGWalker (opens in a new tab) を使って、検証済みの DataFrame をインタラクティブな可視化に変換し、さらに探索を進めることができます。パイプラインのアサーションでデータがクリーンで分析可能だと確認した後の、自然な次のステップです。

API レスポンスのチェック

import requests
 
def fetch_user_profile(user_id):
    """防御的なアサーション付きで API からユーザープロフィールを取得する。"""
    response = requests.get(f"https://api.example.com/users/{user_id}")
 
    # 外部データの検証には raise を使う(assert ではない!)
    if response.status_code != 200:
        raise RuntimeError(f"API error: {response.status_code}")
 
    data = response.json()
    if "user" not in data:
        raise ValueError("API response missing 'user' field")
 
    user = data["user"]
 
    # 内部不変条件には assert を使う -- API 契約が正しいなら常に真であるべきもの
    assert "id" in user, "API contract violation: user missing 'id'"
    assert user["id"] == user_id, (
        f"API returned wrong user: requested {user_id}, got {user['id']}"
    )
 
    return user

違いに注目してください。raise は想定されるエラー条件(ネットワーク障害、不正なステータスコード)を扱います。assert は API 側のバグ、または API に対するコードの前提が間違っていることを示すものを捕まえます。

機械学習モデルのサニティチェック

import numpy as np
 
def train_model(X_train, y_train, X_test, y_test):
    """各段階でサニティチェックを行いながらモデルを学習する。"""
 
    # データ形状のアサーション
    assert X_train.ndim == 2, f"X_train must be 2D, got {X_train.ndim}D"
    assert y_train.ndim == 1, f"y_train must be 1D, got {y_train.ndim}D"
    assert X_train.shape[0] == y_train.shape[0], (
        f"Sample count mismatch: X={X_train.shape[0]}, y={y_train.shape[0]}"
    )
    assert X_train.shape[1] == X_test.shape[1], (
        f"Feature count mismatch: train={X_train.shape[1]}, test={X_test.shape[1]}"
    )
 
    # データに NaN や Inf がないこと
    assert not np.isnan(X_train).any(), "X_train contains NaN values"
    assert not np.isinf(X_train).any(), "X_train contains Inf values"
 
    # ラベルが有効であること
    unique_labels = np.unique(y_train)
    assert len(unique_labels) >= 2, (
        f"Need at least 2 classes, got {len(unique_labels)}"
    )
 
    # モデルを学習
    model = fit(X_train, y_train)
 
    # 予測のサニティチェック
    predictions = model.predict(X_test)
    assert predictions.shape == y_test.shape, (
        f"Prediction shape {predictions.shape} != target shape {y_test.shape}"
    )
    assert set(predictions).issubset(set(unique_labels)), (
        f"Model predicted unknown labels: {set(predictions) - set(unique_labels)}"
    )
 
    # 精度のサニティチェック(ランダムより良いはず)
    accuracy = np.mean(predictions == y_test)
    random_baseline = 1 / len(unique_labels)
    assert accuracy > random_baseline * 0.8, (
        f"Accuracy {accuracy:.2%} is worse than random ({random_baseline:.2%})"
    )
 
    return model

状態機械の遷移

class OrderStateMachine:
    VALID_TRANSITIONS = {
        "created": {"confirmed", "cancelled"},
        "confirmed": {"shipped", "cancelled"},
        "shipped": {"delivered", "returned"},
        "delivered": {"returned"},
        "cancelled": set(),
        "returned": set(),
    }
 
    def __init__(self):
        self.state = "created"
        self.history = ["created"]
 
    def transition(self, new_state):
        assert new_state in self.VALID_TRANSITIONS.get(self.state, set()), (
            f"Invalid transition: {self.state} -> {new_state}. "
            f"Valid transitions: {self.VALID_TRANSITIONS[self.state]}"
        )
 
        self.state = new_state
        self.history.append(new_state)
 
        # 不変条件: history は常に "created" で始まる
        assert self.history[0] == "created", "History corrupted"
        # 不変条件: 現在の state は history の最後の要素と一致する
        assert self.state == self.history[-1], "State/history mismatch"

パフォーマンスの考慮

アサーションのコストはどれくらいか?

アサーションには小さいながらも測定可能なコストがあります。条件式は毎回評価されます。assert x > 0 のような単純なチェックなら無視できる程度です。しかし、コストの高いチェックでは積み重なります。

import time
 
data = list(range(1_000_000))
 
# 高速なアサーション: O(1)
start = time.perf_counter()
for _ in range(10_000):
    assert len(data) > 0
fast_time = time.perf_counter() - start
print(f"Simple assertion: {fast_time:.4f}s")
 
# 低速なアサーション: O(n) -- すべての要素をチェック
start = time.perf_counter()
for _ in range(100):
    assert all(isinstance(x, int) for x in data)
slow_time = time.perf_counter() - start
print(f"Expensive assertion: {slow_time:.4f}s")

高コストなアサーションへの対策

アサーションが tight loop で遅すぎるなら、いくつか選択肢があります。

1. 全件ではなくサンプルをチェックする:

import random
 
def process_large_dataset(records):
    # 全件ではなくランダムサンプルをチェック
    sample = random.sample(records, min(100, len(records)))
    assert all(is_valid(r) for r in sample), "Invalid records found in sample"
    # 全レコードを処理...

2. 高コストなチェックに __debug__ フラグを使う:

def matrix_multiply(a, b):
    if __debug__:
        # このブロック全体は python -O で削除される
        assert a.shape[1] == b.shape[0], (
            f"Incompatible shapes: {a.shape} x {b.shape}"
        )
        # 開発中には有用だが高コストなチェック
        assert not np.isnan(a).any(), "Matrix a contains NaN"
        assert not np.isnan(b).any(), "Matrix b contains NaN"
 
    return a @ b

3. 内側のループではなく境界でだけアサートする:

def process_batch(items):
    # 境界で 1 回だけアサート
    assert all(item.is_valid() for item in items), "Invalid items in batch"
 
    # 内側のループは性能のためアサーションなし
    results = []
    for item in items:
        # ここではアサートしない -- すでに上で検証済み
        result = transform(item)
        results.append(result)
 
    # 出力境界で 1 回だけアサート
    assert len(results) == len(items), "Result count mismatch"
    return results

ベストプラクティスのまとめ

効果的にアサーションを使うための要点は次のとおりです。

1. 必ずメッセージを付ける。 assert x > 0 では、失敗時に何も分かりません。assert x > 0, f"Expected positive value, got {x}" なら十分な情報が得られます。

2. 入力検証に assert を使わない。 ユーザー入力、ファイル内容、API レスポンス、データベースクエリはいずれも壊れている可能性があります。if/raise で検証してください。

3. 内部不変条件には assert を使う。 関数の事前条件、事後条件、ループ不変条件、クラス不変条件など、コードが正しければ常に真であるべきものに使います。

4. assert 式の中に副作用を入れない。 assert items.pop() == expected は、-O では要素を取り除かないため危険です。

5. 開発中は積極的に assert を使う。 -O で無効化されればコストはほぼなく、デバッグ時間を大幅に節約できます。

6. アサーションメッセージは実用的にする。 実際の値、期待値、何が起きたかを理解するのに十分な文脈を含めてください。

7. アサーションをテストする。 どんなバグを捕まえるべきかを確認するテストを書いてください。

import pytest
 
def test_transfer_rejects_negative_amount():
    with pytest.raises(AssertionError, match="positive"):
        transfer_money(account_a, account_b, amount=-100)

FAQ

結論

Python の assert 文は、軽量で強力な防御的プログラミングの道具です。暗黙の前提を明示的で強制的なチェックに変え、悪いデータがコード全体に伝播するのを防ぎ、失敗箇所でバグを捕まえます。正しく使えば、デバッグは速くなり、コードはより読みやすくなり、不変条件は自己文書化されます。

重要なルールはシンプルです。assert は内部不変条件と開発者の前提に使い、raise は入力検証と予期されるエラー条件に使う。常に説明的なメッセージを付け、assert 式の中に副作用を入れない。テストでは、pytest も unittest も期待される振る舞いの検証にアサーションを強く活用します。

データサイエンスや分析ワークフローでは、アサーションは PyGWalker (opens in a new tab) のようなツールと組み合わせることで可視化前の DataFrame 検証に役立ち、RunCell (opens in a new tab) のような対話環境では、Jupyter Notebook でアサーション付きデータパイプラインを段階的に構築・テストできます。

これらのパターンを習得すれば、デバッグ時間は短くなり、コードはより堅牢になり、テストはより表現力豊かになります。

関連ガイド

  • Python Try/Except -- ランタイムエラーの例外処理
  • Python Type Hints -- 実行時アサーションを補完する静的型チェック
  • Python Decorators -- アサーションデコレータを含む再利用可能な関数ラッパー
  • Python unittest -- アサーションメソッドを備えた組み込みテストフレームワーク
  • Python Logging -- print デバッグの代替となる構造化ログ
📚