Pythonのテストコードは個人開発でも必要だった。pytestで「過去に壊れたデータ」を全部テストする

目次

はじめに

テストコードの存在も、何をしているのかも知っていました。それでも私は、これまでほとんど書いたことがありませんでした。理由は単純です。

趣味で一人でプログラムを書いているだけなら、テストコードまで書く必要はないだろう。

そう思っていたからです。仕事で複数人が大きなシステムを開発するなら必要なのは分かります。しかし自分で書いたPythonプログラムなら、自分で実行して確認すればいい。ずっとそう考えていました。ところが最近、

「あ、こういうときにテストコードを使えばよかったのか」

と腑に落ちる出来事がありました。

私のプログラムは入力データが変わるたびに壊れていた

私はPythonでデータの取得や加工をするプログラムをよく作ります。そこで困るのが入力データです。外部から取得するデータは、いつもきれいな形をしているとは限りません。例えば、

  • あるはずの値が空欄になっている
  • 項目が増えている
  • 項目の順番が変わっている
  • 想定外の文字が入っている
  • 古いデータだけ形式が違う

といったことがあります。最初に想定したデータなら正常に動くのに、ある日突然変なデータが入ってきてエラーになる。そこで原因を調べてコードを修正します。

エラー発生
↓
入力データを調査
↓
コードを修正
↓
動いた!

ここまではいいのです。問題はその後でした。

修正したら、昔動いていたデータが読めなくなる

新しいデータに対応するためコードを修正すると、以前は正常に読めていたデータで別の問題が発生することがあります。すると、また修正です。

データAでエラー
↓
修正
↓
データBでエラー
↓
修正
↓
今度はデータAがおかしくなる

だんだんモグラ叩きのようになってきます。そして、あるとき気付きました。

今まで問題を起こした入力データを全部保存して、コードを変更するたびに全部読み込ませればいいのでは?

これこそテストコードの使い道でした。

「汚いデータ」をテストデータとして保存する

これまでは、問題のある入力データに遭遇すると、

  1. 原因を調べる
  2. プログラムを修正する
  3. 正常に動くことを確認する
  4. 終了

としていました。これからは一つ追加します。

  1. 原因を調べる
  2. プログラムを修正する
  3. 正常に動くことを確認する
  4. 問題を起こしたデータをテスト用に保存する
  5. 以後、コードを変更するたびに自動で読み込ませる

これだけです。例えば、こんなフォルダを作ります。

project/
├── main.py
└── tests/
    ├── test_input.py
    └── data/
        ├── normal.csv
        ├── missing_value.csv
        ├── blank_column.csv
        ├── strange_format.csv
        └── old_format.csv

tests/dataには、過去に問題を起こしたCSVを保存していきます。

つまり、不具合を起こしたデータそのものを資産として残すという考え方です。

pytestを使って全部のCSVを読み込んでみる

Pythonには標準ライブラリとしてunittestがありますが、今回はpytestを使います。
個人的な小さなプログラムでは記述が簡潔で、今回の用途にも向いています。インストールします。

pip install pytest

例えば、実際のプログラムにCSVを読み込む処理があるとします。

from pathlib import Path
import pandas as pd

def load_input_file(file_path: Path) -> pd.DataFrame:
    """入力CSVを読み込む。"""
    return pd.read_csv(file_path)

ここでは説明のため単純にしています。実際にはこの後に、

  • 列名の補正
  • 欠損値への対応
  • データ型変換
  • 不要データの除外

など、各プログラム固有の処理が入ります。

テストコード

tests/test_input.pyです。

from pathlib import Path
import pytest
from main import load_input_file


TEST_DATA_DIR = Path(__file__).parent / "data"
TEST_FILES = sorted(TEST_DATA_DIR.glob("*.csv"))


@pytest.mark.parametrize(
    "file_path",
    TEST_FILES,
    ids=lambda path: path.name,
)
def test_input_file_can_be_loaded(file_path: Path) -> None:
    """過去の入力データを例外なく処理できることを確認する。"""
    load_input_file(file_path)

私が今回欲しかったテストなら、これで十分です。

assertすら書いていないけれど、これでいい

テストコードというと、

assert result == 100

のように結果を細かく確認するイメージがありました。もちろん、計算結果の正しさが重要なプログラムなら、そういったテストは必要です。しかし今回確認したいのは過去に読めていた入力データを、現在のコードでもエラーなく処理できるかです。そのため、

load_input_file(file_path)

を実行するだけにしています。例外が発生しなければ成功。例外が発生すればpytestが失敗として報告してくれます。今回の目的なら、これで十分です。

pytestを実行する

プロジェクトのルートフォルダで実行します。

pytest

例えば5個のテストデータがすべて正常なら、

collected 5 items

tests/test_input.py .....                       [100%]

5 passed

となります。少し詳しく確認したい場合は、

pytest -v

とします。今回のコードではファイル名をテストIDとして表示するようにしているため、

test_input_file_can_be_loaded[normal.csv] PASSED
test_input_file_can_be_loaded[missing_value.csv] PASSED
test_input_file_can_be_loaded[blank_column.csv] PASSED
test_input_file_can_be_loaded[strange_format.csv] PASSED
test_input_file_can_be_loaded[old_format.csv] PASSED

のようになります。どのデータが正常だったのか分かりやすくなります。

新しいエラーが出たらテストデータを1個増やす

この仕組みで気に入っているのがここです。例えば、新しいCSVを読み込んだところエラーが発生したとします。まずプログラムを修正します。そして問題になったCSVを、

tests/data/

へコピーします。例えば、

tests/data/2026_08_missing_header.csv

として保存します。これだけです。テストコードは変更しません。今回のコードでは、

TEST_FILES = sorted(TEST_DATA_DIR.glob("*.csv"))

によってtests/dataにあるCSVをすべて自動的に取得しているためです。次から、

pytest

を実行すると、新しく追加したCSVも自動的にテストされます。つまり、エラーに遭遇するたびにテストが1個増えていくことになります。

実際には「回帰テスト」をしていた

この考え方を調べると、これは「回帰テスト(Regression Testing)」という考え方に近いものです。プログラムを変更したことで、これまで正常だった機能が壊れていないかを確認するテストです。
私の場合は、「過去に読み込めた入力データが、現在のコードでも読み込めること」を確認しています。
テストコードというと、関数一つひとつについて、

assert ...

を大量に書くものだと思っていました。もちろん、それも重要なテストです。しかしテストにはもっといろいろな使い方があります。私の場合、過去に実際に問題を起こしたデータを再投入するという方法が、一番テストの必要性を実感できました。

テストデータは不具合の履歴でもある

tests/dataを見れば、

normal.csv
missing_value.csv
blank_column.csv
strange_format.csv
old_format.csv

と過去に遭遇した問題が並びます。これは単なるテストデータではなく、このプログラムが過去にどんな入力で苦しんできたのかという履歴でもあります。そして新しい問題が発生するたび、

new_problem.csv

が追加されていきます。普通なら嫌なだけの不具合ですが、この運用なら少し意味が変わります。一度遭遇した問題をテストデータとして残せば、同じ問題を二度と発生させないための材料になるからです。

個人開発だからこそテストコードが役立つ

私は以前、「テストコードは複数人で開発するときに必要なもの」という印象を持っていました。他の人がコードを変更しても壊れないようにするものだと思っていたわけです。しかし趣味の個人開発でも、十分意味がありました。
なぜなら、数か月後の自分は、今のコードを書いた理由をほとんど覚えていないからです。

コードを見て、「なんでこんな特殊な処理を入れたんだ?」と思って消したら、昔遭遇した特殊な入力データへの対策だった。個人開発でも十分あり得ます。そう考えると、テストコードは、他人からコードを守るだけではなく、未来の自分からコードを守るものとも言えそうです。

最初から完璧なテストを書く必要はない

テストについて調べると、

  • 単体テスト
  • 結合テスト
  • モック
  • Fixture
  • カバレッジ
  • CI
  • TDD

など、いろいろな言葉が出てきます。これを全部やろうとすると、趣味のプログラムでは少し面倒に感じてしまいます。私はまず、そこまでやる必要はないと思っています。今回始めるのは、

一度エラーになった入力データを保存する
↓
pytestで全部読み込ませる
↓
エラーが出ないことだけ確認する

これだけです。これなら導入の負担もほとんどありません。それでもコードを修正するたび、

pytest

と入力するだけで過去の問題を一通り確認できます。十分便利です。

まとめ

私はこれまで、テストコードについて、「重要なのは分かるけれど、一人で趣味のプログラムを書いている自分にはあまり必要ない」と思っていました。
しかし、入力データの形式変更に何度も苦しんだことで、ようやく自分にとって分かりやすい使い道を見つけました。それが、過去に問題を起こした入力データを全部テストデータとして残すという方法です。新しい問題に遭遇したら、

コードを修正する
+
問題になったデータをtests/dataへ追加する

これを繰り返します。すると、不具合に遭遇するたびにテストデータが増え、プログラムが少しずつ壊れにくくなっていきます。私のように、「pytestは知っているけれど、自分には使い道がない」と思っている方がいたら、まずは過去に自分を困らせた入力ファイルを一つ保存するところから始めてみるとよいかもしれません。

一度自分を困らせたデータは、捨てるのではなく、次に困らないためのテストデータにする。

これが、私がようやく実感できたテストコードの使い方でした。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

コメント

コメントする

目次