Python基礎

【コピペOK】pytestで“壊れないPython”を作る12ステップ

「昨日は動いてたのに、今日は壊れた…」

データ分析やETL、機械学習のコードで多発するこの悲劇。実は“テスト不在”が9割です。

本記事は、pytestで“壊れないPython”を作るための実務ガイドです。
未経験〜初学者でも1〜2週間で、基本→AAA→fixture/parametrize→pandas/SQL→モック/monkeypatch→乱数固定→可視化→ML再現性→カバレッジ/CIまでを一気通貫で整えられるよう、コピペOKのテンプレ+チェックリストで解説します。

この記事で身に付く力

  • pytestの基本〜実務に効くテスト設計を一気通貫で理解
  • pandas/SQLite/可視化/MLのテスト“型”をコピペで適用
  • CIとカバレッジで「昨日も今日も動く」状態を維持

なぜデータ系Pythonは“壊れやすい”のか

分析・ETL・MLの現場では、列の追加/削除や外部環境の変化が日常茶飯事。
暗黙知に依存していると、小さな変更が連鎖的な崩壊を引き起こします。

  • 暗黙の前提:列名/型/粒度がドキュメント化されず、ある日列が消えて全滅。
  • 外部依存:ファイル/DB/API/現在時刻/乱数に引っ張られて挙動がブレる。
  • ノートブック依存:セル順・手動実行で再現不能。

解決の基本方針

  • 処理を関数化(副作用は外に出す)

  • pytestで最小のテストから積み上げ

  • CIで毎回自動実行(壊れたら即わかる)

以降のテンプレをそのまま採用してください。

現場で生き残った“壊れない進め方”

筆者(ふみと)は大手企業でデータ/マーケティングサイエンティストを10年。
ノートブック単体運用で痛い目を何度も見ました。
最終的に行き着いたのが、src配下へ関数化→pytestで即時検証→CIの三点セット。
これで在宅や副業でも納期と品質を安定させられます。

pytest 実務の型:12ステップ(コピペOK)

前提環境:Python 3.10+ / pytest 7+ / pandas 2.x / SQLite

ステップ0:プロジェクト雛形

project/
  ├─ src/
  │   ├─ features.py      # 前処理/特徴量
  │   ├─ report.py        # Excel/図の出力
  │   └─ db.py            # SQLite I/O
  ├─ tests/
  │   ├─ test_features.py
  │   ├─ test_db.py
  │   └─ conftest.py      # 共有fixture
  ├─ data/ (raw/warehouse)  # 非追跡推奨
  ├─ reports/               # 出力(非追跡推奨)
  ├─ requirements.txt
  ├─ pyproject.toml        # pytest設定
  └─ README.md

ポイントsrc/に業務ロジック、tests/にテスト。データやレポートの生成物はGitに載せないのが安全です。

ステップ1:pytestを入れて最小実行

やりたいこと:テストを1本でも動かして“痛みなく回る”体験を得る。

pip install pytest pytest-cov pandas
pytest -q   # テスト探索→実行

命名ルール

関数:test_*

ファイル:tests/test_*.py または *_test.py

ステップ2:AAA(Arrange–Act–Assert)で“読みやすい”型にする

やりたいこと:前処理関数add_amountqty*priceを正しく計算するか検証。

# src/features.py
import pandas as pd


def add_amount(df: pd.DataFrame) -> pd.DataFrame:
# 失敗時の原因がすぐ分かるよう、前提条件を明示
assert {"qty", "price"}.issubset(df.columns)
# 副作用なし(受け取ったDFを壊さない)
return df.assign(amount=df["qty"] * df["price"])

# tests/test_features.py
import pandas as pd
from pandas.testing import assert_frame_equal
from src.features import add_amount


def test_add_amount_multiplies_qty_and_price():
# Arrange(入力準備)
df = pd.DataFrame({"qty": [2, 3], "price": [500, 1200]})
# Act(関数を呼ぶ)
got = add_amount(df)
# Assert(期待と比較)
expect = pd.DataFrame({
"qty": [2, 3], "price": [500, 1200], "amount": [1000, 3600]
})
assert_frame_equal(got, expect)

初心者向けTip:DataFrameは==で比較せず、pandas.testingの比較関数で列順・型・インデックスの違いも検出します。

ステップ3:fixtureで“毎回の準備”を共通化

やりたいこと:テストでよく使うサンプルDFや一時SQLite接続、乱数固定を共通化。

# tests/conftest.py
import pandas as pd
import sqlite3
import pytest
import random, numpy as np


@pytest.fixture
def sample_df():
return pd.DataFrame({
"order_date": ["2024-04-01"], "store": ["S01"], "qty": [2], "price": [500]
})


@pytest.fixture
def sqlite_conn(tmp_path: pytest.TempPathFactory):
db = tmp_path / "test.sqlite"
con = sqlite3.connect(db)
yield con
con.close()


# すべてのテストで乱数を固定(再現性の土台)
@pytest.fixture(autouse=True)
def fixed_seed():
random.seed(42); np.random.seed(42)
yield

ポイントtmp_pathはテストごとにクリーンな一時ディレクトリを提供。副作用の混入を防げます。

ステップ4:@pytest.mark.parametrizeで条件網羅を短く

やりたいことqtypriceの境界値(0など)をデータ駆動で網羅。

import pytest
import pandas as pd
from src.features import add_amount

@pytest.mark.parametrize("qty, price, amount", \[
(2, 500, 1000),
(0, 500, 0),
(5, 0, 0),
])
def test\_add\_amount\_param(qty, price, amount):
df = pd.DataFrame({"qty":\[qty], "price":\[price]})
got = add\_amount(df)
assert int(got.loc\[0, "amount"]) == amount

ポイント:境界値テストを“表”で書ける。テストケースの追加・削除も簡単。

ステップ5:pandasの列・型・欠損を検証

やりたいこと:想定列が揃い、型が妥当で、欠損が出ていないかを確認。

from pandas.testing import assert_series_equal
from src.features import add_amount

def test\_columns\_and\_dtypes(sample\_df):
got = add\_amount(sample\_df)
assert set(\["order\_date","store","qty","price","amount"]).issubset(got.columns)
assert got\["qty"].dtype.kind in "iu"   # int/uint
assert got\["price"].dtype.kind in "if"  # int/float
assert not got.isna().any().any()      # 必要に応じて欠損も確認

ポイント型の破壊(int→floatなど)はバグの温床。dtype.kindでざっくり守るのが簡単です。

ステップ6:SQLite I/Oを“副作用ゼロ”で回帰テスト

やりたいこと:DB書き込み→集計読み出しの往復が壊れていないか。

# src/db.py
import sqlite3, pandas as pd


def write_sales(con: sqlite3.Connection, df: pd.DataFrame) -> None:
df.to_sql("sales", con, if_exists="append", index=False)


def read_monthly(con: sqlite3.Connection) -> pd.DataFrame:
q = """
WITH m AS (
SELECT strftime('%Y-%m', order_date) AS ym, store,
SUM(qty*price) AS sales
FROM sales GROUP BY ym, store
) SELECT * FROM m
"""
return pd.read_sql_query(q, con)
# tests/test_db.py
import pandas as pd
from src.db import write_sales, read_monthly


def test_sqlite_roundtrip(sqlite_conn):
df = pd.DataFrame({
"order_date": ["2024-04-01", "2024-04-02"],
"store": ["S01", "S01"],
"qty": [2, 3], "price": [500, 1200],
})
write_sales(sqlite_conn, df)
got = read_monthly(sqlite_conn)
assert set(["ym", "store", "sales"]).issubset(got.columns)
assert float(got.loc[0, "sales"]) > 0

ポイント:本番DBに触らず、tmp_pathやインメモリSQLiteで“安全に再現”。

ステップ7:外部依存の除去(依存注入/monkeypatch)

やりたいこと:ファイルパス・現在時刻・HTTPなど外部に依存する値を引数で受け取る(=依存注入)。

# src/report.py
from pathlib import Path


def save_text(path: Path, content: str) -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
return path
# tests/test_report.py
from pathlib import Path
from src.report import save_text


def test_save_text_writes_to_tmp(tmp_path):
p = tmp_path / "reports/hello.txt"
got = save_text(p, "hello")
assert got.exists() and got.read_text() == "hello"

ポイント:必要に応じてmonkeypatchで環境依存の関数・変数を差し替えれば、どの環境でも同じ結果に。

ステップ8:乱数・現在時刻の固定(再現性)

やりたいこと:モデル学習やサンプリングの再現性を保つ。
すでにconftest.pyで乱数は固定済み。現在時刻はfreezegun等の利用も可。

ステップ9:可視化は“生成されたこと”をテスト

やりたいこと:画像の中身ではなく生成成功(ファイルが存在・サイズ>0)を回帰テスト。

# src/plot.py
import matplotlib.pyplot as plt


def plot_trend(df, out):
plt.figure()
plt.plot(df["x"], df["y"], marker="o")
plt.tight_layout()
plt.savefig(out, dpi=120, bbox_inches="tight")
# tests/test_plot.py
import pandas as pd
from src.plot import plot_trend


def test_plot_trend_saves_png(tmp_path):
df = pd.DataFrame({"x": ["2024-04", "2024-05"], "y": [10, 12]})
out = tmp_path / "trend.png"
plot_trend(df, out)
assert out.exists() and out.stat().st_size > 0

ステップ10:MLの再現性(CVの平均と分散に下限/上限)

やりたいこと:交差検証の平均スコアが下限以上分散が上限以下で、退行を検知。

import numpy as np
from sklearn.datasets import load_breast_cancer
from sklearn.model_selection import StratifiedKFold, cross_val_score
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression

def test\_cv\_auc\_is\_reasonable():
Xy = load\_breast\_cancer()
X, y = Xy.data, Xy.target
pipe = Pipeline(\[
("sc", StandardScaler()),
("lr", LogisticRegression(max\_iter=1000, random\_state=42, n\_jobs=-1))
])
cv = StratifiedKFold(n\_splits=5, shuffle=True, random\_state=42)
scores = cross\_val\_score(pipe, X, y, scoring="roc\_auc", cv=cv, n\_jobs=-1)
assert np.mean(scores) > 0.98  # 下限
assert np.std(scores) < 0.02   # ばらつき上限

ポイントrandom_stateを固定し、CV設計(層化・シャッフル)でブレを制御します。

ステップ11:マーク/スキップ/xfailの活用

やりたいこと:環境依存や“将来直す”テストを適切に管理。

import sys, pytest

@pytest.mark.skipif(sys.platform == "win32", reason="OS依存のためスキップ")
def test\_posix\_only():
assert True

@pytest.mark.xfail(reason="将来対応予定の仕様")
def test\_future\_behavior():
assert 1/0 == 0

ステップ12:設定・実行・カバレッジ(CI連携)

やりたいこと:pytestの既定値を整え、CIで常時起動。必要ならカバレッジも確認。

# pyproject.toml(一例)
[tool.pytest.ini_options]
minversion = "7.0"
addopts = "-q --maxfail=1 --disable-warnings"
testpaths = ["tests"]
filterwarnings = ["ignore::DeprecationWarning"]
# カバレッジ(任意)
pip install pytest-cov
pytest --cov=src --cov-report=term-missing

CI:GitHub Actionsのテンプレを使い、push/pull_requestで自動実行。mainに保護ルールを掛ければ“壊れたコードが入らない”状態になります。

レビュー込みで“壊れないポートフォリオ”へ最短到達

テストを入れると、在宅×副業の毎月レポートやML実験の事故率が激減。設計→テスト→CIまで短期間で整えるなら、質問対応とレビューのあるスクールが近道です。

株式会社キカガク:実務再現型の課題設計とレビュー。転職直結に◎。
Tech Academy:質問の速さ×短時間運用で継続しやすい。副業/在宅に◎。

TechAcademy データサイエンスコース(受講料:174,600円~ ※更に割引あり)

TechAcademy 無料相談

株式会社キカガク AI人材長期育成コース(受講料:237,600円~)

キカガク 無料相談

用途別の重点ポイント(3タイプ)

  • 社会人(転職):ML再現性テスト(平均/分散)を最優先。READMEに実行コマンドとCIバッジ。
  • 副業(稼ぎたい):SQLite I/O+Excel出力の回帰テスト。タグ/リリースで版管理。
  • 主婦/夫(在宅)tmp_path/monkeypatchで副作用ゼロの小さなテストから。“1日15分×1テスト”でも十分積み上がります。

付録A:よく使うアサーション早見表

assert 1 == 1
import math; assert math.isclose(0.1+0.2, 0.3, rel_tol=1e-9)
from pandas.testing import assert_frame_equal, assert_series_equal

付録B:エラーパス(失敗経路)のテスト

import pytest
from src.features import add_amount
import pandas as pd

def test\_add\_amount\_raises\_when\_columns\_missing():
with pytest.raises(AssertionError):
add\_amount(pd.DataFrame({"qty":\[1]}))

付録C:pytest実行の小技

pytest -k amount        # 名前にamountを含むテストだけ
pytest tests/test_db.py::test_sqlite_roundtrip -q
pytest -x               # 最初の失敗で停止
pytest -n auto          # 並列実行(pytest-xdist)

まとめ

  • 関数化→pytest→CIの三点で“壊れないPython”。
  • pandas/SQLite/可視化/MLそれぞれにテストの型がある。
  • テストは短く・自動で・毎回回す。小さく始めて継続が正義。

この記事から次に読むべきもの(内部リンク)

Git/Github
【保存版】Git/GitHub入門:バージョン管理・ブランチ戦略・レビュー・自動化を“実務の型”で最短習得

「分析やノートブックは作れるけど、壊れない運用の“型”がない…」 「final_v3_fix2_LAST.xlsx地獄から抜け出して、レビューと自動化まで一気通貫で回したい!」 この記事では、未経験〜 ...

SQLite
【保存版】SQLite×Pythonで作る“ローカルDWH”|ETL・集計・レポート自動化の最短手順

ローカルでゼロ構築、ファイル1つで完結、サーバ不要。 本記事はSQLite×Pythonで“毎日回る”ETL・集計・レポート自動化を最短で作るための完全ガイドです。データ設計→DB作成→ETL(取り込 ...

データレポート納品
【保存版】データレポート納品の型:要件定義→ETL→検証→可視化→Excel/PDF→引き継ぎまで、失注しないワークフロー完全版

“いい分析”より“伝わる納品”。副業や実務で評価されるのは、意思決定に効く1枚と再現できるパッケージを期限通り出せること。 本記事は、未経験〜初学者が 週10時間×2〜3週 で、要件定義 → データ受 ...

機械学習
【保存版】scikit-learn基礎:回帰・分類・前処理・パイプライン・交差検証を“実務の型”で習得

機械学習で迷子になる最大の理由は、前処理→学習→評価→改善の順番が曖昧なまま個々のアルゴリズムに飛びつくこと。 本記事は、未経験〜初学者が週10時間×2〜3週で到達できるscikit-learnの最短 ...

モデル評価
【保存版】モデル評価:指標の選び方・交差検証・閾値最適化・ビジネス接続を“実務の型”で解説

精度が上がらない原因の多くは「評価設計の誤り」にあります。 評価は「何点取れたか」を競うものではなく、意思決定に耐えうるか を検証する営みです。本記事は、回帰/分類/ランキングの 指標選定 → 交差検 ...

最近のコメント

    • この記事を書いた人
    • 最新記事

    ふみと

    このブログでは、データサイエンティストとして市場価値を上げる方法を独自にまとめて発信しています。

    【プロフィール】
    ・大手企業データサイエンティスト/マーケティングサイエンティスト(10年、年収900万円台)/案件100件以上
    ・資格:JDLA E資格(日本ディープラーニング協会主催)/JDLA Community(CDLE会員)/Advanced Marketer/ビジネス統計スペシャリスト/統計検定2級/TOEIC 805
    ・スキル:Python/Tableau/SQL/機械学習/Deep Learning/RPA

    -Python基礎