pytestのフィクスチャが便利だった

# はじめに
2020年度XTechグループアドベントカレンダーの18日目の記事です。
XTech株式会社のタカクです。

ここ最近Pythonを使って開発をしています。
Pythonには標準のテストフレームワークであるunittestがありますが、私が関わっているプロジェクトでは3rdパーティー製のpytestを好んで使っています。
pytestはテスト失敗時に詳細なメッセージを表示してくれたり、フィクスチャの機能が便利なことから採用しています。

今回はpytestの基本とフィクスチャについて書きます。

# pytestの基本
pytestは3rdパーティー製のテストフレームワークのため、Python標準のunittetとは違い個別でのインストールが必要なのでインストール。

```sh
$ pip install pytest
```

unittestはクラスベースなのに対してpytestは関数ベースです。
assertするにもunittestではself.assertですが、pytestではassertでできます。

```python
def test_calc():
    assert 1 + 1 == 2
```

実行結果。テストが成功した場合は.が付きます。

```sh
$ pytest
============================================ test session starts ============================================
platform darwin -- Python 3.7.2, pytest-6.1.2, py-1.9.0, pluggy-0.13.1
rootdir: /Users/*/test
collected 1 item

calc.py .                                                                                             [100%]

============================================= 1 passed in 0.01s =============================================
```

pytestが対象とするテストの命名規則
* ファイル名: test_*.py または *_test.py
* テストメソッドやテスト関数名はtest_*
* テストクラスの名前はTest*

# エラーメッセージがわかりやすい

ここでのテストは `pytest` と `python` の文字列を比較する簡易的なものを使います。

```python
def test_pytest():
    assert ('p', 'y', 't', 'e', 's', 't') == ('p', 'y', 't', 'h', 'o', 'n')
```

`-v/--verbose` オプションを付けると詳細なエラーメッセージを表示してくれますが、まずはオプションをつけづにエラーメッセージを表示させます。


```sh
$ pytest
============================================ test session starts ============================================
platform darwin -- Python 3.7.2, pytest-6.1.2, py-1.9.0, pluggy-0.13.1
rootdir: /Users/*/test
collected 1 item

test_pytest.py F                                                                                                [100%]

================================================= FAILURES ==================================================
________________________________________________ test_pytest ________________________________________________

    def test_pytest():
>       assert ('p', 'y', 't', 'e', 's', 't') == ('p', 'y', 't', 'h', 'o', 'n')
E       AssertionError: assert ('p', 'y', 't', 'e', 's', 't') == ('p', 'y', 't', 'h', 'o', 'n')
E         At index 3 diff: 'e' != 'h'
E         Use -v to get the full diff

test_pytest.py:2: AssertionError
========================================== short test summary info ==========================================
FAILED test_pytest.py::test_pytest - AssertionError: assert ('p', 'y', 't', 'e', 's', 't') == ('p', 'y', 't', 'h', '...
============================================= 1 failed in 0.08s =============================================
```

3番目のインデックスのエラーを指摘してくれていますね。他にもあるけどとりあえず最初に一つのみ。
続いて `Use -v to get the full diff` と書いてあるように `-v` オプション付けて実行してみます。

```sh
$ pytest -v
================================================= test session starts ==================================================
platform darwin -- Python 3.7.2, pytest-6.1.2, py-1.9.0, pluggy-0.13.1 -- /Users/*/.pyenv/versions/3.7.2/bin/python3.7
cachedir: .pytest_cache
rootdir: /Users/*/test
collected 1 item

test_pytest.py::test_pytest FAILED                                                                                         [100%]

======================================================= FAILURES =======================================================
_____________________________________________________ test_pytest ______________________________________________________

    def test_pytest():
>       assert ('p', 'y', 't', 'e', 's', 't') == ('p', 'y', 't', 'h', 'o', 'n')
E       AssertionError: assert ('p', 'y', 't', 'e', 's', 't') == ('p', 'y', 't', 'h', 'o', 'n')
E         At index 3 diff: 'e' != 'h'
E         Full diff:
E         - ('p', 'y', 't', 'h', 'o', 'n')
E         ?                  ^    ^    ^
E         + ('p', 'y', 't', 'e', 's', 't')
E         ?                  ^    ^    ^

test_pytest.py:2: AssertionError
=============================================== short test summary info ================================================
FAILED d.py::test_pytest - AssertionError: assert ('p', 'y', 't', 'e', 's', 't') == ('p', 'y', 't', 'h', 'o', 'n')
================================================== 1 failed in 0.05s ===================================================
```

今回は Full diffのところに３箇所のエラーがしっかりと指摘されてます！
どこがダメなのか明確にしてくれるのでテストが捗ります。

# フィクスチャ
pytestのフィクスチャはテストの前処理と後処理をする機能を持っています。
unittestのsetupとteardownとは違いテスト関数から分離することができます。
無駄な記述がなく、シンプルになるのでテストの可読性が上がるのがいいですね。

# フィクスチャ関数
`@pytest.fixture()` 修飾子をつけるとpytestがフィクスチャ関数を認識してくれます。
パラメータとしてフィクスチャ関数名をテストの関数に渡すことができる。


```sh
import pytest

@pytest.fixture()
def param():
    return ('p', 'y', 't', 'e', 's', 't')

def test_pytest(param):
    assert param == ('p', 'y', 't', 'e', 's', 't')
```

# フィクスチャを共有する
conftest.pyファイルに記述することで再利用可能なフィクスチャになる。
pytestは現在のテストモジュール、conftest.pyの順番にフィクスチャを検索している。
pytestが探してくれるのでconftestはimportしなくて使える。
conftest.pyはプロジェクトのテストディレクトリルートと必要に応じて各テストディレクトリ配下に置けます。
conftest.pyに今まで同様にフィクスチャ関数を定義するだけです。

```python
import pytest

@pytest.fixture()
def param():
    return ('p', 'y', 't', 'e', 's', 't')
```

# フィクスチャでセットアップとティアダウンをする
フィクスチャではフィクスチャ関数内でyieldを使うことでsetupとteardownを実現できます
フィクスチャ関数内にyieldがあると先にtest関数が実行され、test関数の実行が終わってからフィクスチャ関数に制御が戻り、yield以降の処理が実行されます。

```
@pytest.fixture()
def setup_and_teardown():
    print('\nsetup')
    yield
    print('\nteardown')
```

テスト(yield)の前後で実行されているか確認する。

```python
Mac:test$ pytest --capture=no
================================================= test session starts ==================================================
platform darwin -- Python 3.7.2, pytest-6.1.2, py-1.9.0, pluggy-0.13.1
rootdir: /Users/*/test
collected 1 item

setup.py
setup
.
teardown


================================================== 1 passed in 0.01s ===================================================
```

成功したテストの.前後で `setup` と`teardown` が表示されていることが確認できました。

※ printの出力はERRORの時にしか表示されないので、ここでは `--capture=no` オプションをつけています。

# フィクスチャのパラメータ化
@pytest.fixtureのパラメータparamsを使うことでフィクスチャをパラメータ化することができます。
パラメータのparamsにデータを渡すことでフィクスチャ関数のparamにリストが１件づつ代入されます。
テスト関数の引数にパラメータ化されたフィクスチャを渡すことでその件数分テストが実行される。

```python
import pytest

class Person:
    def __init__(self, first_name, last_name):
        self.first_name = first_name
        self.last_name = last_name
    @property
    def full_name(self):
        return '%s %s' % (self.first_name, self.last_name)
 
@pytest.fixture(params=(Person('Ichiro', 'Suzuki'), Person('Taro', 'Yamamoto'),))
def person(request):
    return request.param

def test(person):
    expected = '%s %s' % (person.first_name, person.last_name)
    assert person.full_name == expected
```

パラメータ化のテストを実行します。

```sh
$ pytest -v
================================================= test session starts ==================================================
platform darwin -- Python 3.7.2, pytest-6.1.2, py-1.9.0, pluggy-0.13.1 -- /Users/*/.pyenv/versions/3.7.2/bin/python3.7
cachedir: .pytest_cache
rootdir: /Users/*/test
collected 2 items

person.py::test[person0] PASSED                                                                                  [ 50%]
person.py::test[person1] PASSED                                                                                  [100%]

================================================== 2 passed in 0.01s ===================================================
```

フィクスチャをパラメータ化したテストが正しく実行できました。
ただ、テスト結果からテストが実行できたことはわかりますが、どのようなテストなのかもう少しわかりやすくすることができます。
`@pytest.fixture`関数の`ids`パラメータを使うと実行結果ページのパラメータの内容をカスタマイズできます。

```python
import pytest

class Person:
    def __init__(self, first_name, last_name):
        self.first_name = first_name
        self.last_name = last_name
    @property
    def full_name(self):
        return f'{self.first_name} {self.last_name}'

def ids(value):
    return  f'Person({value.first_name} {value.last_name})'

@pytest.fixture(params=(Person('Ichiro', 'Suzuki'), Person('Taro', 'Yamamoto'),), ids=ids)
def person(request):
    return request.param

def test(person):
    expected = f'{person.first_name} {person.last_name}'
    assert person.full_name == expected
```

ここではPersonオブジェクトの `first_name` と `last_name` を表示するようにしました。

```sh
$ pytest -v
================================================= test session starts ==================================================
platform darwin -- Python 3.7.2, pytest-6.1.2, py-1.9.0, pluggy-0.13.1 -- /Users/*/.pyenv/versions/3.7.2/bin/python3.7
cachedir: .pytest_cache
rootdir: /Users/*/test
collected 2 items

person.py::test[Person(Ichiro Suzuki)] PASSED                                                                       [ 50%]
person.py::test[Person(Taro Yamamoto)] PASSED                                                                       [100%]

================================================== 2 passed in 0.01s ===================================================
```

`person.py::test[person0]` から `person.py::test[Person(Ichiro Suzuki)]` になりテストの内容がわかりますね。


# まとめ

#### pytest
* pytestは3rdパーティー製なのでインストール必要
* 関数ベースでassertだけで使える
* ファイル名、関数名に命名規則がある

#### fixture
* fixture関数をテスト関数に渡せる
* fixtureをconftest.py使うことで共有できる
* yield使うことでsetup/tearDown的なことができる
* fixtureのパラメータ化ができる

pytestのフィクスチャにはここで紹介した以外にも色々な機能があります。
Pythonでテストを書く機会があったらpytestを試してもらえると嬉しいです。