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

$ pytest --version
pytest 9.1.1

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

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

インストールしたい(pytest

  • pipでインストール

$ python3 -m venv .venv
$ source .venv/bin/activate
$ pip install pytest
$ pip install pytest-mock  # モックを使ったユニットテスト
$ pip install pytest-cov   # カバレッジの計測
$ pip install pytest-html  # テスト結果をHTMLファイルに出力
  • pipxでインストール

$ pipx install pytest
$ pipx install pytest-mock
$ pipx install pytest-cov
$ pipx install pytest-html
  • uv toolでインストール

$ uv tool install pytest
$ uv tool install pytest-mock
$ uv tool install pytest-cov
$ uv tool install pytest-html
  • poetryでインストール

$ poetry add pytest --group test
$ poetry add pytest-mock --group test
$ poetry add pytest-cov --group test
$ poetry add pytest-html --group test
  • uvでインストール

$ uv add pytest --group test
$ uv add pytest-mock --group test
$ uv add pytest-cov --group test
$ uv add pytest-html --group test

pipxuv toolを使ってシステム(の仮想環境)にインストールできます。 poetryuvで管理している場合は--group testに分類するとよいと思います。

テスト結果を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.ini_options]
 2minversion = "8.0"
 3testpaths = ["tests"]
 4markers = [
 5    "local: tests that should only run locally",
 6    "slow: tests that take more than 1 second to run"
 7]
 8addopts = [
 9    "-m", "not local",
10]
11filterwarnings = [
12    "error",
13    "ignore::DeprecationWarning",
14]

minversionで、このプロジェクトが要求するpytestの最低バージョンを指定できます。 testpathsで、引数なしでpytestを実行したときに検索するディレクトリを指定できます。 filterwarningsで、警告(Warning)の扱いを設定できます。 上記の例では、想定外の警告はerrorとして失敗させつつ、DeprecationWarningだけは無視しています。

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

テスト名で絞り込みたい(-k

$ pytest -k "download"
$ pytest -k "download and not slow"

-k "式"で、テスト関数名やクラス名の一部にマッチするテストだけを実行できます。 andornotを組み合わせた式も使えます。 -m(マーカー)と違い、事前にマークしておく必要がないため、手早く絞り込みたいときに便利です。

失敗したら止めたい(-x / --maxfail

$ pytest -x
$ pytest --maxfail=3

-x--exitfirst)で、最初に失敗したテストの時点で実行を打ち切れます。 --maxfail=数で、指定した数だけ失敗した時点で打ち切れます。 テストが大量にある場合、全部の結果を待たずに早めに気づけるので便利です。

前回失敗したテストだけ実行したい(--lf / --ff

# 前回失敗したテストだけ再実行
$ pytest --lf

# 前回失敗したテストを先に、残りもすべて実行
$ pytest --ff

--lf--last-failed)で、前回失敗したテストだけを再実行できます。 --ff--failed-first)で、前回失敗したテストを先頭に並べつつ、すべてのテストを実行できます。 修正がうまくいったかをすぐに確認したいときに便利です。

並列実行したい(pytest-xdist

$ uv add pytest-xdist --group test
$ pytest -n auto
$ pytest -n 4

pytest-xdistをインストールすると、-nオプションでテストを並列実行できるようになります。 -n autoでCPUコア数に応じて自動的に並列数を決定します。 テストの数が増えてきて実行時間が気になってきたら、導入を検討するとよいです。

詳細表示したい(--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@patch("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.raises(例外名):
5        関数(...)  # <- 例外を発生させる

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

繰り返しテストしたい(@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@pytest.fixture
2def sample_data():
3    return [1, 2, 3]
4
5def test_data_length(sample_data):
6    assert len(sample_data) == 3

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