🔌

『ハイパーモダンPython』で紹介されていたpytestのプラグインを試してみる

に公開

2024年9月に出版された技術書『ハイパーモダンPython―信頼性の高いワークフローを構築するモダンテクニック』では、Pythonのテストツールであるpytestの利用方法に加えて、pytestのプラグインも多数紹介されています。

https://amzn.to/4j5BGcH

この記事では、これらのプラグインの機能概要と実際に導入してみた例を紹介します。

なおpytestとプラグインに関する基本的な説明は割愛します。これらの詳細は書籍内の「6章:pytestによるテスト」内で紹介されていますので、そちらも合わせてご参照ください。

紹介するプラグインの一覧

以下に、今回紹介するプラグインの名称とその概要をまとめます。PyPIへのリンクも掲載しますので、より正確な情報は各プラグインのドキュメントを参照してください。

プラグイン名 概要
pytest-xdist テストの並列実行をサポートし、テストの速度向上を図るためのプラグイン。
pytest-cov カバレッジ測定を行い、テストがどれだけのコードをカバーしているかを確認できるプラグイン。
xdoctest docstringのテストを行うプラグイン。
pytest-sugar テスト実行のプログレスバーを表示するためのプラグイン。
pytest-icdiff テスト失敗時に辞書やリストなどの差分を表示するプラグイン。
pytest-httpserver HTTPリクエストをモックするためのプラグイン。外部APIとの連携テストを効率的に行える。
pytest-factoryboy FakerやFactoryBoyを使ってテスト用のランダムなダミーデータを簡単に生成できるプラグイン。
pytest-datadir テスト用の外部データファイル(txtやcsvなど)を読み込み、データの取得やファイル更新を実行できるプラグイン。

ソースコード

今回のサンプルコードはGitHubに掲載しています。
https://github.com/tanny-pm/pytest-plugin-demo

プロジェクト管理にはuvを活用して、プラグインの導入などを行いました。pyproject.tomlファイルは以下のようになっています。

pyproject.toml
[project]
name = "todo_app"
version = "0.1.0"
dependencies = [
    "requests",  
]

[tool.uv]
dev-dependencies = [
    "pytest",
    "pytest-xdist",
    "pytest-sugar",
    "pytest-icdiff",
    "pytest-httpserver",
    "pytest-factoryboy",
    "pytest-datadir",
    "pytest-cov",
    "xdoctest",
    "pygments",
]

[tool.coverage.run]
source = ["pytest_plugin_demo", "tests"]
omit = ["*/__init__.py"]

[tool.coverage.report]
show_missing = true

ソースツリーは以下のような構成にしています。todo_service.pyがテスト対象となるソースコードです。

.
├── README.md
├── pyproject.toml
├── src
│   ├── pytest_plugin_demo
│   │   ├── __init__.py
│   │   └── todo_service.py
├── tests
│   ├── __init__.py
│   ├── conftest.py
│   ├── data
│   │   └── test_tasks.csv
│   └── test_todo_service.py
└── uv.lock

プロジェクトの直下で以下のようにコマンドを実行することで、pytestを実行できます。(一部のプラグインを実行する場合は、後述するオプションも付与します)

$ uv sync
$ uv run pytest

サンプルプログラム

次に、pytestのプラグインを使ったサンプルプログラムを示します。TODOリスト管理アプリを題材に、プラグインを組み合わせたテストを実行します。

テスト対象のモジュール

TODOリストのタスクを表すTaskクラスと、CSVファイルからタスクを読み込むload_tasks_from_csv()関数を実装し、これらをテストします。xdoctestを活用するためにdocstringも記載しています。

src/todo_service.py
import csv
from typing import List


class Task:
    """タスクを表すクラス。

    Attributes:
        task (str): タスクの名前
        completed (bool): タスクの完了状態

    Example:
        >>> task = Task("Example Task")
        >>> task.completed
        False
        >>> task.complete()
        >>> task.completed
        True
    """

    def __init__(self, task: str, completed: bool = False):
        self.task = task
        self.completed = completed

    def complete(self):
        self.completed = True


def load_tasks_from_csv(filepath: str) -> List[Task]:
    """CSVファイルからタスクを読み込む。

    Example:
        >>> tasks = load_tasks_from_csv("tests/data/test_tasks.csv")
        >>> len(tasks)
        2
    """
    with open(filepath, newline="") as csvfile:
        reader = csv.DictReader(csvfile)
        return [Task(row["task"], row["completed"] == "True") for row in reader]

テストコード

次にテストコードを実装します。pytest-icdiffはテストの失敗時に効果を発揮するテストのため、わざと失敗させるようにします。

それぞれのテストの詳細は後述します。

tests/test_todo_service.py
from src.pytest_plugin_demo.todo_service import Task, load_tasks_from_csv
from tests.conftest import TaskFactory


# pytest-httpserver 用のテスト
def test_httpserver(httpserver):
    test_response = [{"task": "API Task", "completed": False}]
    httpserver.expect_request("/tasks").respond_with_json(test_response)

    import requests

    response = requests.get(httpserver.url_for("/tasks")).json()
    assert response[0]["task"] == "API Task"


# pytest-factoryboy 用のテスト
def test_task_factory():
    for _ in range(100000):  # 時間のかかるテスト
        task = TaskFactory()
        assert isinstance(task, Task)
        assert isinstance(task.completed, bool)


def test_task_factory_2():
    for _ in range(100000):  # 時間のかかるテスト(並列実行用)
        task = TaskFactory()
        assert isinstance(task, Task)
        assert isinstance(task.completed, bool)


# pytest-datadir データファイルの使用
def test_load_tasks_from_csv(shared_datadir):
    csv_file = shared_datadir / "test_tasks.csv"
    tasks = load_tasks_from_csv(str(csv_file))
    assert len(tasks) == 2


# pytest-icdiff 差分比較
def test_task_comparison():
    task1 = Task("Task A")
    task2 = Task("Task B")
    assert task1.__dict__ == task2.__dict__  # 故意に失敗させる

ここからは、pytestのプラグインを3つの種類に分けて紹介します。

テスト実行方法に関連するプラグイン

pytest-xdist

pytest-xdistは、テストを並列実行するためのプラグインです。これを活用すると、テストの実行速度を向上できる場合があります。以下のように、-n auto オプションでテストを並列実行します。

tests/test_todo_service.py
pytest -n auto

今回は、一部のテストの実行時間が長くなるように調整しています。この状態でのテスト実行時間を比較してみます。

tests/test_todo_service.py(一部)

# pytest-factoryboy 用のテスト
def test_task_factory():
    for _ in range(100000):  # 時間のかかるテスト
        task = TaskFactory()
        assert isinstance(task, Task)
        assert isinstance(task.completed, bool)


def test_task_factory_2():
    for _ in range(100000):  # 時間のかかるテスト(並列実行用)
        task = TaskFactory()
        assert isinstance(task, Task)
        assert isinstance(task.completed, bool)

以下のように、半減とまではいきませんが、テストの実行時間を削減できました(4.68sec→3.22sec)。もっとテストケースが多い場合はさらに効果が見込めると思います。

$ uv run pytest  # 通常実行
(略)
Results (4.68s):
       4 passed
       1 failed
         - tests/test_todo_service.py:39 test_task_comparison

# uv run pytest -n auto  # 並列実行
(略)
Results (3.22s):
       4 passed
       1 failed
         - tests/test_todo_service.py:39 test_task_comparison

テストの実行時間が長くなってきた時には導入を検討したいプラグインです。

pytest-cov

pytest-cov は、コードのカバレッジを測定するためのプラグインです。テストがカバーしているコードの割合をレポートとして表示してくれます。

以下のオプションを付与してテストを実行します。

tests/test_todo_service.py
pytest --cov

今回はカバレッジが100%になりました。(pyproject.tomlの設定で、__init__.pyを計測対象外にしています)

---------- coverage: platform darwin, python 3.12.2-final-0 ----------
Name                         Stmts   Miss  Cover   Missing
----------------------------------------------------------
tests/conftest.py                7      0   100%
tests/test_todo_service.py      26      0   100%
----------------------------------------------------------
TOTAL                           33      0   100%

このプラグインは、内部的にはCoverage.pyというツールを実行しています。詳細な設定方法などはそちらのドキュメントを参照するとよさそうです。

xdoctest

xdoctest は、docstringに記載したExampleの内容をテストします。単体でも利用できますが、--xdoctestオプションをつけて実行することで、pytestのテストと同時に実行できます。

$ uv run pytest --xdoctest 

実行結果を見ると、テストケースの中にdocstringのテストが含まれていることがわかります。(パスしたテストケース数が4から6に増加)

src/pytest_plugin_demo/todo_service.py ✓✓   
(略)
Results (4.68s):
       6 passed
       1 failed
         - tests/test_todo_service.py:39 test_task_comparison

ここまでのプラグインをすべて同時に利用する場合、pytestのオプションは以下のようになります。

$ uv run pytest --xdoctest --cov -n auto

テストの実行結果に関連するプラグイン

続いては、テストの実行結果を見やすくするプラグインです。どんな場合でも汎用的に活用できます。

pytest-sugar

pytest-sugar はテストの進行状況を視覚的に表示し、進行状況バーや成功・失敗したテストを色分けして表示します。これにより、テストの結果が直感的にわかりやすくなります。プラグインを導入するだけで自動的に適用されます。


右上の「100%」の箇所がプログレスバー。失敗したテストは赤で表示される

テストの実行に時間がかかるプロジェクトの場合は導入しておくと良いかもしれません。

pytest-icdiff

pytest-icdiff を使うと、辞書やリストなどの比較時に、差分を見やすく表示できます。これもプラグインを導入するだけで自動的に適用されます。

差分が出ている箇所だけが強調して表示されるようになりました。項目数の多い辞書を扱う場合はとくに役立ちそうです。

テストコードの実装に関連するプラグイン

最後に、テストコードの実装を容易にするプラグインです。テストする内容に応じて導入します。

pytest-httpserver

pytest-httpserver は、HTTPリクエストをモックするためのプラグインです。外部APIのレスポンスをテストする際、事前に設定した内容を返すモックサーバーを構築できます。

以下の例のように、外部APIのURLを指定する箇所を、httpserverで置き換えることができます。

tests/test_todo_service.py
def test_httpserver(httpserver):
    test_response = [{"task": "API Task", "completed": False}]
    httpserver.expect_request("/tasks").respond_with_json(test_response)

    import requests
    response = requests.get(httpserver.url_for("/tasks")).json()
    assert response[0]["task"] == "API Task"

外部APIと連携するプロジェクトを作成する際には積極的に活用したいプラグインです。

pytest-factoryboy

pytest-factoryboy は、ランダムなテスト用データを生成するためのプラグインです。

まず、以下のようにTaskFactoryクラスを定義します。今回は、タスクのタイトルとしてランダムな3つの単語が設定されるようなFactoryを定義しています。

conftest.py
import factory

from src.pytest_plugin_demo.todo_service import Task


class TaskFactory(factory.Factory):
    class Meta:
        model = Task

    task = factory.Faker("sentence", nb_words=3)
    completed = factory.Faker("boolean")

テストコード内では先ほど定義したTaskFactoryクラスを呼び出すことで、ランダムな値が設定されたTaskクラスを生成できます。そのクラスを利用してテストを実行します。

tests/test_todo_service.py
def test_task_factory():
    task = TaskFactory()
    assert isinstance(task, Task)
    assert isinstance(task.completed, bool)

試しにtaskの中身を出力してみたところ、以下のようにランダムな値が設定されていました。

task=Hot economic.       comp=True
task=Allow marriage.     comp=False
task=Over eight.         comp=True
task=Sing than light.    comp=False
task=Baby then.          comp=False
task=Response news.      comp=False
task=Wife score.         comp=False

pytest-datadir

pytest-datadir は、テストで使う外部ファイルを簡単に管理するためのプラグインです。

以下の例のように、datadirを指定することで、data/ディレクトリ内のデータにアクセスできます。

tests/test_todo_service.py
def test_load_tasks_from_csv(datadir):
    csv_file = datadir / "test_tasks.csv"
    tasks = load_tasks_from_csv(str(csv_file))
    assert len(tasks) == 2

このプラグインのポイントは、テストの実行時にファイルを一時フォルダにコピーして利用する点です。そのため、ファイルの内容を変更するようなテストにおいて効果を発揮します。

まとめ

この記事では、pytestの便利なプラグインを、実用例を交えて紹介しました。それぞれのプラグインをうまく活用することで、テストコードの記載を容易にし、テストの実行も効率化できます。

pytestには豊富なプラグインがあることは知っていましたが、実際にどのようなものがあるか、体型的には把握できていませんでした。今回の検証を通じて、それぞれのプラグインのメリットを実感できました。
この中でも、テスト結果の差分を見やすくするpytest-icdiffは、とりあえず導入してみたいと思いました。その他のプラグインは必要に応じて導入を検討したいです。

参考文献

  • 『Hypermodern Python Tooling』Claudio Jolowicz、O'Reilly、Copyright 2024 Claudio Jolowicz、978-1-098-13958-2、邦題『ハイパーモダンPython』オライリー・ジャパン、ISBN978-4-8144-0092-8
GitHubで編集を提案

Discussion