🗃️

pytestで一時ファイルが必要なテストを行う

2023/07/08に公開

tempfile × pytestを組み合わせる

pytestでテストを書く際、以下の状況に遭遇したことはありますか。

  • 一時的にファイルを作成したい
  • テスト終了後にファイルを削除したい

代表は外部ファイルを読み込んで処理を行うコードだと思います。
そんな時はtempfileを使うといい感じにテストすることができます。

ディレクトリ構成

.
├── csv_read.py
└── tests
    ├── __init__.py
    └── test_csv_read.py
  • csv_read.py 今回のテスト対象
  • test_csv_read.py テストコードモジュール

使用ライブラリ

以下を使用しました。

  • pytest
  • pydantic(テスト対象のクラスに使用)

テストを行うクラス

今回は、CSVを読み込む事を想定したクラスを準備しました。
この内、インスタンス化する際のバリデーションチェックをテストします。

csv_read.py
from pathlib import Path

from pydantic import BaseModel, Field, validator


class CSVReader(BaseModel):
    """CSV読み込みクラス"""

    path: Path = Field(..., description="CSVパス")

    @validator("path")
    def validate_path(cls, path: Path) -> Path:
        """パス検査

        Args:
            path (Path): CSVパス

        Raises:
            ValueError: 検証失敗

        Returns:
            Path: CSVパス
        """
        # ファイル存在検証
        if not path.exists():
            raise ValueError(f"ファイルが存在しません:{path}")

        # 拡張子検証
        if path.suffix != ".csv":
            raise ValueError(f"拡張子に誤りがあります:{path.name}")

        return path

pydanticvalidatorはデコレートにすることで
インスタンス化前にバリデーションチェックを行うことができます。
https://docs.pydantic.dev/1.10/usage/validators/

上記では2点チェックを行っています。

  • ファイルが存在しているか
  • 拡張子が.csv

これらをテストすることにします。

テスト実装

先程のクラスをtempfileでテストします。

tempfileについて

実装前にtempfileについて簡単に解説します。
tempfileは読んで字の如く、一時ファイルに関する操作を行う標準パッケージです。
詳しい使い方はこちらの記事が参考になります。
https://zenn.dev/alivelimb/articles/20220506-tempfile

NamedTemporaryFileを使うと、一時ファイルが作成されたパスを取得できます。
suffixパラメータに拡張子を指定すると、任意の拡張子ファイルを作成できます。

import tempfile

# /tmp/{tempfile_name}.csv
print(tempfile.NamedTemporaryFile(suffix=".csv", delete=True).name)

テストコード

では、テストを実装します。

  • ファイルが存在しない
  • 拡張子が誤っている
  • 検証通過
    の3パターンを網羅しました。
test_csv_read.py
import tempfile
from pathlib import Path
from typing import Any, Generator

import pytest
from pydantic import ValidationError

from csv_read import CSVReader


@pytest.fixture(scope="function")
def json_file() -> Generator[Path, Any, None]:
    """json一時ファイル作成

    Yields:
        Generator[Path, Any, None]: json一時ファイルパス
    """
    path = Path(tempfile.NamedTemporaryFile(suffix=".json", delete=False).name)
    yield path
    # テスト終了後にファイルを削除
    path.unlink()


@pytest.fixture(scope="function")
def csv_file() -> Generator[Path, Any, None]:
    """csv一時ファイル作成

    Yields:
        Generator[Path, Any, None]: csv一時ファイルパス
    """
    path = Path(tempfile.NamedTemporaryFile(suffix=".csv", delete=False).name)
    yield path
    # テスト終了後にファイルを削除
    path.unlink()


def test_not_exists_path() -> None:
    """バリデーションチェックエラー
    ファイルが存在しない
    """
    path = Path(tempfile.NamedTemporaryFile(suffix=".csv", delete=True).name)
    with pytest.raises(ValidationError) as e:
        _ = CSVReader(path=path)

    msg = e.value.errors()[0]["msg"]
    assert msg == f"ファイルが存在しません:{path}"


def test_not_suffix_csv(json_file: Path) -> None:
    """バリデーションエラー
    拡張子不正

    Args:
        json_file (Path): json一時ファイルパス
    """
    with pytest.raises(ValidationError) as e:
        _ = CSVReader(path=json_file)

    msg = e.value.errors()[0]["msg"]
    assert msg == f"拡張子に誤りがあります:{json_file.name}"


def test_validate_ok(csv_file: Path) -> None:
    """バリデーションチェック通過

    Args:
        csv_file (Path): csv一時ファイルパス
    """
    reader = CSVReader(path=csv_file)
    assert reader.path == csv_file

ポイントをいくつかご紹介します。

一時ファイルの作成と削除

pytest.fixtureを使って

  • 作成を行う前処理
  • 削除を行う後処理

を実現しています。

test_csv_read.py
@pytest.fixture(scope="function")
def json_file() -> Generator[Path, Any, None]:
    """json一時ファイル作成

    Yields:
        Generator[Path, Any, None]: json一時ファイルパス
    """
    path = Path(tempfile.NamedTemporaryFile(suffix=".json", delete=False).name)
    # テスト関数にパスを返却
    yield path
    # テスト終了後にファイルを削除
    path.unlink()

NamedTemporaryFile().nameでパスを取得した後、pathlib.Pathでパスのインスタンス化を行っています。
delete=Falseパラメータをつけることで、一時ファイルを削除せずに保持することが可能です。

pathlibはパス関係の処理を盛り込んでいる標準パッケージです。
パスをオブジェクト感覚で操作出来るため、パスの処理をしたいなら是非使用してみてください。
https://docs.python.org/ja/3/library/pathlib.html

yieldで本処理にパスを返却します。
テスト終了後はpath.unlink()で自身のファイルを削除して、後処理を完了させます。

バリデーションエラーを拾う

pytest.raises()で拾います。

test_csv_read.py
def test_not_exists_path() -> None:
    """バリデーションチェックエラー
    ファイルが存在しない
    """
    path = Path(tempfile.NamedTemporaryFile(suffix=".csv", delete=True).name)
    with pytest.raises(ValidationError) as e:
        _ = CSVReader(path=path)

    msg = e.value.errors()[0]["msg"]
    assert msg == f"ファイルが存在しません:{path}"

pydantic.ValidationErrorでエラー受けし、メッセージの確認をしています。

まとめ

pytestを使って外部ファイルが必要なテストについて紹介しました。

  • tempfileで一時ファイルを作成する
  • pytest.fixtureで前処理と後処理を実現する

これらを活用すれば、一時的なファイルの作成~削除までをこなすことができます。
是非試してみてください。

追記

執筆中、pytestにtmp_pathという機能があることを知りました。
https://docs.pytest.org/en/7.1.x/how-to/tmp_path.html
これを活用すれば、もう少し楽に書けるかもしれません。
使えそうであれば記事に追記しておきます。

参考文献

Discussion