【Python】fixtureとtmp_pathで実データを壊さずファイル処理をテストする

PythonのTopに戻る

本番データを使わず、テストごとに入力を作る

ファイル処理のテストで手元の実データを直接使うと、出力の上書きやテスト間の汚染が心配になる。pytestのtmp_pathを使えば、テストごとに専用のPathを受け取り、その中だけで入力と出力を用意できる。共通の準備はfixtureへまとめ、開いた資源があるならyieldの後で閉じる。

以下は整数が一行ずつ入った小さなファイルを読み、各値を2倍にして別ファイルへ書く例である。出力前に全入力を検証するため、この例は小さなファイルを対象とする。巨大ファイルの定数メモリ処理ではない。読み取り元を変えないこと、不正入力で出力ができないこと、資源が閉じられることをテストする。

入力用fixtureとファイルを閉じるfixtureを分ける

file_tools.py

def write_doubles(incoming, destination):
    values = [int(line) for line in incoming]
    with destination.open("x", encoding="utf-8", newline="\n") as outgoing:
        for value in values:
            outgoing.write(f"{value * 2}\n")
    return len(values)

test_files.py

import pytest
from file_tools import write_doubles


@pytest.fixture
def input_file(tmp_path):
    path = tmp_path / "input.txt"
    path.write_text("1\n2\n3\n", encoding="utf-8")
    return path


@pytest.fixture
def input_stream(input_file):
    stream = input_file.open(encoding="utf-8")
    try:
        yield stream
    finally:
        stream.close()
        assert stream.closed


def test_output(input_file, input_stream, tmp_path):
    output = tmp_path / "output.txt"
    assert write_doubles(input_stream, output) == 3
    assert output.read_text(encoding="utf-8") == "2\n4\n6\n"
    assert input_file.read_text(encoding="utf-8") == "1\n2\n3\n"


def test_invalid_input(input_file, tmp_path):
    input_file.write_text("1\nbad\n", encoding="utf-8")
    output = tmp_path / "output.txt"
    with input_file.open(encoding="utf-8") as stream:
        with pytest.raises(ValueError):
            write_doubles(stream, output)
    assert stream.closed
    assert not output.exists()

二つのファイルを同じフォルダーへ置き、python -m pytest -q test_files.pyで実行する。正常系と異常系の2件が成功し、input_streamの後始末も実行された。次は集計部分の抜粋で、時間表示を省略している。

実行結果(抜粋)

2 passed

引数名から必要な準備が選ばれる

テスト関数の引数にinput_fileを書くと、pytestが同名のfixtureを実行して返り値を渡す。fixture自身もtmp_pathを引数として受け取れるので、「専用領域を用意する」「入力を書く」「ファイルを開く」という依存関係を自然に表せる。テストが使わないinput_streamは、そのテストでは起動されない。

fixtureの既定のscopeはfunctionで、テスト関数ごとに準備される。test_invalid_inputが入力ファイルを書き換えても、test_outputと同じ実ファイルを共有しない。この分離により、どちらを先に実行しても他方の結果に依存しにくくなる。関数名の順番でテストを成立させる設計は避けたい。

yieldの前が準備、後が後始末になる。例ではtry/finallyにより、テストが途中で失敗しても開いたstreamを閉じる。後始末自体のassertが失敗すれば、テスト本体が成功していても問題として報告される。資源の取得途中で例外が起きる場合にも備え、取得した資源に対応する後始末の範囲を明確にしておく。

tmp_pathは即時削除の契約ではない

tmp_pathはテスト用の隔離されたディレクトリを用意するが、各テスト終了時に必ずその場で消えるという意味ではない。pytestは調査のため過去の一時領域を保持する方針を持ち、保持数などは設定でも変わる。中身をすぐ消すTemporaryDirectoryと同じ寿命だと決めつけない。機密データをfixtureへ使わない理由の一つでもある。

記事のコードは実データをコピーせず、必要な3行をテスト内で生成する。実ファイルの巨大なコピーをfixtureへ持ち込むより、どの条件を確かめているかが明確になる。実データでしか再現しない問題がある場合も、可能な範囲で不要な行や個人情報を除き、小さな再現入力へ縮める。

何を検証すればよいか

正常系は戻り値だけでなく出力内容と入力が変わっていないことを確認する。異常系はValueErrorだけでなく、出力ファイルがまだ存在しないことも確認する。今回は全行を変換してから出力を開くので成立する条件であり、逐次書き込み方式なら途中ファイルの扱いを別に設計する必要がある。

出力にxモードを使っているため、既存の同名ファイルへ上書きしない。実運用で上書きを認める場合でも、テストでは前回の出力を再利用せず、新しいtmp_pathへ毎回作る方が状態を読みやすい。テスト用の一時領域に隔離することと、製品側の保存手順の安全性は別々に確認する。

実行環境と関連情報

掲載コードはLinux上のCPython 3.12.14で実行した。pytestを使う例はpytest 9.1.1で確認している。OS固有のファイル操作や対話環境の違いは、本文に記した条件に従って扱う。

関連:作業用ファイルを残さない:TemporaryDirectoryで一時領域を管理する / pytestで正常系と異常系の自動テストを始める / monkeypatchで環境変数や外部依存を切り替えてテストする

PythonのTopに戻る