第138回 実務で使えるテストとCI:pytest・モック・テストデータでAIワークフローの品質を担保する手順

AIワークフローを業務に組み込もうとすると、「モデルは動くけれど、変更すると突然動かなくなる」「外部APIの遅延で処理が止まる」「実運用データでの想定外ケースに気づけない」といった悩みに直面しがちです。本記事では、Pythonで実装した日常的なAIワークフロー(CSV前処理、API/LLM呼び出し、推論バッチ)を対象に、実務で使えるテスト戦略とCIの構成を手順とコード例で解説します。現場で再現しやすい具体例に沿って進めますので、まずは小さく始め、運用に耐える品質を徐々に高めていきましょう。

本記事の対象と目的

想定読者:AIを仕事で活用したい実務担当者、個人事業主、中小企業の担当者。目的は、既存のPythonワークフローにテストとCIを導入して運用リスクを下げ、変更時の回帰を防ぐことです。

章立て(概要)

  • テスト戦略の決め方(ユニット/統合/エンドツーエンド)
  • pytestを使った単体テストの書き方(fixtures, parametrize)
  • 外部API/LLM呼び出しのモック手法
  • テストデータの生成と管理(faker, factory_boy, CSVサンプル)
  • 統合テストと小型データセットでの実行方針
  • GitHub ActionsでのCIパイプライン例
  • 運用時の注意点と失敗パターン
  • 導入チェックリストとテンプレート/30分クイックスタート

1. テスト戦略(どのテストをどこまで書くか)

まずは役割を明確にします。小さなチームや一人運用では、全てのテストを同時に充実させるのは非現実的です。優先度を決めて段階的に増やすと良いです。

テスト種別 対象 速度 導入優先度(実務)
ユニットテスト 関数単位、前処理ロジック 速い CSVの列変換、正規化関数
統合テスト モジュール間連携、データパイプライン CSV読み込み→前処理→推論ラッパー
エンドツーエンド(E2E) 本番に近いフロー(API/外部サービス含む) 遅い 小さなデータセットでのバッチ実行 低→定期実行推奨

実務的な優先度の目安

  • まずユニットテスト:データ前処理、ビジネスロジック、スコア計算。
  • 次に統合テスト:モジュールの連携、不整合を早期検出。
  • E2Eは定期実行(夜間/週次)で本番寄せの検証を行う。

2. pytestを使った単体テストの書き方(基本とコツ)

pytestはfixtureやparametrizeで可読性の高いテストが書けます。ここではCSV前処理関数の例を示します。

CSV前処理(例)

def clean_row(row):
    # 入力: dict(CSVの1行)
    # 戻り値: 正規化された dict
    name = row.get("name", "").strip()
    age = row.get("age")
    try:
        age = int(age)
    except (TypeError, ValueError):
        age = None
    return {"name": name, "age": age}

pytestの例

import pytest
from myproject.csv_utils import clean_row

@pytest.mark.parametrize(
    "input_row,expected",
    [
        ({"name": " Alice ", "age": "30"}, {"name": "Alice", "age": 30}),
        ({"name": "", "age": "x"}, {"name": "", "age": None}),
    ],
)
def test_clean_row_param(input_row, expected):
    assert clean_row(input_row) == expected

@pytest.fixture
def sample_row():
    return {"name": " Bob ", "age": "45"}

def test_clean_row_fixture(sample_row):
    out = clean_row(sample_row)
    assert out["name"] == "Bob"
    assert isinstance(out["age"], int)

assertion のコツ

  • 等価性は == を使い、浮動小数点は pytest.approx を使う。
  • 複雑なオブジェクトは必要最小限に比較(キーのみや主要値のみ)。
  • 失敗時に読みやすいメッセージが出るよう、pytestのassertをご活用ください。

3. 外部API/LLM呼び出しのモック

外部依存はテストを不安定にするため、ユニット/統合レベルで適切にモックします。代表的な手法と使い分けを下表に示します。

ライブラリ 用途 向く場面
unittest.mock 関数/メソッドの置換(軽量) 内部ラッパーの戻り値制御、エラー再現
responses requestsによるHTTPレスポンスのモック 外部REST APIのユニットテスト
requests-mock requestsのSession単位でのモック 細かいHTTP振る舞いを制御したい場合
VCR.py 実際のHTTP交流を録画して再生 既知のAPI応答をキャッシュして再現性を得たいとき

外部APIラッパーの例とテスト

# myproject/api_client.py
import requests

class APIClient:
    def __init__(self, base_url):
        self.base_url = base_url

    def get_score(self, payload):
        r = requests.post(f"{self.base_url}/score", json=payload, timeout=5)
        r.raise_for_status()
        return r.json()
# tests/test_api_client.py
from unittest.mock import patch
from myproject.api_client import APIClient

@patch("myproject.api_client.requests.post")
def test_get_score_mock(mock_post):
    mock_resp = mock_post.return_value
    mock_resp.json.return_value = {"score": 0.9}
    mock_resp.raise_for_status.return_value = None

    client = APIClient("https://api.example")
    out = client.get_score({"x": 1})
    assert out["score"] == 0.9
    mock_post.assert_called_once()

よりHTTP層で試したい場合は responses を使います:

import responses
from myproject.api_client import APIClient

@responses.activate
def test_get_score_responses():
    responses.add(
        responses.POST,
        "https://api.example/score",
        json={"score": 0.8},
        status=200,
    )
    client = APIClient("https://api.example")
    assert client.get_score({"x": 1})["score"] == 0.8

4. テストデータの生成と管理

良いテストは良いデータから生まれます。現実の代表ケースと境界値、エッジケースを意図的に含めましょう。個人情報(PII)を含む実データは必ず合成化するか、マスクして使用してください。

ルール 説明
代表ケース抽出 現場データから頻度の高いパターンを抽出してサンプル化
異常値/境界値 欠損、極端値、異常文字列(例: 半角/全角混在)を含める
PIIの扱い 本番データを使う場合は必ず匿名化または合成データで代替
小さなCSVサンプル 数十行の代表的セットを用意して統合テストに利用

faker と factory_boy の簡単例

from faker import Faker
fake = Faker()

def make_user_record():
    return {"name": fake.first_name(), "email": fake.email(), "age": fake.random_int(18, 80)}

生成したレコードはCSVに書き出して統合テスト用の小データとして利用できます。

小さなCSVサンプル設計(例)

行種別 説明
正常 代表的な1〜2行(標準的な列と値)
欠損 必須列が欠けている行(ageが空など)
境界 年齢=0、最大値、長い文字列など
異常 数値が文字列、特殊文字を含む列

5. 統合テストの実行方針

統合テストは重くなりがちなので、次の方針を推奨します。

  • ローカルではユニットテストを高速に回す(pre-commit / pre-pushで実行)。
  • 統合テストは小さなデータセットで速く回るように設計し、CIでは長いE2Eは別ジョブ・夜間に実行。
  • 外部APIはスタブやVCRで再現性を担保。定期的に実際のAPIで罹患テストを走らせる。

統合テスト(ワークフロー実行)の簡単な例

# myproject/workflow.py
import csv
from myproject.csv_utils import clean_row
from myproject.api_client import APIClient

def run_batch(csv_path, api_client):
    results = []
    with open(csv_path, newline="", encoding="utf-8") as f:
        reader = csv.DictReader(f)
        for r in reader:
            r2 = clean_row(r)
            res = api_client.get_score(r2)
            results.append(res)
    return results
# tests/test_workflow_integration.py
from myproject.workflow import run_batch
from types import SimpleNamespace

def test_run_batch(tmp_path):
    csv_file = tmp_path / "sample.csv"
    csv_file.write_text("name,age\nAlice,30\nBob,\n")

    class DummyClient:
        def get_score(self, payload):
            return {"score": 0.5 if payload.get("age") else 0.0}

    out = run_batch(str(csv_file), DummyClient())
    assert len(out) == 2
    assert out[0]["score"] == 0.5
    assert out[1]["score"] == 0.0

6. GitHub ActionsでのCIパイプライン(テンプレート)

ここではPRで自動的にpytestを回し、カバレッジを計測して報告する最小テンプレートを示します。ファイル名は .github/workflows/ci.yml を想定してください。

name: CI

on:
  pull_request:
  push:
    branches: [ main ]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Set up Python
        uses: actions/setup-python@v4
        with:
          python-version: '3.10'
      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          pip install -r requirements-dev.txt
      - name: Run tests with coverage
        run: |
          pytest --junitxml=reports/junit.xml --cov=myproject --cov-report=xml
      - name: Upload coverage report
        uses: actions/upload-artifact@v4
        with:
          name: coverage-report
          path: coverage.xml

実務では coverage の結果を Codecov や Coveralls と連携したり、PR上で失敗を通知する仕組み(Reviewdog や GitHub Checks)を追加します。

7. 運用時の注意点と失敗パターン

問題 原因 対策
フレークテスト(不安定なテスト) 外部依存、時間依存、並列性の問題 依存のモック化、retry回避、テストの分離
テストが遅い 大きなデータセット、E2Eを頻繁に実行 ユニットと統合を分離、夜間にE2Eを実行
カバレッジ偏重 量だけ増えて中身が薄いテスト 重要ロジックに対する深いアサーション、コードレビューでの品質担保

品質指標とゲート条件(目安)

指標 目安
全体テストカバレッジ 70〜85%(業務重要度に応じて高める)
重要モジュール 90%前後を目指す
PRゲート ユニットテスト全通+カバレッジの変化が大きい場合は要確認

CIで失敗した時の簡単なエスカレーションフロー

  • 1) PRに修正コミットを追加して再実行(まず試す)
  • 2) それで直らない場合は失敗ログを貼って担当者に@メンション
  • 3) 夜間のE2Eで発生した場合は運用チームにチケット登録し、翌稼働日までブロッキング回避策を適用

8. 導入チェックリストとテンプレート

項目 完了 注記
requirements-dev.txt に pytest, pytest-cov を追加 [ ] 最低限のテスト実行に必要
少なくとも1つのユニットテストを追加 [ ] CSV前処理などビジネスロジック
CIワークフローを追加(.github/workflows/ci.yml) [ ] PRでテストが回るように
テストデータ(小さなCSV)をリポジトリに保存 [ ] 例: tests/data/sample.csv(PIIに注意)
外部APIをモックするガイドを作成 [ ] responses や unittest.mock の例を含める

9. 30分でできるクイックスタート(実践手順)

  1. requirements-dev.txt に次を追加:pytest, pytest-cov, responses、保存(5分)。
  2. リポジトリに tests/test_csv_utils.py を1つ作成し、上記の clean_row テストを貼る(10分)。
  3. .github/workflows/ci.yml を追加し、上記テンプレートを貼る(10分)。
  4. PRを作って動作を確認。失敗したらログを見て修正して再コミット(5分)。

補助リソースと推奨ライブラリ(導入順)

ライブラリ 目的
1 pytest, pytest-cov テスト実行とカバレッジ測定
2 unittest.mock 軽量なモック・パッチ
3 responses / requests-mock HTTP APIのモック
4 faker, factory_boy テスト用データ生成
5 VCR.py HTTP録画再生(必要時)
6 GitHub Actions CIの実行環境

まとめ

テストとCIは、運用リスクを低減し、変更時の安心感を高めます。現場では小さく始めることが重要です。まずは主要な前処理やスコア計算に対するユニットテストを1つ書き、CIで自動実行する流れを作ってください。次に統合テストを小さなデータセットで追加し、外部依存はモックで切り離します。最終的には定期的に実運用に近いE2Eを走らせることで、運用上の齟齬を早期に発見できます。

Manage AI(https://manageai.online)では、今回のような実務寄りの手順を今後も扱っていきます。まずはこの記事のクイックスタートに従い、既存リポジトリに1つのユニットテストとCIを追加してみてください。

次の一歩(推奨):ローカルで pytest が通ることを確認したら、PR を作成して GitHub Actions で自動化される流れを体験してください。困ったときはログ全文を保存してチームで共有すると原因特定が速くなります。