pytestで始めるPythonテスト入門|書き方・実行・CI連携まで

kento_morota 17分で読めます

ソフトウェアの品質を担保するうえで、テストの自動化は不可欠です。Pythonのテストフレームワークであるpytestは、シンプルな記法と強力な機能を兼ね備え、小規模なスクリプトから大規模なプロジェクトまで幅広く活用されています。

本記事では、pytestの基本的な使い方から、フィクスチャ、パラメタライズ、モック、カバレッジ計測、CI/CD連携までを実践的に解説します。テストを書く文化をチームに根付かせるための第一歩として活用してください。

なぜテストを書くのか|pytestを選ぶ理由

テストを書くメリット

  • リグレッションの防止:コード変更時に既存機能が壊れていないかを自動で確認できる
  • リファクタリングの安全性:テストがあれば安心してコードを改善できる
  • ドキュメントとしての役割:テストコードは「コードの使い方の具体例」として機能する
  • 設計品質の向上:テストしやすいコードは、自然とモジュール化され結合度が低くなる

pytestが選ばれる理由

Python標準のunittestと比較して、pytestには以下の利点があります。

項目pytestunittest
テスト関数の記法関数ベース(シンプル)クラスベース(冗長)
アサーションassert文のみself.assertEqual等のメソッド
失敗時の出力詳細な差分表示基本的なメッセージのみ
フィクスチャ柔軟で再利用可能setUp/tearDown
プラグイン豊富なエコシステム限定的

環境構築と最初のテスト

インストールとプロジェクト構成

# pytestのインストール
pip install pytest pytest-cov

# 推奨プロジェクト構成
my_project/
├── src/
│   └── calculator.py
├── tests/
│   ├── __init__.py
│   ├── conftest.py
│   └── test_calculator.py
├── pyproject.toml
└── requirements-dev.txt

テスト対象のコード(calculator.py)

class Calculator:
    """シンプルな計算機クラス"""

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

    def subtract(self, a: float, b: float) -> float:
        return a - b

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

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

最初のテスト(test_calculator.py)

from src.calculator import Calculator


def test_add():
    calc = Calculator()
    assert calc.add(2, 3) == 5


def test_subtract():
    calc = Calculator()
    assert calc.subtract(10, 3) == 7


def test_multiply():
    calc = Calculator()
    assert calc.multiply(4, 5) == 20


def test_divide():
    calc = Calculator()
    assert calc.divide(10, 2) == 5.0

テストの実行

# テストの実行
pytest

# 詳細出力
pytest -v

# 特定のファイルを実行
pytest tests/test_calculator.py

# 特定のテスト関数を実行
pytest tests/test_calculator.py::test_add

# 失敗したテストのみ再実行
pytest --lf

アサーションと例外テスト

多様なアサーション

def test_various_assertions():
    # 等値比較
    assert 1 + 1 == 2

    # 真偽値
    assert True
    assert not False

    # 含有チェック
    assert "Python" in "I love Python"
    assert 3 in [1, 2, 3, 4, 5]

    # 型チェック
    assert isinstance(42, int)

    # 近似値の比較(浮動小数点数)
    assert 0.1 + 0.2 == pytest.approx(0.3)

    # None チェック
    result = None
    assert result is None

    # リストの比較
    assert sorted([3, 1, 2]) == [1, 2, 3]

    # 辞書の比較
    expected = {"name": "田中", "age": 30}
    actual = {"name": "田中", "age": 30}
    assert actual == expected

例外のテスト

import pytest
from src.calculator import Calculator


def test_divide_by_zero():
    """ゼロ除算時にValueErrorが発生することを確認"""
    calc = Calculator()
    with pytest.raises(ValueError) as exc_info:
        calc.divide(10, 0)
    assert str(exc_info.value) == "ゼロで割ることはできません"


def test_divide_by_zero_match():
    """例外メッセージのパターンマッチ"""
    calc = Calculator()
    with pytest.raises(ValueError, match="ゼロで割る"):
        calc.divide(10, 0)

フィクスチャ|テストの前準備と後処理

フィクスチャは、テストの前準備(セットアップ)や後処理(ティアダウン)を再利用可能な形で定義する仕組みです。

基本的なフィクスチャ

import pytest
from src.calculator import Calculator


@pytest.fixture
def calc():
    """Calculatorインスタンスを提供するフィクスチャ"""
    return Calculator()


def test_add(calc):
    assert calc.add(2, 3) == 5


def test_subtract(calc):
    assert calc.subtract(10, 3) == 7

セットアップとティアダウン

import pytest
import tempfile
from pathlib import Path


@pytest.fixture
def temp_dir():
    """一時ディレクトリを作成し、テスト後に削除するフィクスチャ"""
    with tempfile.TemporaryDirectory() as tmpdir:
        yield Path(tmpdir)
    # ブロックを抜けると自動的にディレクトリが削除される


def test_file_creation(temp_dir):
    """ファイル作成のテスト"""
    file_path = temp_dir / "test.txt"
    file_path.write_text("Hello, Test!")
    assert file_path.exists()
    assert file_path.read_text() == "Hello, Test!"

conftest.pyでフィクスチャを共有

conftest.pyにフィクスチャを定義すると、同じディレクトリ以下のすべてのテストファイルから自動的に利用できます。

# tests/conftest.py
import pytest
from src.calculator import Calculator
from src.database import Database


@pytest.fixture
def calc():
    return Calculator()


@pytest.fixture
def db():
    """テスト用データベースの初期化と後処理"""
    database = Database(":memory:")
    database.create_tables()
    yield database
    database.close()

フィクスチャのスコープ

# セッション全体で1回だけ実行されるフィクスチャ
@pytest.fixture(scope="session")
def app_config():
    return {"database_url": "sqlite:///:memory:", "debug": True}


# モジュール(ファイル)ごとに1回実行
@pytest.fixture(scope="module")
def shared_resource():
    resource = create_expensive_resource()
    yield resource
    resource.cleanup()

パラメタライズ|同じテストを複数パターンで実行

パラメタライズを使うと、同じテストロジックを異なる入力値で繰り返し実行できます。

import pytest
from src.calculator import Calculator


@pytest.fixture
def calc():
    return Calculator()


@pytest.mark.parametrize("a, b, expected", [
    (1, 2, 3),
    (0, 0, 0),
    (-1, 1, 0),
    (100, 200, 300),
    (0.1, 0.2, pytest.approx(0.3)),
])
def test_add_parametrized(calc, a, b, expected):
    """さまざまな入力パターンで加算をテスト"""
    assert calc.add(a, b) == expected


@pytest.mark.parametrize("a, b, expected", [
    (10, 2, 5.0),
    (9, 3, 3.0),
    (7, 2, 3.5),
    (1, 3, pytest.approx(0.333, rel=1e-2)),
])
def test_divide_parametrized(calc, a, b, expected):
    """さまざまな入力パターンで除算をテスト"""
    assert calc.divide(a, b) == expected

複数パラメータの組み合わせ

@pytest.mark.parametrize("method", ["add", "subtract", "multiply"])
@pytest.mark.parametrize("a", [0, 1, -1, 100])
def test_operations_with_zero(calc, method, a):
    """各演算でゼロとの計算が正常に動作することを確認"""
    func = getattr(calc, method)
    result = func(a, 0)
    assert isinstance(result, (int, float))

モック|外部依存のテスト

外部API呼び出しやデータベースアクセスなど、外部依存のある処理をテストする場合はモックを使います。

unittest.mockの基本

from unittest.mock import patch, MagicMock

# テスト対象のコード
# src/weather.py
import requests

class WeatherService:
    def get_temperature(self, city: str) -> float:
        response = requests.get(
            f"https://api.weather.example.com/temperature?city={city}"
        )
        response.raise_for_status()
        data = response.json()
        return data["temperature"]
# tests/test_weather.py
from unittest.mock import patch, MagicMock
from src.weather import WeatherService


def test_get_temperature():
    """APIレスポンスをモックしてテストする"""
    service = WeatherService()

    mock_response = MagicMock()
    mock_response.json.return_value = {"temperature": 25.5}
    mock_response.raise_for_status.return_value = None

    with patch("src.weather.requests.get", return_value=mock_response) as mock_get:
        result = service.get_temperature("Tokyo")

    assert result == 25.5
    mock_get.assert_called_once_with(
        "https://api.weather.example.com/temperature?city=Tokyo"
    )


def test_get_temperature_api_error():
    """API通信エラー時の挙動をテストする"""
    service = WeatherService()

    with patch("src.weather.requests.get") as mock_get:
        mock_get.side_effect = requests.exceptions.ConnectionError("接続エラー")
        with pytest.raises(requests.exceptions.ConnectionError):
            service.get_temperature("Tokyo")

pytest-mockプラグインの活用

# pip install pytest-mock

def test_get_temperature_with_mocker(mocker):
    """mockerフィクスチャを使ったテスト"""
    service = WeatherService()

    mock_response = mocker.MagicMock()
    mock_response.json.return_value = {"temperature": 18.0}

    mocker.patch("src.weather.requests.get", return_value=mock_response)

    result = service.get_temperature("Osaka")
    assert result == 18.0

カバレッジ計測とCI/CD連携

テストカバレッジの計測

# カバレッジ付きでテスト実行
pytest --cov=src --cov-report=term-missing

# HTMLレポートの生成
pytest --cov=src --cov-report=html

# 特定のカバレッジ閾値を設定(80%未満で失敗)
pytest --cov=src --cov-fail-under=80

出力例:

---------- coverage: platform linux, python 3.12.2 -----------
Name                    Stmts   Miss  Cover   Missing
-----------------------------------------------------
src/calculator.py          15      0   100%
src/weather.py             12      2    83%   18-19
-----------------------------------------------------
TOTAL                      27      2    93%

pyproject.tomlでの設定

[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-v --cov=src --cov-report=term-missing"
markers = [
    "slow: marks tests as slow (deselect with '-m \"not slow\"')",
    "integration: marks tests as integration tests",
]

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

[tool.coverage.report]
fail_under = 80
show_missing = true

GitHub ActionsでのCI連携

# .github/workflows/test.yml
name: Python Tests

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: ["3.11", "3.12"]

    steps:
      - uses: actions/checkout@v4

      - name: Set up Python ${{ matrix.python-version }}
        uses: actions/setup-python@v5
        with:
          python-version: ${{ matrix.python-version }}

      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          pip install -r requirements-dev.txt

      - name: Run tests with coverage
        run: pytest --cov=src --cov-report=xml

      - name: Upload coverage report
        uses: codecov/codecov-action@v4
        with:
          file: ./coverage.xml

まとめ

本記事では、pytestを使ったPythonテストの基本から実践的な手法までを解説しました。

  • pytestはシンプルなassert文でテストを書ける、直感的なフレームワーク
  • フィクスチャを使えばテストの前準備・後処理を効率的に管理できる
  • パラメタライズで同じテストロジックを複数パターンで網羅的に検証できる
  • モックを使って外部APIやデータベースへの依存をテストから切り離せる
  • カバレッジ計測とCI/CD連携で、継続的な品質担保を実現できる

テストは「書くコスト」よりも「書かないリスク」のほうが遥かに大きいものです。まずはプロジェクトの中核となる関数から1つずつテストを追加し、徐々にカバレッジを上げていくアプローチがおすすめです。pytestの豊富な機能を活用し、信頼性の高いコードベースを構築しましょう。

#Python#pytest#テスト
共有:
無料メルマガ

週1回、最新の技術記事をお届け

AI・クラウド・開発の最新記事を毎週月曜にメールでお届けします。登録は無料、いつでも解除できます。

プライバシーポリシーに基づき管理します

起業準備に役立つ情報、もっとありますよ。

まずは話だけ聞いてもらう