Python単体テスト完全入門!初心者でもわかるテスト自動化

Pythonで開発しているけど、テストコードの書き方が分からないと悩んでいませんか?単体テスト(ユニットテスト)は、バグの早期発見、コード品質の向上、開発効率の向上に欠かせません。この記事では、Python初心者でも理解できる単体テストの基本から実践的なテクニックまで、分かりやすく解説します。

Python単体テストとは?なぜ必要なのか

単体テストとは、プログラムの最小単位(関数やメソッド)が正しく動作するかを検証するテストです。「ユニットテスト」とも呼ばれます。

単体テストのメリット

  • バグの早期発見: 開発中にエラーを素早く特定
  • リファクタリング安全性: コード変更時の影響範囲を把握
  • コード品質向上: テストしやすいコードは良いコード
  • 仕様書代わり: テストコードが使用例として機能
  • 開発効率向上: 手動テストの削減による時間短縮

Python単体テストの基本:unittestライブラリ

Pythonには標準ライブラリとしてunittestが用意されており、追加インストール不要でテストを開始できます。

1. 基本的なテストの書き方

import unittest

def add(a, b):
    return a + b

class TestAddFunction(unittest.TestCase):
    def test_add_positive_numbers(self):
        self.assertEqual(add(2, 3), 5)
    
    def test_add_negative_numbers(self):
        self.assertEqual(add(-1, -1), -2)

if __name__ == '__main__':
    unittest.main()

2. 主要なアサーションメソッド

import unittest

class TestAssertions(unittest.TestCase):
    def test_various_assertions(self):
        # 等価性のテスト
        self.assertEqual(1 + 1, 2)
        self.assertNotEqual(1, 2)
        
        # 真偽値のテスト
        self.assertTrue(True)
        self.assertFalse(False)
        
        # None値のテスト
        self.assertIsNone(None)
        self.assertIsNotNone("text")
        
        # 例外のテスト
        with self.assertRaises(ValueError):
            int("invalid")

【実践編】pytest入門 – より使いやすいテストフレームワーク

pytestは、unittestよりも簡潔でパワフルなテストフレームワークです。

インストール

pip install pytest

1. pytestの基本的な書き方

# test_calculator.py
def multiply(a, b):
    return a * b

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

def test_multiply_zero():
    assert multiply(5, 0) == 0

def test_multiply_negative():
    assert multiply(-2, 3) == -6

実行方法:

pytest test_calculator.py

2. パラメータ化テスト

import pytest

@pytest.mark.parametrize("a,b,expected", [
    (2, 3, 5),
    (0, 5, 5),
    (-1, 1, 0),
    (10, -5, 5)
])
def test_add_parametrized(a, b, expected):
    assert add(a, b) == expected

単体テストを実行してみる(unittestとpytestの実行結果)

ここまでの書き方を、実際に実行するとどう表示されるかを確認します。Python 3.9.6・pytest 8.4.2で実行した結果をそのまま載せています。テスト対象は次のcalc.pyです。

# calc.py
def add(a, b):
    return a + b


def divide(a, b):
    if b == 0:
        raise ValueError("0では割れません")
    return a / b

unittestで実行する:python -m unittest -v

# test_calc_unittest.py
import unittest
from calc import add, divide


class TestCalc(unittest.TestCase):
    def test_add(self):
        self.assertEqual(add(2, 3), 5)

    def test_divide(self):
        self.assertEqual(divide(10, 4), 2.5)

    def test_divide_by_zero(self):
        with self.assertRaises(ValueError):
            divide(1, 0)


if __name__ == "__main__":
    unittest.main()
python -m unittest -v

実行結果:

test_add (test_calc_unittest.TestCalc) ... ok
test_divide (test_calc_unittest.TestCalc) ... ok
test_divide_by_zero (test_calc_unittest.TestCalc) ... ok

----------------------------------------------------------------------
Ran 3 tests in 0.000s

OK

ファイル名を指定しなくても、カレントフォルダのtest*.pyが自動で探されて実行されます。1行ごとに「テスト名 … ok」と出て、最後に件数とOKが表示されれば全部成功です。

pytestで実行する:pytest -v

# test_calc.py
import pytest
from calc import add, divide


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


def test_divide():
    assert divide(10, 4) == 2.5


def test_divide_by_zero():
    with pytest.raises(ValueError):
        divide(1, 0)
pytest test_calc.py -v --no-header

実行結果:

============================= test session starts ==============================
collecting ... collected 3 items

test_calc.py::test_add PASSED                                            [ 33%]
test_calc.py::test_divide PASSED                                         [ 66%]
test_calc.py::test_divide_by_zero PASSED                                 [100%]

============================== 3 passed in 0.00s ===============================

(--no-headerは、実行環境のパスなどが書かれたヘッダー行を省くオプションです。)

テストが失敗したときの表示

わざと失敗するテストを書いて、表示を確かめます。小数の足し算を==で比べる、よくある失敗です。

# test_calc_ng.py
from calc import add


def test_add_ng():
    result = add(0.1, 0.2)
    assert result == 0.3
pytest -q test_calc_ng.py

実行結果:

F                                                                        [100%]
=================================== FAILURES ===================================
_________________________________ test_add_ng __________________________________

    def test_add_ng():
        result = add(0.1, 0.2)
>       assert result == 0.3
E       assert 0.30000000000000004 == 0.3

test_calc_ng.py:6: AssertionError
=========================== short test summary info ============================
FAILED test_calc_ng.py::test_add_ng - assert 0.30000000000000004 == 0.3
1 failed in 0.01s

読むべきところは3か所です。

  • >の行:失敗した行(assert result == 0.3)
  • Eの行:実際の値(0.30000000000000004)と期待した値(0.3)
  • test_calc_ng.py:6:失敗したファイルと行番号

assertと書くだけで、pytestが実際の値まで表示してくれるのが便利な点です。小数の比較はpytest.approx()を使うと通ります。

# test_calc_ok.py
import pytest
from calc import add


def test_add_float():
    assert add(0.1, 0.2) == pytest.approx(0.3)

実行結果:

.                                                                        [100%]
1 passed in 0.00s

pytestはunittestのテストもまとめて実行できる

失敗するテストを消してから、フォルダ全体をpytest -qで実行しました。

pytest -q

実行結果:

.......                                                                  [100%]
7 passed in 0.01s

7件の内訳は、unittestで書いた3件・pytestで書いた3件・pytest.approx()の1件です。unittestで書いたテストも、書き直さずにpytestで実行できました。既存のunittestのテストを残したまま、新しいテストからpytestで書き始めることができます。

終了コードは、全部成功したときが0、1件でも失敗したときが1でした。後半で紹介するCI(GitHub Actions)でのテスト自動化は、この終了コードで成功・失敗を判定しています。

それぞれの詳しい使い方はunittestの解説、pytestの解説にまとめています。

テストケースの設計パターン

1. 正常系・異常系・境界値テスト

def divide(a, b):
    if b == 0:
        raise ValueError("Division by zero")
    return a / b

class TestDivide(unittest.TestCase):
    def test_normal_division(self):  # 正常系
        self.assertEqual(divide(10, 2), 5)
    
    def test_division_by_zero(self):  # 異常系
        with self.assertRaises(ValueError):
            divide(10, 0)
    
    def test_division_boundary(self):  # 境界値
        self.assertAlmostEqual(divide(1, 3), 0.333333, places=5)

2. Given-When-Thenパターン

def test_user_creation():
    # Given(前提条件)
    name = "田中太郎"
    email = "tanaka@example.com"
    
    # When(実行)
    user = create_user(name, email)
    
    # Then(検証)
    assert user.name == name
    assert user.email == email
    assert user.id is not None

モックとスタブの活用

外部依存(API、データベース、ファイル)をテストする際は、モックやスタブを使用します。

1. unittest.mockの使用

import unittest
from unittest.mock import patch, Mock

def fetch_user_data(user_id):
    response = requests.get(f"https://api.example.com/users/{user_id}")
    return response.json()

class TestUserData(unittest.TestCase):
    @patch('requests.get')
    def test_fetch_user_data(self, mock_get):
        # モックの設定
        mock_response = Mock()
        mock_response.json.return_value = {"id": 1, "name": "テストユーザー"}
        mock_get.return_value = mock_response
        
        # テスト実行
        result = fetch_user_data(1)
        
        # 検証
        self.assertEqual(result["name"], "テストユーザー")
        mock_get.assert_called_once_with("https://api.example.com/users/1")

2. pytestでのモック使用

import pytest
from unittest.mock import patch

@patch('requests.get')
def test_api_call(mock_get):
    mock_get.return_value.json.return_value = {"status": "success"}
    
    result = call_external_api()
    assert result["status"] == "success"

フィクスチャとセットアップ・ティアダウン

1. unittestでのセットアップ

import unittest

class TestDatabase(unittest.TestCase):
    def setUp(self):
        # 各テスト前に実行
        self.db = create_test_database()
        self.user = create_test_user()
    
    def tearDown(self):
        # 各テスト後に実行
        self.db.close()
    
    def test_user_creation(self):
        user = self.db.get_user(self.user.id)
        self.assertIsNotNone(user)

2. pytestフィクスチャ

import pytest

@pytest.fixture
def sample_data():
    return {"name": "テスト", "value": 100}

@pytest.fixture
def database():
    db = create_test_database()
    yield db  # テスト実行
    db.close()  # クリーンアップ

def test_data_processing(sample_data, database):
    result = process_data(sample_data, database)
    assert result is not None

テストカバレッジの測定

コードのどの部分がテストされているかを確認するためにカバレッジを測定します。

インストールと実行

pip install coverage

# カバレッジ測定でテスト実行
coverage run -m pytest

# レポート表示
coverage report

# HTML形式でレポート生成
coverage html

設定ファイル(.coveragerc)

[run]
source = .
omit = 
    */venv/*
    */tests/*
    setup.py

[report]
exclude_lines =
    pragma: no cover
    def __repr__
    raise AssertionError

実践的なテスト例

1. Webアプリケーションのテスト

import pytest
from flask import Flask

app = Flask(__name__)

@app.route('/api/users/<int:user_id>')
def get_user(user_id):
    user = User.get(user_id)
    if not user:
        return {"error": "User not found"}, 404
    return {"id": user.id, "name": user.name}

@pytest.fixture
def client():
    app.config['TESTING'] = True
    with app.test_client() as client:
        yield client

def test_get_user_success(client):
    response = client.get('/api/users/1')
    assert response.status_code == 200
    assert response.json['name'] is not None

def test_get_user_not_found(client):
    response = client.get('/api/users/999')
    assert response.status_code == 404

2. データベース操作のテスト

import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker

@pytest.fixture
def db_session():
    engine = create_engine('sqlite:///:memory:')
    Base.metadata.create_all(engine)
    Session = sessionmaker(bind=engine)
    session = Session()
    yield session
    session.close()

def test_user_crud_operations(db_session):
    # Create
    user = User(name="テストユーザー", email="test@example.com")
    db_session.add(user)
    db_session.commit()
    
    # Read
    found_user = db_session.query(User).filter_by(email="test@example.com").first()
    assert found_user.name == "テストユーザー"
    
    # Update
    found_user.name = "更新ユーザー"
    db_session.commit()
    
    # Delete
    db_session.delete(found_user)
    db_session.commit()
    
    assert db_session.query(User).count() == 0

3. 非同期処理のテスト

import pytest
import asyncio

async def async_fetch_data(url):
    await asyncio.sleep(0.1)  # 非同期処理のシミュレーション
    return {"url": url, "data": "test_data"}

@pytest.mark.asyncio
async def test_async_fetch():
    result = await async_fetch_data("https://example.com")
    assert result["data"] == "test_data"
    assert result["url"] == "https://example.com"

テストドリブン開発(TDD)の実践

TDDの基本サイクル:Red-Green-Refactor

# Step 1: Red(失敗するテストを書く)
def test_calculate_tax():
    assert calculate_tax(100, 0.1) == 10

# Step 2: Green(テストを通すコードを書く)
def calculate_tax(amount, rate):
    return amount * rate

# Step 3: Refactor(コードを改善)
def calculate_tax(amount, rate):
    if amount < 0 or rate < 0:
        raise ValueError("金額と税率は0以上である必要があります")
    return round(amount * rate, 2)

CI/CDでのテスト自動化

GitHub Actionsの設定例

# .github/workflows/test.yml
name: Test
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: [3.8, 3.9, '3.10', '3.11']
    
    steps:
    - uses: actions/checkout@v3
    - name: Set up Python
      uses: actions/setup-python@v4
      with:
        python-version: ${{ matrix.python-version }}
    
    - name: Install dependencies
      run: |
        pip install -r requirements.txt
        pip install pytest coverage
    
    - name: Run tests
      run: |
        coverage run -m pytest
        coverage report
        coverage xml
    
    - name: Upload coverage
      uses: codecov/codecov-action@v3

テストのベストプラクティス

1. テストの命名規則

# ✅ 良い例:テスト内容が分かる名前
def test_user_creation_with_valid_email_should_succeed():
    pass

def test_division_by_zero_should_raise_value_error():
    pass

# ❌ 悪い例:内容が分からない名前
def test_1():
    pass

def test_user():
    pass

2. テストの独立性

# ✅ 良い例:各テストが独立
class TestUserService:
    def test_create_user(self):
        user = UserService.create("test@example.com")
        assert user.email == "test@example.com"
    
    def test_delete_user(self):
        user = UserService.create("delete@example.com")
        result = UserService.delete(user.id)
        assert result is True

# ❌ 悪い例:テスト間に依存関係
class TestUserServiceBad:
    def test_create_user(self):
        self.user = UserService.create("test@example.com")
    
    def test_delete_user(self):
        # 前のテストに依存している
        result = UserService.delete(self.user.id)

3. テストデータの管理

# ✅ 良い例:テストデータをファイルで管理
import json

def load_test_data(filename):
    with open(f"tests/data/{filename}") as f:
        return json.load(f)

def test_user_processing():
    test_users = load_test_data("users.json")
    for user_data in test_users:
        result = process_user(user_data)
        assert result["status"] == "success"

パフォーマンステストとプロファイリング

1. 実行時間のテスト

import time
import pytest

def slow_function():
    time.sleep(0.1)
    return "result"

def test_performance():
    start_time = time.time()
    result = slow_function()
    end_time = time.time()
    
    assert result == "result"
    assert (end_time - start_time) < 0.2  # 0.2秒以内で完了

@pytest.mark.timeout(5)  # pytest-timeoutプラグイン使用
def test_with_timeout():
    slow_function()

2. メモリ使用量のモニタリング

import psutil
import os

def test_memory_usage():
    process = psutil.Process(os.getpid())
    memory_before = process.memory_info().rss
    
    # テスト対象の処理
    large_data = create_large_dataset()
    process_data(large_data)
    
    memory_after = process.memory_info().rss
    memory_diff = memory_after - memory_before
    
    # メモリ使用量が10MB以下であることを確認
    assert memory_diff < 10 * 1024 * 1024

テストの組織化と管理

1. テストディレクトリ構造

project/
├── src/
│   ├── __init__.py
│   ├── calculator.py
│   └── user.py
├── tests/
│   ├── __init__.py
│   ├── unit/
│   │   ├── test_calculator.py
│   │   └── test_user.py
│   ├── integration/
│   │   └── test_api.py
│   └── data/
│       └── test_users.json
└── conftest.py

2. conftest.pyでの共通フィクスチャ

# conftest.py
import pytest
from src.database import create_test_db

@pytest.fixture(scope="session")
def database():
    """テストセッション全体で使用するデータベース"""
    db = create_test_db()
    yield db
    db.close()

@pytest.fixture
def sample_user():
    """テスト用のサンプルユーザー"""
    return {
        "name": "テストユーザー",
        "email": "test@example.com",
        "age": 25
    }

エラーパターン別テスト手法

1. 例外処理のテスト

def validate_email(email):
    if "@" not in email:
        raise ValueError("無効なメールアドレス")
    if len(email) > 100:
        raise ValueError("メールアドレスが長すぎます")
    return True

def test_email_validation():
    # 正常なケース
    assert validate_email("test@example.com") is True
    
    # 異常なケース
    with pytest.raises(ValueError, match="無効なメールアドレス"):
        validate_email("invalid-email")
    
    with pytest.raises(ValueError, match="メールアドレスが長すぎます"):
        validate_email("a" * 95 + "@example.com")

2. ファイル操作のテスト

import tempfile
import os

def save_data_to_file(data, filename):
    with open(filename, 'w') as f:
        json.dump(data, f)

def test_file_operations():
    test_data = {"name": "test", "value": 123}
    
    with tempfile.NamedTemporaryFile(mode='w', delete=False) as temp_file:
        temp_filename = temp_file.name
    
    try:
        save_data_to_file(test_data, temp_filename)
        
        # ファイルが作成されたことを確認
        assert os.path.exists(temp_filename)
        
        # ファイル内容を確認
        with open(temp_filename, 'r') as f:
            loaded_data = json.load(f)
        assert loaded_data == test_data
        
    finally:
        os.unlink(temp_filename)

まとめ:効果的なPython単体テストの実践

Python単体テストは、品質の高いソフトウェア開発に欠かせない重要なスキルです。この記事で紹介した基本的な書き方から実践的なテクニックまでを活用することで、バグの少ない保守性の高いコードを開発できるようになります。

重要なポイント:

  • 小さく始める: 簡単な関数から徐々にテストを導入
  • 継続的な実施: CI/CDパイプラインでテストを自動化
  • 品質重視: テストカバレッジだけでなく、テストの質も重視
  • チーム文化: テストを書く文化をチーム全体で共有

まずはunittestやpytestを使った基本的なテストから始めて、徐々にモックやフィクスチャなどの高度な機能を取り入れてみてください。効果的な単体テストの実践により、より安心してコードを変更・拡張できる開発環境を構築できます。


この記事がお役に立ちましたら、ぜひシェアしてください。Python単体テストやテスト駆動開発に関するご質問がございましたら、お気軽にコメントでお知らせください。


unittest・mock の個別解説

あわせて読みたい

学習の順序はPython学習のロードマップ、標準で使える機能の一覧はPython標準ライブラリのモジュール一覧にまとめています。

プログラミング学習に興味がある方は、まずは無料体験会へ

無料体験会・お申し込みはこちら