pytest の基礎から Docker で実行、実践テストケースまで解説

# はじめに
「とりあえず動いたけど将来の改修で壊れない保証がない」
——そんな不安を解消する最短ルートが pytest です。

本記事では 代表的な機能を Docker 上でそのまま動くコード＋実行結果付き でまとめました。

# pytestの基本構成
```
.
├── app/
│   └── calc.py          # テスト対象
├── tests/
│   └── test_calc.py     # すべてのケースをここで実装
├── pytest.ini           # マーカー定義など
├── Dockerfile & docker-compose.yml
└── (任意) pyproject.toml / setup.py
```


```python:app/calc.py
import math

def divide(x, y):
    return x / y

def save_to_file(path, content):
    with open(path, "w") as f:
        f.write(content)

def sin_deg(degree):
    return math.sin(math.radians(degree))

def buggy_feature(): 
    return 1 / 0

def notify_user(user, send_func):
    body = f"Hi {user}, welcome!"
    send_func(user, body)
```

テスト実行（Docker）
```python:app/calc.py
docker compose up --build          # or: docker compose run --rm app
```

上記ソースを利用して、実際の代表的なケースを解説します。

### 通常のテスト (アサーション)
#### 概要
assert を使うだけで、結果を検証できるのが pytest の最大の特徴です。

#### コード
```python:tests/test_calc.py
def test_divide():
    assert calc.divide(10, 2) == 5
    assert calc.divide(10, 5) == 2
```
#### 出力
```
pytest  | .                                                                        [100%]
pytest  | ================================ tests coverage ================================
pytest  | _______________ coverage: platform linux, python 3.11.13-final-0 _______________
pytest  | 
pytest  | Name          Stmts   Miss  Cover   Missing
pytest  | -------------------------------------------
pytest  | app/calc.py       4      0   100%
pytest  | -------------------------------------------
pytest  | TOTAL             4      0   100%
pytest  | 1 passed in 0.82s
```

#### 解説
- assert は Python 標準構文。pytest はそれを自動検出し、失敗時に 並べられた値の差 を表示します
- 少ないコードで、検証を文脈として読めるテスト を書けます

- 値の内容は以下の通り。

| 列項 | 意味 |
|-----------|------------|
| Stmts       | 課題コード内の Python 構文の行数        |
| Miss     | テストで一度も通っていない行      |
| Cover       | (実行済み行 / 全行) の割合        |
| Missing         | 実行されなかった行番号 (複数の場合は列挙)          |

### . fixture で共通化
#### 概要
同じ前提データを何度も書きたくないときに、@pytest.fixtureで一括構築することで、重複するセットアップ が解決します


```python:tests/test_calc.py
@pytest.fixture(params=[
    (10, 2, 5),
    (20, 4, 5),
    (-9, -3, 3),
])
def division_case(request):
    """(被除数, 除数, 期待値) を返す"""
    return request.param

def test_divide_fixture(division_case):
    x, y, expected = division_case
    assert calc.divide(x, y) == expected
```

#### 解説
- @pytest.fixture(params=...)で 複数のテストデータ を fixture から渡せます
- division_case は、test 関数の実行ごとに1 セットずつ渡されます


### parametrize で同型テストを一行に
#### 概要の説明
「同じ関数ロジックを異なる入力で繰り返し検証したい」ときに最も力を発揮するのが @pytest.mark.parametrize です。
毎回テスト関数をコピーせずに、引数パターンを並べるだけでケース数を量産できます。


```python:tests/test_calc.py
import pytest
from app import calc

@pytest.mark.parametrize(
    ("x", "y", "expected"),
    [
        (10, 2, 5),
        (20, 4, 5),
        (9, 3, 3),
        (-8, -2, 4),
        (0, 5, 0)
    ]
)
def test_divide_parametrize(x, y, expected):
    assert calc.divide(x, y) == expected
```
#### 解説
- 各テーブルは、一つの test 関数を同じ式で何度も実行します
- 失敗した場合は「[90-3-3]で失敗」などと出力されるので検知が容易

#### fixture との違い
個人的に最初試していた時に、fixtureとの違いが不明確だったので、まとめてみました。
| 比較項目     | parametrize               | fixture              |
| -------- | ------------------------- | -------------------- |
| 関数呼び出し回数 | ケース数ぶん繰り返す（= **ループ自動展開**） | 関数自体は1回だけ            |
| 結果の記録    | 各ケースが **別々のテスト結果として出る**   | テスト関数単位で1回ぶんの結果      |
| 適用場面     | **「入力を網羅する」ことが目的**のとき     | **共通の前提状態や環境が必要**なとき |


### skip / xfail で予期された失敗を可視化
#### 概要
未対応 OS や既知バグ を そのままスキップすることができます。

```python:tests/test_calc.py
import pytest
from app import calc
import sys

@pytest.mark.skipif(sys.platform == "win32", reason="Windowsでは実行不可")
def test_not_on_windows():
    assert True  # ここではOKとする

@pytest.mark.xfail(reason="buggy_featureはまだ修正されていない")
def test_buggy_feature():
    calc.buggy_feature()
```

```python:app/calc.py
import math
def buggy_feature():
    #既知のバグがある仮の機能
    return 1 / 0
```
出力結果
```
pytest  | .x                                                                       [100%]
pytest  | ================================ tests coverage ================================
pytest  | _______________ coverage: platform linux, python 3.11.13-final-0 _______________
pytest  | 
pytest  | Name          Stmts   Miss  Cover   Missing
pytest  | -------------------------------------------
pytest  | app/calc.py       5      0   100%
pytest  | -------------------------------------------
pytest  | TOTAL             5      0   100%
pytest  | 1 passed, 1 xfailed in 1.15s
pytest exited with code 0
```

#### 詳細に解説
- skip は その環境で実行したくないテストをとばす
- xfail は 失敗することが予期されるテストで、修正されたら XPASS として告知される

### 設定ファイルによるカスタム
#### 概要
pytest.ini で CLI オプションやマークを標準化 できます。
プロジェクト全体にルールやマークを適用でき、コマンドの簡略化や警告の抑制ができます。

```python:app/calc.py
@pytest.mark.slow
def test_slow_calc():
    import time
    time.sleep(1.1)  # 1秒超え
    assert True
```

```ini:app/pytest.ini
[pytest]
addopts    = -q -ra
markers    =
    slow: mark tests as slow (>1s)
```

#### 解説
- addopts でコマンドのオプションを 毎回書かなくて済む
- markers を記述すると、未登録マークの警告を消せる

### モック・スタブ
#### 概要
実際のメール送信や DB 書き込みなどを行わずに、関数の呼び出しと引数だけを検証する方法です。
外部依存（メール送信・DB など）を 切り離してロジックだけ検証。


```python:test/test_calc.py
def test_notify_user(mocker):
    mock_sender = mocker.Mock()
    calc.notify_user("alice", send_func=mock_sender)
    mock_sender.assert_called_once_with("alice", "Hi alice, welcome!")
```

```python:app/calc.py
def notify_user(user, send_func):
    #ユーザーに通知を送る（外部依存あり）
    body = f"Hi {user}, welcome!"
    send_func(user, body)
```
#### 詳細に解説
- mocker は pytest-mock パッケージの便利な fixture です
- 本物の通信や I/O を使わず、「その関数が正しく呼ばれたか」だけを確認します
- 安全で速く、CIでも安心して回せます



## 参考文献
- https://docs.pytest.org/en/latest/how-to/assert.html