# テスト関数をカスタマイズする

## アサーションヘルパー関数を作成する

### サンプルコード

以下のような Card dataclass が定義されている場合を考える。
id 属性はクラス同士で比較しないよう定義されており、id 属性以外の値がすべて一致していれば同一インスタンスとして検出される。

```python
from dataclasses import dataclass


@dataclass
class Card:
    summary: str = None
    owner: str = None
    state: str = "todo"
    id: int = field(default=None, compare=False)

    @classmethod
    def from_dict(cls, d):
        return Card(**d)
    def to_dict(self):
        return asdict(self)
```

id 属性が一致しない場合には異なるインスタンスとして検出できるようアサーションヘルパー関数を定義する。

```python:test_helper.py
from cards import Card
import pytest


def assert_identical(c1: Card, c2: Card):
    __tracebackhide__ = True # 失敗するテストのトレースバックを非表示
    assert c1 == c2
    if c1.id != c2.id:
        pytest.fail(f"id's don't match. {c1.id} != {c2.id}")


def test_identical():
    c1 = Card("foo", id=123)
    c2 = Card("foo", id=123)
    assert_identical(c1, c2)


def test_identical_fail():
    c1 = Card("foo", id=123)
    c2 = Card("foo", id=456)
    assert_identical(c1, c2)
```

> 実行結果

```bash
pypytest test_helper.py             2024-11-17 12:08:19
================= test session starts =================
platform darwin -- Python 3.12.6, pytest-8.3.3, pluggy-1.5.0
rootdir: /Users/junpei/A_Python/training_pytest/pytest_sample_code
configfile: pytest.ini
collected 2 items

test_helper.py .F                               [100%]

====================== FAILURES =======================
_________________ test_identical_fail _________________

    def test_identical_fail():
        c1 = Card("foo", id=123)
        c2 = Card("foo", id=456)
>       assert_identical(c1, c2)
E       Failed: id's don't match. 123 != 456

test_helper.py:21: Failed
=============== short test summary info ===============
FAILED test_helper.py::test_identical_fail - Failed: id's don't match. 123 != 456
============= 1 failed, 1 passed in 0.17s =============
```

意図した通り、アサーションヘルパー関数によって id 属性のみが異なる 2 つ Cards は別インスタンスであることを検出できるようになった。

## 想定される例外をテストする

テストが失敗することを前提として、意図した例外が発生することをテストする方法を示す。

### サンプルコード

```python:test_exceptions.py
import pytest
import cards


def test_no_path_raises():
    with pytest.raises(TypeError):
        cards.CardsDB()


def test_raises_with_info():
    match_regex = "missing 1 .* positional argument"
    with pytest.raises(TypeError, match=match_regex):
        cards.CardsDB()


def test_raises_with_info_alt():
    with pytest.raises(TypeError) as exc_info:
        cards.CardsDB()
    expected = "missing 1 required positional argument"
    assert expected in str(exc_info.value)

```

- 失敗するテスト結果の例外タイプが想定通りかテストする。（`test_no_path_raises`）
- 失敗するテスト結果の例外タイプと traceback に特定の文字列が表示されるかどうかを正規表現で検出してテストする。（`test_raises_with_info`）
- 失敗するテスト結果の例外タイプと traceback に特定の文字列が含まれるか検出してテストする。（`test_raises_with_info_alt`）

#### 実行結果

```text
================= test session starts =================
platform darwin -- Python 3.12.6, pytest-8.3.3, pluggy-1.5.0
rootdir: /Users/junpei/A_Python/training_pytest/pytest_sample_code
configfile: pytest.ini
collected 3 items

test_exceptions.py ...                          [100%]

================== 3 passed in 0.09s ==================
```

想定通りの例外を検出したことで、テスト成功として ... テスト完了していることが確認できる。

## テストコードの設計は Given/When/Then のワークフローで書く

### サンプルコード

```python:sample.py
from cards import Card


def test_to_dict():
    # GIVEN a Card object with known contents
    c1 = Card("something", "brian", "todo", 123)

    # WHEN we call to_dict() on the object
    c2 = c1.to_dict()

    # THEN the result will be a dictionary with known content
    c2_expected = {
        "summary": "something",
        "owner": "brian",
        "state": "todo",
        "id": 123,
    }
    assert c2 == c2_expected

```

- テスト実行における前提データとの準備（Given）
- データに対するアクション・処理（When）
- 結果や最終状態の期待値（Then）

の順序でテストコードを書くことで、読み手が理解しやすくなる。

## テストをクラスにまとめる

テストコードを複数のファイルに分けて、一つのディレクトリ内で管理する方法でも良いが
テストコードを 1 つのファイルにまとめて、テストクラスを定義することでテストコードを管理することもできる。

### サンプルコード

```python:test_classes.py
from cards import Card


def test_field_access():
    c = Card("something", "brian", "todo", 123)
    assert c.summary == "something"
    assert c.owner == "brian"
    assert c.state == "todo"
    assert c.id == 123


def test_defaults():
    c = Card()
    assert c.summary is None
    assert c.owner is None
    assert c.state == "todo"
    assert c.id is None


class TestEquality:
    def test_equality(self):
        c1 = Card("something", "brian", "todo", 123)
        c2 = Card("something", "brian", "todo", 123)
        assert c1 == c2
    def test_equality_with_diff_ids(self):
        c1 = Card("something", "brian", "todo", 123)
        c2 = Card("something", "brian", "todo", 4567)
        assert c1 == c2

    def test_inequality(self):
        c1 = Card("something", "brian", "todo", 123)
        c2 = Card("completely different", "okken", "done", 123)
        assert c1 != c2


def test_from_dict():
    c1 = Card("something", "brian", "todo", 123)
    c2_dict = {
        "summary": "something",
        "owner": "brian",
        "state": "todo",
        "id": 123,
    }
    c2 = Card.from_dict(c2_dict)
    assert c1 == c2


def test_to_dict():
    c1 = Card("something", "brian", "todo", 123)
    c2 = c1.to_dict()
    c2_expected = {
        "summary": "something",
        "owner": "brian",
        "state": "todo",
        "id": 123,
    }
    assert c2 == c2_expected
```

テストクラスを使用する場合、テストメソッドの引数に self を与える必要があるがそれでも
テストコードを構造化できて読みやすいテストコードを書くことができる。