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

フリーランスボード

20万件以上の案件から、副業に最適なリモート・週3〜の案件を一括検索できるプラットフォーム。プロフィール登録でAIスカウトが自動的にマッチング案件を提案。市場統計や単価相場、エージェントの口コミも無料で閲覧可能なため、本業を続けながら効率的に高単価の副業案件を探せます。フリーランスボード

ITプロパートナーズ

週2〜3日から働ける柔軟な案件が業界トップクラスの豊富さを誇るフリーランスエージェント。エンド直契約のため高単価で、週3日稼働でも十分な報酬を得られます。リモートや時間フレキシブルな案件も多数。スタートアップ・ベンチャー中心で、トレンド技術を使った魅力的な案件が揃っています。専属エージェントが案件紹介から契約交渉までサポート。利用企業2,000社以上の実績。ITプロパートナーズ

Midworks 10,000件以上の案件を保有し、週3日〜・フルリモートなど柔軟な働き方に対応。高単価案件が豊富で、報酬保障制度(60%)や保険料負担(50%)など正社員並みの手厚い福利厚生が特徴。通勤交通費(月3万円)、スキルアップ費用(月1万円)の支給に加え、リロクラブ・freeeが無料利用可能。非公開案件80%以上、支払いサイト20日で安心して稼働できます。Midworks

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

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

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単体テストやテスト駆動開発に関するご質問がございましたら、お気軽にコメントでお知らせください。

「らくらくPython塾」が切り開く「呪文コーディング」とは?

■プロンプトだけでオリジナルアプリを開発・公開してみた!!

■AI時代の第一歩!「AI駆動開発コース」はじめました!

テックジム東京本校で先行開始。

■テックジム東京本校

「武田塾」のプログラミング版といえば「テックジム」。
講義動画なし、教科書なし。「進捗管理とコーチング」で効率学習。
より早く、より安く、しかも対面型のプログラミングスクールです。

<短期講習>5日で5万円の「Pythonミニキャンプ」開催中。

<月1開催>放送作家による映像ディレクター養成講座

<オンライン無料>ゼロから始めるPython爆速講座

フリーランスボード

20万件以上の案件から、副業に最適なリモート・週3〜の案件を一括検索できるプラットフォーム。プロフィール登録でAIスカウトが自動的にマッチング案件を提案。市場統計や単価相場、エージェントの口コミも無料で閲覧可能なため、本業を続けながら効率的に高単価の副業案件を探せます。フリーランスボード

ITプロパートナーズ

週2〜3日から働ける柔軟な案件が業界トップクラスの豊富さを誇るフリーランスエージェント。エンド直契約のため高単価で、週3日稼働でも十分な報酬を得られます。リモートや時間フレキシブルな案件も多数。スタートアップ・ベンチャー中心で、トレンド技術を使った魅力的な案件が揃っています。専属エージェントが案件紹介から契約交渉までサポート。利用企業2,000社以上の実績。ITプロパートナーズ

Midworks 10,000件以上の案件を保有し、週3日〜・フルリモートなど柔軟な働き方に対応。高単価案件が豊富で、報酬保障制度(60%)や保険料負担(50%)など正社員並みの手厚い福利厚生が特徴。通勤交通費(月3万円)、スキルアップ費用(月1万円)の支給に加え、リロクラブ・freeeが無料利用可能。非公開案件80%以上、支払いサイト20日で安心して稼働できます。Midworks