@pytest.mark.parametrizeでリスト内包表記とアンパック演算子(*)を使って効率的なテストケースを作成する

# はじめに
Pythonのテストフレームワークであるpytestでは、`@pytest.mark.parametrize`デコレーターを使うことで複数のテストケースを簡潔に定義することができます。

https://docs.pytest.org/en/stable/example/parametrize.html

さらに、`@pytest.mark.parametrize`に **リスト内包表記とアンパック演算子(`*`)** を組み合わせることで、繰り返しパターンが多い関数や多数の入力を必要とする関数をテストする際に効率的にテストケースを生成することができます。

本記事ではそのやり方を紹介します。

# 対象読者
- `@pytest.mark.parametrize` の基本的な使い方を知りたい方
- `@pytest.mark.parametrize` にリスト内包表記とアンパック演算子(`*`) を組み合わせることで簡潔にテストケースを作りたい方

# `@pytest.mark.parametrize`とは？
まずは前提となる`@pytest.mark.parametrize`について簡単に紹介します。

`@pytest.mark.parametrize`はpytestフレームワークで提供されるデコレーターの1つであり、テスト関数に対して複数の入力値を簡単に与えることができるものです。これを使えば同じテストロジックを異なる入力値で繰り返し実行することが可能になります。

## `@pytest.mark.parametrize`を使用しない場合
まずは`@pytest.mark.parametrize`を使用せずにFizzBuzz関数をテストする例を見てみます。

```python
# テスト対象の関数
def fizzbuzz(n):
    if n % 3 == 0 and n % 5 == 0:
        return 'FizzBuzz'
    elif n % 3 == 0:
        return 'Fizz'
    elif n % 5 == 0:
        return 'Buzz'
    else:
        return str(n)

# テストケース
def test_fizzbuzz():
    assert fizzbuzz(1) == '1'
    assert fizzbuzz(3) == 'Fizz'
    assert fizzbuzz(5) == 'Buzz'
    assert fizzbuzz(15) == 'FizzBuzz'
    assert fizzbuzz(2) == '2'
    assert fizzbuzz(6) == 'Fizz'
    assert fizzbuzz(10) == 'Buzz'
    assert fizzbuzz(30) == 'FizzBuzz'
```

この方法では、各テストケースごとに`assert`文を書く必要があります。
テスト対象の入力値が少ない分にはあまり問題になりませんが、入力値が多い場合やテストケースを増やす場合、`assert fizzbuzz()`文を繰り返し書かないといけないです。これは冗長でありメンテナンス性が低下する要因になります。

## `@pytest.mark.parametrize`を使用する場合

次に、同じテストを`@pytest.mark.parametrize`を使って書き直してみると以下のようになります。

```python
import pytest

@pytest.mark.parametrize(
    "input,expected",
    [
        (1, '1'),
        (3, 'Fizz'),
        (5, 'Buzz'),
        (15, 'FizzBuzz'),
        (2, '2'),
        (6, 'Fizz'),
        (10, 'Buzz'),
        (30, 'FizzBuzz')
    ],
    ids=repr
)
def test_fizzbuzz(input, expected):
    assert fizzbuzz(input) == expected
```
上記のようにテスト関数に与える入力値と期待値をタプルで定義します。
この状態でテストを実行すると、定義したタプルが1行ずつテスト関数の引数に渡され、それぞれが独立したテストケースとして実行されます。

`@pytest.mark.parametrize` を使うメリットとして、与える入力値を外部から渡せるのでテストロジック（`assert`文）は1つだけ定義すればよくなります。
これによってテストロジックの冗長性が排除されます。

また、テストケースを追加する場合は入力値と期待値のタプルを追加すればいいだけ（テストロジックには手を加えない）なので追加や変更が容易かつ安全になる点もメリットの1つです。

---

ただし、`@pytest.mark.parametrize`を使う場合でもテストケース（入力値と期待値のタプル）を1行ずつ定義する必要があります。
そのため、例えばテストケースを10行、20行、30行〜と増やすとなった場合、見通しが悪くなるのは想像に容易いと思います。

これを解決する手段として、次に紹介するリスト内包表記とアンパック演算子(`*`) を使う方法があります。


# リスト内包表記とアンパック演算子(`*`）を使って書く
リスト内包表記とアンパック演算子(`*`)を組み合わせることで、以下のようにテストケースをより簡潔かつ効率的に記述することができます。
```python
import pytest

def fizzbuzz(n):
    if n % 3 == 0 and n % 5 == 0:
        return 'FizzBuzz'
    elif n % 3 == 0:
        return 'Fizz'
    elif n % 5 == 0:
        return 'Buzz'
    else:
        return str(n)

@pytest.mark.parametrize(
    'input, expected',
    [
        # リスト内包表記とアンパック演算子(*）を使う
        *[(i, 'FizzBuzz') for i in range(15, 101, 15)],  # 15の倍数
        *[(i, 'Fizz') for i in range(3, 101, 3) if i % 15 != 0],  # 3の倍数で15の倍数でないもの
        *[(i, 'Buzz') for i in range(5, 101, 5) if i % 15 != 0],  # 5の倍数で15の倍数でないもの
        
        # 3の倍数でも5の倍数でもないもの
        *[(i, str(i)) for i in range(1, 101) if i % 3 != 0 and i % 5 != 0],
    ],
    ids=repr
)
def test_fizzbuzz(input, expected):
    assert fizzbuzz(input) == expected
```

この方法には以下のようなメリットがあります。
- コードの見通しが良くなり、多数のテストケースを少ない行数で表現できる
- 繰り返しのあるテストケースを簡単に生成できる

ただ、初見だと何をやっているのかがわかりづらいので、上記の内部動作を具体的に説明します。

## リスト内包表記の動作
まず、リスト内包表記はループと条件文を1行で表現できるPythonの機能です。
```python
# 15の倍数のケース
[(i, 'FizzBuzz') for i in range(15, 101, 15)]
```
このコードは以下のような通常のループと同等であり、リストの中にタプルが内包されます。
```python
result = []
for i in range(15, 101, 15):
    result.append((i, 'FizzBuzz'))
    
#...出力結果...
[(15, 'FizzBuzz'),
 (30, 'FizzBuzz'),
 (45, 'FizzBuzz'),
 (60, 'FizzBuzz'),
 (75, 'FizzBuzz'),
 (90, 'FizzBuzz')]
```

## アンパック演算子(`*`)の動作
アンパック演算子(`*`)は、リストやタプルなどの反復可能なオブジェクトの要素を個別の要素として展開してくれるものです。

`@pytest.mark.parametrize` デコレータ内ではアンパック演算子を使用することで、リスト内包表記で生成したリストの要素を個別のテストケースとして扱うことができます。

例えば、先ほどのリスト内包表記の例を使って説明します。
```python
@pytest.mark.parametrize(
    'input, expected',
    [
        *[(i, 'FizzBuzz') for i in range(15, 101, 15)],
    ]
)
```
これは以下と同等です。
```python
@pytest.mark.parametrize(
    'input, expected',
    [
        (15, 'FizzBuzz'),
        (30, 'FizzBuzz'),
        (45, 'FizzBuzz'),
        (60, 'FizzBuzz'),
        (75, 'FizzBuzz'),
        (90, 'FizzBuzz'),
    ]
)
```
アンパック演算子(`*`)を使用することで、リスト内包表記で生成されたリストの各要素（この場合は各タプル）が、`@pytest.mark.parametrize` デコレータの引数として個別に展開されます。
その結果、各タプルが独立したテストケースとして実行されます。

## テストの実行と結果

内部動作がわかったところで、作成したテストコードを実行して結果を確認してみましょう。
```bash
# -v オプションを付けることで詳細な実行結果を確認することができる
❯ pytest test.py -v                                                                                                                                                                                                          18:25:42
========================================================================================================= test session starts ==========================================================================================================
...省略

collected 100 items                                                                                                                                                                                                                    

test.py::test_fizzbuzz[15-'FizzBuzz'] PASSED                                                                                                                                                                                     [  1%]
test.py::test_fizzbuzz[30-'FizzBuzz'] PASSED                                                                                                                                                                                     [  2%]
test.py::test_fizzbuzz[45-'FizzBuzz'] PASSED                                                                                                                                                                                     [  3%]
test.py::test_fizzbuzz[60-'FizzBuzz'] PASSED                                                                                                                                                                                     [  4%]
test.py::test_fizzbuzz[75-'FizzBuzz'] PASSED                                                                                                                                                                                     [  5%]
test.py::test_fizzbuzz[90-'FizzBuzz'] PASSED                                                                                                                                                                                     [  6%]
test.py::test_fizzbuzz[3-'Fizz'] PASSED                                                                                                                                                                                          [  7%]
test.py::test_fizzbuzz[6-'Fizz'] PASSED                                                                                                                                                                                          [  8%]
test.py::test_fizzbuzz[9-'Fizz'] PASSED                                                                                                                                                                                          [  9%]
test.py::test_fizzbuzz[12-'Fizz'] PASSED                                                                                                                                                                                         [ 10%]
test.py::test_fizzbuzz[18-'Fizz'] PASSED                                                                                                                                                                                         [ 11%]
test.py::test_fizzbuzz[21-'Fizz'] PASSED                                                                                                                                                                                         [ 12%]
test.py::test_fizzbuzz[24-'Fizz'] PASSED
...　省略

test.py::test_fizzbuzz[5-'Buzz'] PASSED                                                                                                                                                                                          [ 34%]
test.py::test_fizzbuzz[10-'Buzz'] PASSED                                                                                                                                                                                         [ 35%]
test.py::test_fizzbuzz[20-'Buzz'] PASSED                                                                                                                                                                                         [ 36%]
test.py::test_fizzbuzz[25-'Buzz'] PASSED                                                                                                                                                                                         [ 37%]
test.py::test_fizzbuzz[35-'Buzz'] PASSED
...省略

test.py::test_fizzbuzz[1-'1'] PASSED                                                                                                                                                                                             [ 48%]
test.py::test_fizzbuzz[2-'2'] PASSED                                                                                                                                                                                             [ 49%]
test.py::test_fizzbuzz[4-'4'] PASSED                                                                                                                                                                                             [ 50%]
test.py::test_fizzbuzz[7-'7'] PASSED                                                                                                                                                                                             [ 51%]
test.py::test_fizzbuzz[8-'8'] PASSED                                                                                                                                                                                             [ 52%]
test.py::test_fizzbuzz[11-'11'] PASSED     
```

この出力結果から以下のことがわかります。

- 全部で100個のテストケースが生成され、実行された（`collected 100 items`）
- 各テストケースが個別に実行された
    - テストケースには入力値と期待値が表示されている（`[1-1]`, `[3-Fizz]`）

リスト内包表記で生成したテストデータがアンパック演算子で展開され、それらが個別のテストケースとして正しく実行されていることが確認できました。

# さいごに
本記事で紹介したリスト内包表記とアンパック演算子(`*`)の利用ケースですが、
FizzBuzzのような以下の特性をもつ関数をテストする場合に向いているかと思います。

- 入力値の範囲が広い
    - 1から100までの数値など
- 出力が特定のパターンで繰り返される
    - FizzBuzzのように3の倍数、5の倍数、15の倍数など

参考までに、実際にリスト内包表記とアンパック演算子(`*`)を使ったテストコードは以下です。pydantic-coreが提供するfloat型/decimal型の`multiple_of`バリデーション関数のテストを本手法を用いて効率化しました。

https://github.com/pydantic/pydantic-core/pull/1373/files



同じような特徴を持つ関数をテストする場合はぜひこの方法を試してみてください。