Pythonで自動テストをしよう

このハンズオンでは、Pythonのテストフレームワークpytestを使い、自動テストについて実際にハンズオン形式で手を動かしながら体験します。

  • pytestの概要とインストール
  • テストの書き方と実行方法
  • テストの構造化(Arrange, Act, Assert パターン)
  • フィクスチャとパラメタライズドテスト
  • モックの基本
  • テストカバレッジの計測

1. 事前準備

このハンズオンでは、以下のツールが必要です。まだ準備できていない場合は、リンク先の手順に沿って準備をお願いします。

2. pytestとは

このハンズオンで作成するテストの全体像を以下に示します。

flowchart TB
    pytest[pytest] --> TC[test_calculator.py<br>計算ロジックのテスト]
    pytest --> TU[test_user_service.py<br>ユーザ管理のテスト]
    pytest --> TW[test_weather_client.py<br>外部APIのモックテスト]
    TC --> src1[src/calculator.py]
    TU --> src2[src/user_service.py]
    TW --> src3[src/weather_client.py]

pytestは、Pythonで最も広く使われているテストフレームワークです。Python標準のunittestモジュールと比較して、以下の特徴があります。

  • シンプルなassert文でテストを記述できる
  • テスト関数をtest_で始めるだけでテストとして認識される
  • フィクスチャパラメタライズなどの強力な機能を持つ
  • プラグインによる拡張が豊富

3. プロジェクトの準備

3.1 フォルダの作成

任意の場所にpython-testingフォルダを作成し、Visual Studio Codeの「ファイル」→「フォルダーを開く」から、作成したpython-testingフォルダを開きます。

python-testing  ← このフォルダを作成

さらに、python-testingフォルダの中にsrcフォルダとtestsフォルダを作成します。Visual Studio Codeのエクスプローラーでpython-testingフォルダを右クリックし、「新しいフォルダー」を選択してそれぞれ作成してください。

python-testing
├── src/  ← このフォルダを作成
└── tests/  ← このフォルダを作成
💡 ポイント
srcフォルダにはテスト対象となるアプリケーションのソースコードを、testsフォルダにはテストコードを配置します。ソースコードとテストコードを分けることで、プロジェクトの構成が整理されます。

3.2 requirements.txtの作成

requirements.txtを作成します。Visual Studio Codeのエクスプローラーでpython-testingフォルダを右クリックし、「新しいファイル」を選択してrequirements.txtという名前で作成してください。

python-testing
├── src/
├── tests/
└── requirements.txt  ← このファイルを作成

作成したファイルに以下の内容を記述して保存します。

pytest==8.3.3
pytest-cov==6.0.0

各パッケージの役割は以下のとおりです。

パッケージ 役割
pytest Pythonで最も広く使われているテストフレームワーク。テストの記述と実行を行う
pytest-cov pytestのプラグインで、テストカバレッジ(コードの網羅率)を計測する
💡 ポイント
requirements.txtは、Pythonプロジェクトで使用するライブラリとそのバージョンを記載するファイルです。このファイルを用意しておくことで、pip install -r requirements.txtコマンドで必要なライブラリをまとめてインストールできます。

3.3 パッケージのインストール

requirements.txtに記載したパッケージをまとめてインストールします。Visual Studio Codeのターミナルで以下のコマンドを実行してください。

Windowsの場合:

pip install -r requirements.txt

Macの場合:

pip3 install -r requirements.txt

-r requirements.txtオプションにより、ファイルに記載されたすべてのパッケージが一括でインストールされます。

以下のようにSuccessfully installedと表示されれば、インストールは完了です。

Successfully installed pytest-8.3.3 pytest-cov-6.0.0 ...
⚠️ インストールに失敗する場合
pip3: command not foundpip: command not foundと表示される場合は、Pythonが正しくインストールされていない可能性があります。事前準備のPythonインストール手順を確認してください。

3.4 テスト対象コードの作成

src/init.pyの作成

src/__init__.pyを作成します。Visual Studio Codeのエクスプローラーでsrcフォルダを右クリックし、「新しいファイル」を選択して__init__.pyという名前で作成してください。このファイルは空ファイルとして作成します。

python-testing
├── src/
│   └── __init__.py  ← このファイルを作成(空ファイル)
├── tests/
└── requirements.txt
💡 ポイント
__init__.pyは、そのフォルダがPythonのパッケージ(モジュールのまとまり)であることを示すファイルです。このファイルがないと、テストコードからfrom src.calculator import addのようにインポートできない場合があります。

src/calculator.pyの作成

src/calculator.pyを作成します。Visual Studio Codeのエクスプローラーでsrcフォルダを右クリックし、「新しいファイル」を選択してcalculator.pyという名前で作成してください。

python-testing
├── src/
│   ├── __init__.py
│   └── calculator.py  ← このファイルを作成
├── tests/
└── requirements.txt

作成したファイルに以下の内容を記述して保存します。

def add(a: int, b: int) -> int:
    return a + b


def subtract(a: int, b: int) -> int:
    return a - b


def multiply(a: int, b: int) -> int:
    return a * b


def divide(a: int, b: int) -> float:
    if b == 0:
        raise ValueError("0で割ることはできません")
    return a / b

四則演算を行う4つの関数を定義しています。divide関数では、0で割ろうとした場合にValueError例外を発生させています。この例外処理が正しく動作するかも、後ほどテストで検証します。

src/user_service.pyの作成

src/user_service.pyを作成します。Visual Studio Codeのエクスプローラーでsrcフォルダを右クリックし、「新しいファイル」を選択してuser_service.pyという名前で作成してください。

python-testing
├── src/
│   ├── __init__.py
│   ├── calculator.py
│   └── user_service.py  ← このファイルを作成
├── tests/
└── requirements.txt

作成したファイルに以下の内容を記述して保存します。

class UserService:
    def __init__(self):
        self.users = {}

    def add_user(self, user_id: int, name: str) -> dict:
        if user_id in self.users:
            raise ValueError(f"ユーザID {user_id} は既に存在します")
        self.users[user_id] = {"id": user_id, "name": name}
        return self.users[user_id]

    def get_user(self, user_id: int) -> dict:
        if user_id not in self.users:
            raise KeyError(f"ユーザID {user_id} が見つかりません")
        return self.users[user_id]

    def get_all_users(self) -> list:
        return list(self.users.values())

    def delete_user(self, user_id: int) -> None:
        if user_id not in self.users:
            raise KeyError(f"ユーザID {user_id} が見つかりません")
        del self.users[user_id]

ユーザ情報を管理するUserServiceクラスを定義しています。ユーザの追加、取得、一覧表示、削除の機能を持ち、存在しないユーザの操作や重複登録に対して例外を発生させます。テストでは、これらの各操作が正しく動作するか、またエラーケースで適切な例外が発生するかを検証します。

src/weather_client.pyの作成

src/weather_client.pyを作成します。Visual Studio Codeのエクスプローラーでsrcフォルダを右クリックし、「新しいファイル」を選択してweather_client.pyという名前で作成してください。

python-testing
├── src/
│   ├── __init__.py
│   ├── calculator.py
│   ├── user_service.py
│   └── weather_client.py  ← このファイルを作成
├── tests/
└── requirements.txt

作成したファイルに以下の内容を記述して保存します。

import urllib.request
import json


def fetch_weather(city: str) -> dict:
    try:
        url = f"https://api.example.com/weather?city={city}"
        req = urllib.request.Request(url)
        with urllib.request.urlopen(req, timeout=5) as response:
            data = json.loads(response.read().decode())
            return {"city": city, "status": "success", "temperature": data["temperature"]}
    except Exception as e:
        return {"city": city, "status": "error", "message": str(e)}


def get_weather_summary(cities: list) -> dict:
    results = []
    for city in cities:
        result = fetch_weather(city)
        results.append(result)

    success_count = sum(1 for r in results if r["status"] == "success")
    return {
        "total": len(cities),
        "success": success_count,
        "error": len(cities) - success_count,
        "details": results,
    }

外部APIを呼び出して天気情報を取得する関数を定義しています。fetch_weather関数は指定した都市の天気を取得し、get_weather_summary関数は複数の都市の結果をまとめます。このコードは外部APIに依存しているため、テストではモックを使って実際のHTTPリクエストを送らずにロジックを検証します。

4. はじめてのテストを書く

4.1 テストの基本ルール

pytestでは、以下のルールに従ってテストを記述します。

  • テストファイル名はtest_で始める(例: test_calculator.py
  • テスト関数名はtest_で始める(例: test_add
  • assert文で期待する結果を検証する

基本的なテスト関数の構文は以下の通りです。

def test_関数名():
    assert 関数(引数) == 期待値

実際に試してみましょう。

4.2 テストの作成

tests/init.pyの作成

tests/__init__.pyを作成します。Visual Studio Codeのエクスプローラーでtestsフォルダを右クリックし、「新しいファイル」を選択して__init__.pyという名前で作成してください。このファイルは空ファイルとして作成します。

python-testing
├── src/
│   ├── __init__.py
│   ├── calculator.py
│   ├── user_service.py
│   └── weather_client.py
├── tests/
│   └── __init__.py  ← このファイルを作成(空ファイル)
└── requirements.txt
💡 ポイント
testsフォルダにも__init__.pyを作成します。srcフォルダと同様に、Pythonパッケージとして認識させることで、テストコードからsrcモジュールを正しくインポートできるようになります。

tests/test_calculator.pyの作成

tests/test_calculator.pyを作成します。Visual Studio Codeのエクスプローラーでtestsフォルダを右クリックし、「新しいファイル」を選択してtest_calculator.pyという名前で作成してください。

python-testing
├── src/
│   ├── __init__.py
│   ├── calculator.py
│   ├── user_service.py
│   └── weather_client.py
├── tests/
│   ├── __init__.py
│   └── test_calculator.py  ← このファイルを作成
└── requirements.txt

作成したファイルに以下の内容を記述して保存します。

from src.calculator import add, subtract, multiply, divide


def test_add():
    assert add(1, 2) == 3


def test_subtract():
    assert subtract(5, 3) == 2


def test_multiply():
    assert multiply(3, 4) == 12

コードを解説します。

from src.calculator import add, subtract, multiply, divide

テスト対象のsrc/calculator.pyから、テストしたい関数をインポートしています。テストファイルからテスト対象のコードを読み込むことで、関数を呼び出してテストできるようになります。

def test_add():
    assert add(1, 2) == 3

test_で始まる関数がテスト関数です。assert文は、続く式がTrueであればテスト成功、Falseであればテスト失敗となります。ここではadd(1, 2)の戻り値が3であることを検証しています。

def test_subtract():
    assert subtract(5, 3) == 2


def test_multiply():
    assert multiply(3, 4) == 12

同様に、subtract関数とmultiply関数のテストを記述しています。それぞれ期待する計算結果と一致するかをassert文で検証しています。

4.3 テストの実行

プロジェクトのルートディレクトリで、以下のコマンドを実行します。

Windowsの場合:

pytest tests/test_calculator.py -v

Macの場合:

python3 -m pytest tests/test_calculator.py -v

-vオプションは詳細な出力を表示します。実行結果は以下のようになります。

========================= test session starts ==========================
collected 3 items

tests/test_calculator.py::test_add PASSED                        [ 33%]
tests/test_calculator.py::test_subtract PASSED                   [ 66%]
tests/test_calculator.py::test_multiply PASSED                   [100%]

========================== 3 passed in 0.01s ===========================

3つのテストがすべてPASSED(成功)となりました。

4.4 テストが失敗するケースを体験する

テストが失敗するとどうなるかを体験してみましょう。tests/test_calculator.pytest_add関数を以下のように変更してください。

def test_add():
    assert add(1, 2) == 5  # わざと間違った期待値にする

変更を保存したら、再度テストを実行します。

Windowsの場合:

pytest tests/test_calculator.py -v

Macの場合:

python3 -m pytest tests/test_calculator.py -v

以下のように、テストが失敗した結果が表示されます。

========================= test session starts ==========================
collected 3 items

tests/test_calculator.py::test_add FAILED                         [ 33%]
tests/test_calculator.py::test_subtract PASSED                    [ 66%]
tests/test_calculator.py::test_multiply PASSED                    [100%]

================================ FAILURES ================================
__________________________ test_add __________________________

    def test_add():
>       assert add(1, 2) == 5
E       assert 3 == 5
E        +  where 3 = add(1, 2)

tests/test_calculator.py:5: AssertionError
======================== 1 failed, 2 passed in 0.02s ========================

失敗したテストはFAILEDと表示され、FAILURESセクションに詳細が出力されます。assert 3 == 5という行から、add(1, 2)の実際の戻り値は3であるのに、期待値を5としていたため失敗したことがわかります。このように、pytestは失敗時に「何が期待されていて、実際には何だったか」を明確に表示してくれます。

テスト失敗を確認できたら、test_add関数を元に戻してください。

def test_add():
    assert add(1, 2) == 3

変更を保存したら、再度テストを実行して3つすべてがPASSEDに戻ることを確認してください。

5. Arrange, Act, Assertパターン

前のセクションで作成したテストはシンプルな1行のテストでしたが、実際のプロジェクトではテストの内容がより複雑になります。たとえば、テスト前にデータを準備する必要があったり、複数の条件を検証する必要があったりします。

テストが複雑になると、「このテストは何をしているのか」が読み取りにくくなります。そこで、テストを構造化する際の基本的なパターンとして、Arrange, Act, Assert(AAA)パターンが広く使われています。AAAパターンでは、テストを以下の3つのステップに分けて記述します。

  • Arrange(準備): テストに必要なデータやオブジェクトを用意する
  • Act(実行): テスト対象の処理を実行する
  • Assert(検証): 実行結果が期待通りであることを検証する

この3ステップに分けることで、テストの意図が明確になり、他の開発者がテストコードを読んだときにも「何を準備して」「何を実行して」「何を検証しているのか」がすぐに理解できます。

AAAパターンの基本的な構文は以下の通りです。

def test_関数名():
    # Arrange(準備)
    入力値 = ...

    # Act(実行)
    結果 = テスト対象関数(入力値)

    # Assert(検証)
    assert 結果 == 期待値

実際に試してみましょう。tests/test_calculator.pyに、AAAパターンを意識したtest_divide関数を追加します。

import pytest
from src.calculator import add, subtract, multiply, divide


def test_add():
    assert add(1, 2) == 3


def test_subtract():
    assert subtract(5, 3) == 2


def test_multiply():
    assert multiply(3, 4) == 12


def test_divide():
    # Arrange
    a = 10
    b = 3

    # Act
    result = divide(a, b)

    # Assert
    assert result == pytest.approx(3.333, rel=1e-2)

追加したコードを解説します。

import pytest

pytestモジュールをインポートしています。pytest.approxなどのヘルパ関数を使用するために必要です。

def test_divide():
    # Arrange
    a = 10
    b = 3

    # Act
    result = divide(a, b)

    # Assert
    assert result == pytest.approx(3.333, rel=1e-2)

AAAパターンに沿ってdivide関数のテストを記述しています。Arrangeでテストデータを準備し、Actで関数を実行し、Assertで結果を検証しています。pytest.approxは浮動小数点数の比較に使用するヘルパで、rel=1e-2は1%の相対誤差を許容することを意味します。

テストを実行して結果を確認しましょう。

Windowsの場合:

pytest tests/test_calculator.py -v

Macの場合:

python3 -m pytest tests/test_calculator.py -v

以下のような実行結果が表示されます。

========================= test session starts ==========================
collected 4 items

tests/test_calculator.py::test_add PASSED                        [ 25%]
tests/test_calculator.py::test_subtract PASSED                   [ 50%]
tests/test_calculator.py::test_multiply PASSED                   [ 75%]
tests/test_calculator.py::test_divide PASSED                     [100%]

========================== 4 passed in 0.01s ===========================

test_divideが追加され、4つのテストがすべてPASSEDとなりました。

5.1 例外のテスト

次に、エラーケースのテストを追加します。divide関数は0で割った場合にValueError例外を発生させるように実装しています。この例外が正しく発生することもテストで検証しましょう。

tests/test_calculator.pytest_divide_by_zero関数を追加します。

import pytest
from src.calculator import add, subtract, multiply, divide


def test_add():
    assert add(1, 2) == 3


def test_subtract():
    assert subtract(5, 3) == 2


def test_multiply():
    assert multiply(3, 4) == 12


def test_divide():
    # Arrange
    a = 10
    b = 3

    # Act
    result = divide(a, b)

    # Assert
    assert result == pytest.approx(3.333, rel=1e-2)


def test_divide_by_zero():
    # Arrange & Act & Assert
    with pytest.raises(ValueError, match="0で割ることはできません"):
        divide(10, 0)

追加したコードを解説します。

def test_divide_by_zero():
    with pytest.raises(ValueError, match="0で割ることはできません"):
        divide(10, 0)

pytest.raisesは、特定の例外が発生することを検証するためのヘルパです。matchパラメータで例外メッセージの内容も検証できます。

💡 ポイント
通常のテストではassert 結果 == 期待値のように戻り値を検証しますが、例外が発生する場合は関数が値を返す前にエラーで中断されるため、戻り値を受け取ることができません。with pytest.raises(ValueError):と書くことで、withブロックの中で例外が発生することを「期待している」とpytestに伝えます。ブロック内で指定した例外が発生すればテスト成功、発生しなければテスト失敗となります。

基本的な構文は以下の通りです。

with pytest.raises(例外型):
    例外を発生させる処理()

テストを実行して結果を確認しましょう。

Windowsの場合:

pytest tests/test_calculator.py -v

Macの場合:

python3 -m pytest tests/test_calculator.py -v

以下のような実行結果が表示されます。

========================= test session starts ==========================
collected 5 items

tests/test_calculator.py::test_add PASSED                        [ 20%]
tests/test_calculator.py::test_subtract PASSED                   [ 40%]
tests/test_calculator.py::test_multiply PASSED                   [ 60%]
tests/test_calculator.py::test_divide PASSED                     [ 80%]
tests/test_calculator.py::test_divide_by_zero PASSED             [100%]

========================== 5 passed in 0.01s ===========================

test_divide_by_zeroが追加され、5つのテストがすべてPASSEDとなりました。正常系のテスト(test_divide)とエラーケースのテスト(test_divide_by_zero)の両方が正しく動作しています。

6. フィクスチャ(Fixture)

ここまでのテストでは、add(1, 2)のようにテスト関数の中で直接値を渡していました。しかし、実際のアプリケーションでは、テストの前にデータベースにデータを登録したり、オブジェクトを初期化したりする前準備が必要になることがあります。

こうした前準備を各テスト関数の中に毎回書くと、テストコードが重複し、修正が必要になったときにすべてのテスト関数を変更しなければなりません。フィクスチャは、この前準備を1か所にまとめて共通化するための仕組みです。@pytest.fixtureデコレータ(関数に機能を追加する構文)を使って定義します。

基本的な構文は以下の通りです。

@pytest.fixture
def フィクスチャ名():
    オブジェクト = ...
    return オブジェクト

def test_関数名(フィクスチャ名):
    assert ...

実際に試してみましょう。tests/test_user_service.pyを作成します。Visual Studio Codeのエクスプローラーでtestsフォルダを右クリックし、「新しいファイル」を選択してtest_user_service.pyという名前で作成してください。

python-testing
├── src/
│   ├── __init__.py
│   ├── calculator.py
│   ├── user_service.py
│   └── weather_client.py
├── tests/
│   ├── __init__.py
│   ├── test_calculator.py
│   └── test_user_service.py  ← このファイルを作成
└── requirements.txt

作成したファイルに以下の内容を記述して保存します。

import pytest
from src.user_service import UserService


@pytest.fixture
def user_service():
    """テストごとに新しいUserServiceインスタンスを作成する"""
    service = UserService()
    service.add_user(1, "Tanaka")
    service.add_user(2, "Suzuki")
    return service


def test_get_user(user_service):
    # Act
    user = user_service.get_user(1)

    # Assert
    assert user["name"] == "Tanaka"
    assert user["id"] == 1


def test_get_all_users(user_service):
    # Act
    users = user_service.get_all_users()

    # Assert
    assert len(users) == 2


def test_add_user(user_service):
    # Act
    new_user = user_service.add_user(3, "Yamada")

    # Assert
    assert new_user["name"] == "Yamada"
    assert len(user_service.get_all_users()) == 3


def test_add_duplicate_user(user_service):
    # Act & Assert
    with pytest.raises(ValueError, match="既に存在します"):
        user_service.add_user(1, "Tanaka")


def test_delete_user(user_service):
    # Act
    user_service.delete_user(1)

    # Assert
    assert len(user_service.get_all_users()) == 1
    with pytest.raises(KeyError):
        user_service.get_user(1)


def test_delete_nonexistent_user(user_service):
    # Act & Assert
    with pytest.raises(KeyError, match="見つかりません"):
        user_service.delete_user(999)

コードを解説します。

@pytest.fixture
def user_service():
    service = UserService()
    service.add_user(1, "Tanaka")
    service.add_user(2, "Suzuki")
    return service

@pytest.fixtureデコレータを付けた関数がフィクスチャです。UserServiceのインスタンスを作成し、テスト用のデータ(2名のユーザ)を登録して返しています。

def test_get_user(user_service):

テスト関数の引数にフィクスチャ名(user_service)を指定すると、pytestが自動的にフィクスチャを実行し、戻り値を引数に渡してくれます。テストごとに新しいインスタンスが作成されるため、あるテストでデータを変更しても他のテストに影響しません。

def test_add_duplicate_user(user_service):
    with pytest.raises(ValueError, match="既に存在します"):
        user_service.add_user(1, "Tanaka")

既に存在するユーザID(1)を再度登録しようとした場合に、ValueErrorが発生することを検証しています。正常系だけでなく、エラーケースもテストすることで、コードの信頼性が高まります。

Windowsの場合:

pytest tests/test_user_service.py -v

Macの場合:

python3 -m pytest tests/test_user_service.py -v

以下のような実行結果が表示されます。

========================= test session starts ==========================
collected 6 items

tests/test_user_service.py::test_get_user PASSED                [ 16%]
tests/test_user_service.py::test_get_all_users PASSED            [ 33%]
tests/test_user_service.py::test_add_user PASSED                 [ 50%]
tests/test_user_service.py::test_add_duplicate_user PASSED       [ 66%]
tests/test_user_service.py::test_delete_user PASSED              [ 83%]
tests/test_user_service.py::test_delete_nonexistent_user PASSED  [100%]

========================== 6 passed in 0.01s ===========================

フィクスチャで用意したUserServiceインスタンスに対して、6つのテスト(取得、一覧、追加、重複エラー、削除、削除エラー)がすべて成功しています。

7. パラメタライズドテスト

前のセクションで作成したtest_addでは、add(1, 2) == 3という1つのパターンだけをテストしていました。しかし、実際には「0同士の足し算」「負の数同士の足し算」「大きな数の足し算」など、さまざまなパターンで正しく動作するかを検証する必要があります。

各パターンごとにtest_add_zerotest_add_negativeのようにテスト関数を作ることもできますが、テストのロジック(関数を呼んで結果を確認する)は同じで、入力値と期待値だけが異なります。このような場合にパラメタライズドテストを使うと、1つのテスト関数に複数のデータセットを渡して繰り返し実行できます。@pytest.mark.parametrizeデコレータを使います。

基本的な構文は以下の通りです。

@pytest.mark.parametrize("引数1, 引数2, 期待値", [
    (1, 値2, 期待値1),
    (3, 値4, 期待値2),
])
def test_関数名(引数1, 引数2, 期待値):
    assert 関数(引数1, 引数2) == 期待値

実際に試してみましょう。tests/test_calculator.pyに以下のテストを追加します。

@pytest.mark.parametrize("a, b, expected", [
    (1, 2, 3),
    (0, 0, 0),
    (-1, 1, 0),
    (100, 200, 300),
    (-5, -3, -8),
])
def test_add_parametrize(a, b, expected):
    assert add(a, b) == expected


@pytest.mark.parametrize("a, b, expected", [
    (10, 2, 5.0),
    (7, 2, 3.5),
    (1, 3, pytest.approx(0.333, rel=1e-2)),
])
def test_divide_parametrize(a, b, expected):
    assert divide(a, b) == expected

追加したコードを解説します。

@pytest.mark.parametrize("a, b, expected", [
    (1, 2, 3),
    (0, 0, 0),
    (-1, 1, 0),
    (100, 200, 300),
    (-5, -3, -8),
])
def test_add_parametrize(a, b, expected):
    assert add(a, b) == expected

@pytest.mark.parametrizeデコレータの第1引数にテスト関数の引数名を文字列で指定し、第2引数にテストデータのリストを渡しています。この例では、add関数に対して正の数、0、負の数など5つのパターンをテストしています。テストデータを追加するだけで、テストケースを簡単に増やすことができます。

テストを実行すると、パラメータの組み合わせごとにテストが実行されます。

Windowsの場合:

pytest tests/test_calculator.py -v

Macの場合:

python3 -m pytest tests/test_calculator.py -v

以下のような実行結果が表示されます。

tests/test_calculator.py::test_add_parametrize[1-2-3] PASSED
tests/test_calculator.py::test_add_parametrize[0-0-0] PASSED
tests/test_calculator.py::test_add_parametrize[-1-1-0] PASSED
tests/test_calculator.py::test_add_parametrize[100-200-300] PASSED
tests/test_calculator.py::test_add_parametrize[-5--3--8] PASSED
tests/test_calculator.py::test_divide_parametrize[10-2-5.0] PASSED
tests/test_calculator.py::test_divide_parametrize[7-2-3.5] PASSED
tests/test_calculator.py::test_divide_parametrize[1-3-expected2] PASSED

パラメータの組み合わせごとにテスト名に[1-2-3]のようなサフィックスが付き、それぞれ個別に実行されています。すべてのパラメータパターンでPASSEDとなっています。

💡 ポイント
パラメタライズドテストを使うことで、1つのテスト関数で複数のケースを網羅できます。テストコードの重複を減らし、新しいテストケースの追加も容易になります。

8. モック

プロジェクトのsrc/weather_client.pyには、外部APIを呼び出して天気情報を取得する関数があります。しかし、このコードをそのままテストしようとすると、テストを実行するたびに実際のHTTPリクエストが送信されます。これにはいくつかの問題があります。

  • 天気情報は時間とともに変わるため、テスト結果が毎回異なり、期待値を固定できない
  • テストの実行にネットワーク接続が必要になる
  • 外部APIが停止しているとテストが失敗する(コードに問題がなくても)
  • HTTPリクエストの分だけテストの実行が遅くなる

このように、テスト対象のコードが外部のサービスやリソースに依存している場合、依存先を本物の代わりに「偽物」に置き換えてテストする手法をモックと呼びます。モックを使うことで、外部APIの状態に関係なく、コードのロジックだけを安定してテストできます。Pythonではunittest.mockモジュールのpatchを使います。

基本的な構文は以下の通りです。

from unittest.mock import patch

@patch("モジュール名.関数名")
def test_関数名(mock_オブジェクト):
    mock_オブジェクト.return_value = モック戻り値
    結果 = テスト対象関数()
    assert 結果 == 期待値

実際に試してみましょう。tests/test_weather_client.pyを作成します。Visual Studio Codeのエクスプローラーでtestsフォルダを右クリックし、「新しいファイル」を選択してtest_weather_client.pyという名前で作成してください。

python-testing
├── src/
│   ├── __init__.py
│   ├── calculator.py
│   ├── user_service.py
│   └── weather_client.py
├── tests/
│   ├── __init__.py
│   ├── test_calculator.py
│   ├── test_user_service.py
│   └── test_weather_client.py  ← このファイルを作成
└── requirements.txt

作成したファイルに以下の内容を記述して保存します。

from unittest.mock import patch, MagicMock
from src.weather_client import fetch_weather, get_weather_summary


@patch("src.weather_client.urllib.request.urlopen")
def test_fetch_weather_success(mock_urlopen):
    # Arrange
    mock_response = MagicMock()
    mock_response.read.return_value = b'{"temperature": 22.5}'
    mock_response.__enter__ = MagicMock(return_value=mock_response)
    mock_response.__exit__ = MagicMock(return_value=False)
    mock_urlopen.return_value = mock_response

    # Act
    result = fetch_weather("Tokyo")

    # Assert
    assert result["status"] == "success"
    assert result["temperature"] == 22.5


@patch("src.weather_client.urllib.request.urlopen")
def test_fetch_weather_error(mock_urlopen):
    # Arrange
    mock_urlopen.side_effect = Exception("Connection refused")

    # Act
    result = fetch_weather("Tokyo")

    # Assert
    assert result["status"] == "error"
    assert "Connection refused" in result["message"]


@patch("src.weather_client.fetch_weather")
def test_get_weather_summary(mock_fetch):
    # Arrange
    mock_fetch.side_effect = [
        {"city": "Tokyo", "status": "success", "temperature": 22.5},
        {"city": "Osaka", "status": "error", "message": "timeout"},
        {"city": "Nagoya", "status": "success", "temperature": 20.0},
    ]

    cities = ["Tokyo", "Osaka", "Nagoya"]

    # Act
    summary = get_weather_summary(cities)

    # Assert
    assert summary["total"] == 3
    assert summary["success"] == 2
    assert summary["error"] == 1

コードを解説します。

@patch("src.weather_client.urllib.request.urlopen")
def test_fetch_weather_success(mock_urlopen):

@patchデコレータでurllib.request.urlopenをモックに置き換えています。テスト関数の引数mock_urlopenにモックオブジェクトが渡されます。これにより、実際のHTTPリクエストを送ることなく、関数のロジックのみを検証できます。

    mock_response = MagicMock()
    mock_response.read.return_value = b'{"temperature": 22.5}'

MagicMock(あらゆるメソッド呼び出しを模擬できるオブジェクト)を使って、APIのレスポンスを模擬しています。read()メソッドが呼ばれたときにJSON形式のバイト列を返すように設定しています。

@patch("src.weather_client.fetch_weather")
def test_get_weather_summary(mock_fetch):
    mock_fetch.side_effect = [...]

side_effectにリストを渡すと、関数が呼ばれるたびにリストの要素を順番に返します。ここでは3都市分のレスポンスを設定し、成功2件・エラー1件のケースをテストしています。

📝 モックを使う場面
モックは、外部API、データベース、ファイルシステムなど、テスト環境で利用しにくい外部依存がある場合に使います。モックを使うことで、テストの実行速度が向上し、外部サービスの障害に影響されない安定したテストが実現できます。

実際に試してみましょう。テストを実行します。

Windowsの場合:

pytest tests/test_weather_client.py -v

Macの場合:

python3 -m pytest tests/test_weather_client.py -v

以下のような実行結果が表示されます。

========================= test session starts ==========================
collected 3 items

tests/test_weather_client.py::test_fetch_weather_success PASSED         [ 33%]
tests/test_weather_client.py::test_fetch_weather_error PASSED           [ 66%]
tests/test_weather_client.py::test_get_weather_summary PASSED           [100%]

========================== 3 passed in 0.01s ===========================

モックにより実際のHTTPリクエストを送ることなく、天気取得ロジックの成功ケース、エラーケース、サマリ集計の3つのテストがすべて成功しています。

9. テストカバレッジの計測

テストカバレッジとは、テストによってプログラムのコードがどの程度実行されたかを示す指標です。パーセンテージで表され、数値が高いほど多くのコードがテストで検証されていることを意味します。

カバレッジにはいくつかの種類があります。

  • 行カバレッジ(Line Coverage): コードの各行のうち、テストで実行された行の割合
  • 分岐カバレッジ(Branch Coverage): if文などの条件分岐のうち、テストで通過した分岐の割合
  • 関数カバレッジ(Function Coverage): 定義された関数のうち、テストで呼び出された関数の割合

pytestでは、pytest-covプラグインを使ってカバレッジを計測できます。requirements.txtにすでにpytest-covを含めてインストール済みです。

以下のコマンドでカバレッジ付きでテストを実行します。

Windowsの場合:

pytest tests/ --cov=src --cov-report=term-missing -v

Macの場合:

python3 -m pytest tests/ --cov=src --cov-report=term-missing -v
オプション 説明
--cov=src srcディレクトリのカバレッジを計測
--cov-report=term-missing カバレッジレポートをターミナルに表示し、未カバーの行番号も表示

実行結果の例は以下の通りです。

---------- coverage: platform darwin, python 3.12.x -----------
Name                      Stmts   Miss  Cover   Missing
---------------------------------------------------------
src/__init__.py               0      0   100%
src/calculator.py             9      0   100%
src/weather_client.py        16      4    75%   10-12
src/user_service.py          17      0   100%
---------------------------------------------------------
TOTAL                        42      4    90%

出力の読み方は以下のとおりです。

  • Stmtsは、ファイル内の実行可能な文(ステートメント)の総数を表します
  • Missは、テストで実行されなかった文の数を表します
  • Coverは、カバレッジ率((Stmts - Miss) / Stmts × 100)を表します
  • Missingは、テストで実行されなかった行番号を表します

calculator.pyuser_service.pyはカバレッジ100%で、すべてのコードがテストで検証されています。weather_client.pyは75%で、10〜12行目がテストで実行されていないことがわかります。これはモックを使用しているため、実際のHTTP通信の一部のコードが実行されなかったことを示しています。

📝 カバレッジの目安
カバレッジ80%以上を目標にすることが一般的ですが、数値だけを追い求めず、重要なビジネスロジックが確実にテストされていることが重要です。

10. まとめ

このハンズオンでは、pytestを使った自動テストの書き方と実行方法を体験しました。

  • pytestはPythonで最も広く使われるテストフレームワークで、test_で始まる関数を自動的にテストとして認識する
  • テストはArrange(準備)、Act(実行)、Assert(検証)のパターンで構造化する
  • フィクスチャ@pytest.fixture)を使って、テストの前準備を共通化できる
  • パラメタライズドテスト@pytest.mark.parametrize)で、同じロジックを複数のデータセットでテストできる
  • モックunittest.mock.patch)を使って、外部依存を模擬し、テスト対象のロジックのみを検証できる
  • テストカバレッジpytest-covで計測でき、テストの網羅性を数値で確認できる

11. 次のステップ

これでPython講座は最後の講座まで完了です。学んだ内容を実際の成果物に落とし込みたい方は、章末尾の応用課題に挑戦してみてください。FastAPI と SQLModel を使って、ホテルの予約管理REST APIを実装する課題です。

応用課題の課題内容は誰でも閲覧できます。メンターによる成果物レビューや実装の壁打ちはポートフォリオプランでご提供しています。