Pytestのアサーションエラーをわかりやすくしてみる

Pytestのアサーションエラーをわかりやすくしてみる

2025.10.21

はじめに

データ事業本部のkobayashiです。
pytestでテストを書いていると、アサーションが失敗した際の差分表示が見づらいと感じることはあります。特に大きなリストや辞書、長い文字列を比較する際は、どこが違うのかを把握するのに時間がかかることがあります。
今回は、pytestのアサーションエラー時の差分表示を劇的に改善するpytest-icdiffというプラグインを試してみました。

https://github.com/hjwp/pytest-icdiff

pytest-icdiffとは

pytest-icdiffは、pytestのアサーションエラーメッセージの差分表示を改善するプラグインです。ICDiff(Improved Colored Diff)を使用して、カラフルで見やすい横並び形式の差分を提供します。

主な特徴としては以下になります。

  • カラフルな差分表示により違いが一目瞭然
  • 横並び(side-by-side)形式で期待値と実際の値を比較
  • 大きなデータ構造の比較でも違いを素早く特定可能
  • 追加設定なしですぐに使える
  • 標準のpytestと完全互換

では早速試してみます。

pytest-icdiffを使ってみる

環境

今回使用した環境は以下の通りです。

Python 3.12.8
pytest 8.3.4
pytest-icdiff 0.9

インストール

pipで簡単にインストールできます。

$ pip install pytest-icdiff

基本的な使い方

まず、テスト対象となる簡単な関数を用意します。

calculator.py
def process_user_data(users: list) -> dict:
    """ユーザーデータを処理して統計情報を返す"""
    result = {
        "total_users": len(users),
        "active_users": 0,
        "average_age": 0,
        "user_categories": {
            "premium": [],
            "standard": [],
            "free": []
        }
    }

    total_age = 0
    for user in users:
        if user.get("active", False):
            result["active_users"] += 1

        total_age += user.get("age", 0)

        category = user.get("category", "free")
        result["user_categories"][category].append(user["name"])

    if users:
        result["average_age"] = total_age / len(users)

    return result

def format_report(title: str, content: str, footer: str = "") -> str:
    """レポートをフォーマットする"""
    separator = "=" * 50
    report = f"""
{separator}
{title.upper()}
{separator}

{content}

{footer}
{separator}
    """.strip()

    return report

次に、この関数をテストするコードを書きます。

test_calculator.py
import pytest
from calculator import process_user_data, format_report

class TestUserDataProcessing:
    """ユーザーデータ処理のテスト"""

    def test_process_user_data_basic(self):
        """基本的なユーザーデータ処理のテスト"""
        users = [
            {"name": "Alice", "age": 25, "active": True, "category": "premium"},
            {"name": "Bob", "age": 30, "active": False, "category": "standard"},
            {"name": "Charlie", "age": 35, "active": True, "category": "free"}
        ]

        expected = {
            "total_users": 3,
            "active_users": 2,
            "average_age": 30.0,
            "user_categories": {
                "premium": ["Alice"],
                "standard": ["Bob"],
                "free": ["Charlie"]
            }
        }

        result = process_user_data(users)
        assert result == expected

    def test_process_user_data_complex(self):
        """複雑なユーザーデータの比較(意図的に失敗)"""
        users = [
            {"name": "David", "age": 28, "active": True, "category": "premium"},
            {"name": "Eve", "age": 32, "active": True, "category": "premium"},
            {"name": "Frank", "age": 45, "active": False, "category": "standard"},
            {"name": "Grace", "age": 22, "active": True, "category": "free"},
            {"name": "Henry", "age": 38, "active": False, "category": "free"}
        ]

        # 意図的に間違った期待値を設定
        expected = {
            "total_users": 5,
            "active_users": 4,  # 実際は3
            "average_age": 35.0,  # 実際は33.0
            "user_categories": {
                "premium": ["David", "Eve", "Frank"],  # Frankはstandard
                "standard": [],  # 実際は["Frank"]
                "free": ["Grace", "Henry"]
            }
        }

        result = process_user_data(users)
        assert result == expected

class TestFormatReport:
    """レポートフォーマットのテスト"""

    def test_format_report_basic(self):
        """基本的なレポートフォーマットのテスト"""
        title = "Monthly Report"
        content = "This is the content of the report."
        footer = "Generated on 2024-10-12"

        expected = """==================================================
MONTHLY REPORT
==================================================

This is the content of the report.

Generated on 2024-10-12
=================================================="""

        result = format_report(title, content, footer)
        assert result == expected

    def test_long_text_comparison(self):
        """長いテキストの比較(意図的に失敗)"""
        text1 = """
        Lorem ipsum dolor sit amet, consectetur adipiscing elit.
        Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
        Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.
        Nisi ut aliquip ex ea commodo consequat.
        Duis aute irure dolor in reprehenderit in voluptate velit.
        """

        text2 = """
        Lorem ipsum dolor sit amet, consectetur adipiscing elit.
        Sed do eiusmod tempor incididunt ut labor et dolore magna aliqua.
        Ut enim ad minimum veniam, quis nostrud exercitation ullamco laboris.
        Nisi ut aliquip ex ea commodo consequat.
        Duis aute irure dolor in reprehenderit in voluptate velit esse.
        """

        assert text1.strip() == text2.strip()

これでテストができたので早速pytest-icdiffの効果を試してみます。

通常のpytestとpytest-icdiffの比較

通常のpytest実行結果

pytest-icdiffをアンインストールした状態で実行してみます。

$ pytest test_calculator.py -v

スクリーンショット 2025-10-13 2.28.13

標準の出力では、違いがある項目は表示されますが、複雑なデータ構造では見づらいです。

pytest-icdiff導入後の実行結果

pytest-icdiffをインストールした状態で実行すると

$ pytest test_calculator.py -v

スクリーンショット 2025-10-13 2.26.30
スクリーンショット 2025-10-13 2.32.10

pytest-icdiffを使用すると、差分が横並びでカラフルに表示されます:

  • 左側に実際の値(actual)
  • 右側に期待値(expected)
  • 違いがある部分がハイライトされる
  • 行番号付きで表示される

特に辞書やリストのネストが深い場合、どこが違うのかが一目瞭然になります。

実践的な使用例

JSONデータの比較

APIレスポンスのJSONデータをテストする際に特に便利です。

test_json_comparison.py
import json
import pytest

def test_api_response():
    """APIレスポンスのJSONデータ比較"""
    actual_response = {
        "status": "success",
        "data": {
            "user_id": 12345,
            "profile": {
                "name": "John Doe",
                "email": "john.doe@example.com",
                "preferences": {
                    "newsletter": True,
                    "notifications": {
                        "email": True,
                        "push": False,
                        "sms": True
                    }
                }
            },
            "last_login": "2024-10-12T10:30:00Z"
        },
        "metadata": {
            "request_id": "abc-123",
            "timestamp": "2024-10-12T11:00:00Z"
        }
    }

    expected_response = {
        "status": "success",
        "data": {
            "user_id": 12345,
            "profile": {
                "name": "John Doe",
                "email": "john.doe@example.org",  # 違うドメイン
                "preferences": {
                    "newsletter": True,
                    "notifications": {
                        "email": False,  # 違う値
                        "push": False,
                        "sms": True
                    }
                }
            },
            "last_login": "2024-10-12T10:30:00Z"
        },
        "metadata": {
            "request_id": "xyz-789",  # 違うID
            "timestamp": "2024-10-12T11:00:00Z"
        }
    }

    assert actual_response == expected_response

実行結果

$ pytest test_json_comparison.py -vv

スクリーンショット 2025-10-13 2.36.01

リストの要素比較

test_list_comparison.py
def test_list_of_dictionaries():
    """辞書のリストを比較"""
    actual_products = [
        {"id": 1, "name": "Product A", "price": 100, "stock": 50},
        {"id": 2, "name": "Product B", "price": 200, "stock": 30},
        {"id": 3, "name": "Product C", "price": 150, "stock": 0},
        {"id": 4, "name": "Product D", "price": 300, "stock": 15},
    ]

    expected_products = [
        {"id": 1, "name": "Product A", "price": 100, "stock": 50},
        {"id": 2, "name": "Product B", "price": 250, "stock": 30},  # 価格が違う
        {"id": 3, "name": "Product C", "price": 150, "stock": 10},  # 在庫が違う
        {"id": 4, "name": "Product D", "price": 300, "stock": 15},
    ]

    assert actual_products == expected_products

実行結果

$ pytest test_list_comparison.py -vv

スクリーンショット 2025-10-13 2.38.05

その他

pytest-icdiffは差分を計算して表示するため、わずかながらオーバーヘッドがあります。しかし、テストが失敗した場合のみ動作するため、通常のテスト実行にはほとんど影響しません。

test_performance.py
import pytest
import time

@pytest.mark.parametrize("n", range(100))
def test_many_assertions(n):
    """多数のアサーションのパフォーマンステスト"""
    # 成功するテストには影響なし
    assert n >= 0
    assert n < 100
    assert str(n).isdigit() or n == 0

GitHub ActionsやGitLab CIなどのCI/CD環境でも、pytest-icdiffは有効です。ただし、ターミナルがカラー出力に対応していない場合は、モノクロで表示されます。

また以下の制限事項には注意が必要です。

  • 非常に大きな差分の場合、表示が長くなりすぎることがある
  • カスタムアサーション関数では動作しない場合がある
  • ターミナルの幅に依存して表示が崩れる可能性がある

まとめ

pytest-icdiffは、pytestのアサーションエラー時の差分表示を大幅に改善する素晴らしいプラグインで、特に 複雑なデータ構造(ネストした辞書やリスト)の比較、長い文字列やJSONデータの差分確認と言った場合にテスト結果がわかりやすくなります。
導入もpip install pytest-icdiffだけで完了し、既存のテストコードを変更する必要がないため、すぐに恩恵を受けられます。テストのデバッグ時間を短縮し、開発効率を向上させたい方には、ぜひ試していただきたいプラグインです。

最後まで読んでいただきありがとうございました。

この記事をシェアする

FacebookHatena blogX

関連記事