pytestのテスト関数にパラメータつけてみる(parametrize, pytest_generate_tests)

# 単体テスト書いてますか?
めんどくさくてサボりがちになってしまいますが。ていうか、サボってますが。今後もコードを触る可能性があるなら、単体テストを書く作業は必ずペイします。低リスク、利益率数百パーセントという夢のような投資です。

これ読みに来てる人は、そんなの分かってると信じて。
テストコードをコピペする作業に飽きた人のために、pytestのテスト関数にパラメータ付けをしてみます。

今回は、私が開発している量子コンピュータ用ライブラリ[Blueqat](https://github.com/Blueqat/Blueqat)で実際に使われているテストコードから、実際の例を抜き出してみます。

# `pytest.mark.parametrize`を使う

見た方が早いので、いきなり実例です。

```py:parametrizeを使う
@pytest.mark.parametrize('arg, expect', [
    ("01011", (0, 1, 0, 1, 1)),
    ({"00011": 2, "10100": 3}, {(0, 0, 0, 1, 1): 2, (1, 0, 1, 0, 0): 3}),
    (Counter({"00011": 2, "10100": 3}), Counter({(0, 0, 0, 1, 1): 2, (1, 0, 1, 0, 0): 3}))
])
def test_to_inttuple(arg, expect):
    assert to_inttuple(arg) == expect
```

`parametrize`を使わなければ、恐らくこうなったでしょう。

```py:parametrizeを使わなかった場合
def test_to_inttuple1():
    assert to_inttuple("01011") == (0, 1, 0, 1, 1)

def test_to_inttuple2():
    assert to_inttuple({"00011": 2, "10100": 3}) == {(0, 0, 0, 1, 1): 2, (1, 0, 1, 0, 0): 3}

def test_to_inttuple3():
    assert to_inttuple(Counter({"00011": 2, "10100": 3})) == Counter({(0, 0, 0, 1, 1): 2, (1, 0, 1, 0, 0): 3})
```

テスト関数は、なにもなければ引数なしで作りますが、既に見たように

```py:parametrizeの使い方
@pytest.mark.parametrize('引数1, 引数2, ...', [
    (テストケース1の引数1, テストケース1の引数2, ...),
    (テストケース2の引数1, テストケース2の引数2, ...),
    ...
])
def test_hogehoge(引数1, 引数2):
    assert hogehoge(引数1, 引数2)
```

のようにできます。引数が1つの場合、

```py:parametrizeの使い方(1引数)
@pytest.mark.parametrize('引数', [テストケース1の引数, テストケース2の引数, ...])
def test_hogehoge(引数):
    assert hogehoge(引数)
```

でも構いません。

便利ですね。

# テストケースをコマンドラインオプションに応じて、動的に作る
`parametrize`で十分便利なんですが。最近、開発しているBlueqatに、NVIDIA GPUでしか動かないオプション機能が追加されました。
CIツールとしてCircle CIを使っていますが、GPUのコードは動かないので、そっちは自分の開発環境でテストして、Circle CIではGPUなしで動くコードを動かしたいという需要ができました。(ほんとは[GPUもCircle CIで](https://circleci.com/docs/2.0/gpu/)やりたいが。GPUインスタンス立てる金ないから許して)

また、同時期に類似のオプション機能の追加があったので、それも含めてテストしたい、となりました。

Blueqatでは、以下のようにして、裏で使うプログラム(backendと呼んでいます)を選べます。

```py:backendの指定
c = Circuit()
c.run() # => デフォルトのbackendで動く
c.run(backend='numpy') # => 'numpy'という名前のbackendで動く
```

今まで、デフォルトの'numpy' backendしかテストしてなかったのですが、新たに'numba' backendとGPU必須の'qgate' backendが加わりました。
テストコードの再利用と、テストするbackendを選ぶ(Circle CIではGPU必須のコードを外す)需要が出たので、コマンドラインオプションからテストするbackendを選べるようにしました。

## `pytest_addoption`でコマンドラインオプションを追加する
テストのあるディレクトリに`conftest.py`というファイルを作り、そこに`pytest_addoption`という関数を定義し、その中で`parser.addoption`を呼んでオプションを追加します。

```py:conftest.py(コマンドラインオプションの追加)
def pytest_addoption(parser):
    parser.addoption('--add-backend', default=['numpy'], action='append')
```

`parser.addoption`の引数は、[ドキュメント](https://docs.pytest.org/en/latest/reference.html?highlight=parser#_pytest.config.argparsing.Parser.addoption)には、標準ライブラリの`argparse.add_option`と同じだと書いてありますが、[`argparse.add_argument`](https://docs.python.org/3/library/argparse.html#argparse.ArgumentParser.add_argument)だと思われます。

上に書いたコードでは、`--add-backend`オプションを定義し、デフォルト値を`['numpy']`としています。`--add-backend numba`とコマンドラインオプションを指定すると、その値が追加(append)され`['numpy', 'numba']`となります。

## `pytest_generate_tests`でパラメータ化する
これも`conftest.py`に書きます。

```py:conftest.py(コマンドラインオプションからテストを作る)
def pytest_generate_tests(metafunc):
    if 'backend' in metafunc.fixturenames:
        metafunc.parametrize('backend', metafunc.config.getoption('--add-backend'))
```

テストの引数に`backend`が含まれていたら、`backend`に、`--add-backend`オプションで指定されたリストでparametrizeします。

続いて、他のテストファイルで、backendパラメータを使ったテストを書きます。

```py:test_*.py
def test_hgate1(backend):
    assert is_vec_same(Circuit().h[1].h[0].run(backend=backend), np.array([0.5, 0.5, 0.5, 0.5]))


def test_hgate2(backend):
    assert is_vec_same(Circuit().x[0].h[0].run(backend=backend),
                       np.array([1 / np.sqrt(2), -1 / np.sqrt(2)]))
```

これらは
`python -m pytest .`
のように動かした場合は

```py:test_*.py(pytest_generate_testsを使わずparametrizeで書く・その1)
@pytest.mark.parametrize('backend', ['numpy'])
def test_hgate1(backend):
    assert is_vec_same(Circuit().h[1].h[0].run(backend=backend), np.array([0.5, 0.5, 0.5, 0.5]))


@pytest.mark.parametrize('backend', ['numpy'])
def test_hgate2(backend):
    assert is_vec_same(Circuit().x[0].h[0].run(backend=backend),
                       np.array([1 / np.sqrt(2), -1 / np.sqrt(2)]))
```
と同じになります。また、
`python -m pytest . --add-backend numba --add-backend qgate`
のように動かした場合は

```py:test_*.py(pytest_generate_testsを使わずparametrizeで書く・その2)
@pytest.mark.parametrize('backend', ['numpy', 'numba', 'qgate'])
def test_hgate1(backend):
    assert is_vec_same(Circuit().h[1].h[0].run(backend=backend), np.array([0.5, 0.5, 0.5, 0.5]))


@pytest.mark.parametrize('backend', ['numpy', 'numba', 'qgate'])
def test_hgate2(backend):
    assert is_vec_same(Circuit().x[0].h[0].run(backend=backend),
                       np.array([1 / np.sqrt(2), -1 / np.sqrt(2)]))
```

と同じになります。

ややハードルが上がりますが、一度がんばってしまえば楽で、メリットは大きいかと思います。

# 参考文献
https://docs.pytest.org/en/latest/example/parametrize.html