ユニットテストしたい(pytest

$ pytest --version
pytest 8.3.3

$ pytest
$ pytest --verbose
$ pytest ファイル名

pytestはPythonのユニットテスト群をまとめて実行できるツールです。 プロジェクトのルートディレクトリで実行すればテストをまとめて実行できます。 --verboseオプションで、それぞれのテストごとに結果を表示できます。

インストールしたい(pytest

  • pipxでインストール

$ pipx install pytest
  • poetryでインストール

$ poetry add pytest --group=test
$ poetry add pytest-mock --group=test  # モックを使ったユニットテスト
$ poetry add pytest-cov --group=test   # カバレッジの計測
$ poetry add pytest-html --group=test  # テスト結果をHTMLファイルに出力

poetryで管理している場合は--group=testに分類するとよいと思います。

  • uvでインストール

$ uv tool install pytest
$ uv tool install pytest-mock
$ uv tool install pytest-cov

pipxuvを使ってシステム(の仮想環境)にインストールできます。

テスト結果をHTMLファイルに出力する場合はpytest-htmlのが必要です。 unittest.mockを使う場合は、 pytest-mockもインストールしておくとよいです。 カバレッジを計測した場合はpytest-covが必要です。

マーカーしたい(-m

$ pytest テストのパス -m "xxx"

$ pytest テストのパス -m "slow"
$ pytest テストのパス -m "local"

-m "マーカー名"@pytest.mark.xxxのデコレーターでマークしたテストだけを実行できます。

$ pytest --markers

// @pytest.mark.filterwarnings(warning)
// @pytest.mark.skip(reason=None)
// @pytest.mark.skipif(condition, ..., *, reason=...)
// @pytest.mark.xfail(condition, ..., *, reason=..., run=True, raises=None, strict=xfail_strict)
// @pytest.mark.parametrize(argnames, argvalues)
// @pytest.mark.usefixtures(fixturename1, fixturename2, ...)

--markersオプションでマーカー名を確認できます。 いくつかのマーカーはpytestにプリセットがあります。 プロジェクト固有のマーカーはpyproject.tomlで定義できます。

1[tool.pytest.init_options]
2markers = [
3    "local: tests that should only run locally",
4    "slow: tests that take more than 1 second to run"
5]
6addopts = [
7    "-m", "not local",
8]

マーカー名にnotをつけることで除外できます。 このサンプルでは、時間のかかるテストなどに@pytest.mark.localとマークし、デフォルトでスキップするようにしています。

詳細表示したい(--verbose

$ pytest テストのパス --verbose

--verboseオプションで、ファイル内のテスト関数を表示できます。

トレースバックを表示したい(--tb

$ pytest テストのパス --tb=short    # 簡潔
$ pytest テストのパス --tb=long     # 詳細
$ pytest テストのパス --tb=none     # 非表示

--tbオプションで、トレースバックの表示形式を変更できます。

実行時間を確認したい(--durations

$ pytest テストのパス --verbose --durations=0
$ pytest テストのパス --verbose --durations=10  # TOP10件

--durationsオプションで、各テストの実行時間を表示できます。

テスト用ディレクトリ(tests

tests/
├── conftest.py
├── __init__.py
├── test_モジュール1.py
├── test_モジュール2.py

ユニットテスト用のファイルは、testsディレクトリの中に作成します。 ファイル名の先頭は必ずtest_にする必要があります。

conftest.pyはPyTestを実行するときに読み込まれる特殊なファイルです。 どのテストでも利用するデータセットなど、再利用可能なオブジェクトは このファイルにfixtureとして定義しておくとよいです。

オススメのテスト構造

僕がたどりついた勝手にオススメのディレクトリ構造を紹介します。

$ cd プロジェクト
$ tree
.
├── 自作パッケージ名
│   ├── __init__.py
│   ├── 自作モジュール1.py
│   ├── 自作モジュール2.py
├── tests
│   ├── conftest.py
│   ├── __init__.py
│   ├── unit/
│   │   ├── 自作モジュール名1/
│   │   │   ├── test_関数1.py    // Success / Failure / Edge
│   │   │   ├── test_関数2.py    // Success / Failure / Edge
│   │   ├── 自作モジュール2.py
│   │   │   ├── test_関数3.py
│   ├── integration/
│       ├── test_統合テスト1.py
├── poetry.toml
├── pyproject.toml

自作パッケージと同じ階層にtestsディレクトリを作成します。 その中にユニットテスト用(unit)と 統合テスト用(integration)を分けて作成します。

$ uv run pytest tests/unit -v
$ uv run pytest tests/integration -v

ディレクトリを分けることで、 ユニットテストだけ、統合テストだけを実行できます。

ヒント

このディレクトリ分類は、リファクター時にとても有用だと思います。 ユニットテストだけで実行できるので、(気持ち的に)安全にリファクターを進めることができます。

ユニットテストは、モジュールごとにディレクトリを作成し、 その中に関数ごとにテストファイル(test_関数名.py)を作成します。 テストには、成功、失敗、エッジケースに分類し、 必要なユニットテストを記述します。

ヒント

この分類はディレクトリの階層が深くなってしまうのがデメリットです。 ただし、テストファイルの肥大化を防ぎつつ、 網羅性を確保できるのが大きなメリットだと思います。

モックしたい

注釈

モック/パッチの作り方はまだわかっていないので、 ChatGPTに聞きながら書くことが多いです。

 1import pytest
 2from unittest.mock import patch
 3
 4@patch("subprocess.run")
 5def test_download(mock_subprocess_run):
 6
 7    """Test download method"""
 8
 9    # テスト用URL
10    url = TEST_SHARED_URL
11    sheet = Sheet(
12        url=url,
13        filename="output.csv")
14    sheet.download()
15
16    mock_subprocess_run.assert_called_with(
17        ["wget", "--quiet", "-O", "output.csv", sheet.export_url]
18    )

上のサンプルは、 sheet.downloadの中で、 subprocess.runを使って wgetを呼んでいる場合のテストです。

subprocess.runをモックすることで、wgetを実行せずにテストできるようにしています。 テスト関数の引数名はモック名にします。 この場合はmock_subprocess_runでアクセスできるようになります。

wgetを実行していないため、filename="output.csv"に設定したファイルは作成されません。 そのため、assert_called_withを使って、指定した引数で関数が呼ばれたかどうかで、動作確認しています。

ファイル書き込みをモックしたい(pathlib.Path.write_text

1def 関数名(引数):
2    p = Path("ファイル名")
3    p.write_text("ファイルの内容", encoding="utf-8")

pathlib.Path.write_textを使っている関数のユニットテストを作成したときのサンプルです。 関数名や引数名は適当に置き換えて読んでください。

 1from unittest.mock import patch
 2
 3@path("pathlib.Path.write_text")
 4def test_関数名(mock_write):
 5    # test strings
 6    text = "ファイル内容"
 7
 8    # run a function
 9    関数名(引数)
10
11    # assertion
12    # write_textが1回だけ呼ばれたことを確認
13    mock_write.assert_called_once_with(text, encoding="utf-8")

pathlib.Path.write_textをモックします。 write_textは内部でpathlib.Path.openを使っていますが、 mock_openは必要ありません。

注釈

open関数を使う場合はmock_openが必要です。

例外をテストしたい(pytest.raises

1import pytest
2
3def test_関数名():
4    with pytest.raise(例外名):
5        関数(...)  # <- 例外を発生させる

pytest.raiseで例外をテストできます。s

繰り返しテストしたい(@pytest.mark.parametrize

1@pytest.mark.parametrize(
2    "a, b, expected",
3    [ (1, 2, 3),
4      (3, 4, 5),]
5)
6def test_関数名(a, b, expected):
7    assert 関数名(a, b) == expected

@pytest.mark.parametrizeデコレータで、 異なる値で繰り返しテストできます。

テスト用の設定したい(@pytest.fixture

1@pytext.fixture
2def sample_data():
3    return [1, 2, 3]
4
5def test_data_length(sample_data):
6    assert len(sample_data) == 3

@pytest.fixtureでテスト用の設定値を作成できます。