pytestにおけるparametrize(パラメータ化)とnextafterによる境界値試験

## TL; DR

Unit Test, Integration Testnのコードを書く際、境界値試験のコードを書くことがよくあります。ここではpytest.parametrizeとmath.nextafterを用いたDRY原則に従った簡便な書き方を紹介します。

## pytest.parametrize

### parametrizeとは
pytestにおけるparametrize(パラメータ化)とは、テスト関数を異なる引数のセットで複数回実行する機能を指します。これは、コードを複製することなく、さまざまな入力値で同じ機能をテストするのに役立ち、テストのカバレッジを向上させます。

### パラメータ化の例
以下に、pytestのパラメータ化の仕組みを説明する例を示します：

```python:サンプルコード
import pytest

# テスト対象の関数
def add(a, b):
    return a + b

# テストのパラメータ化
@pytest.mark.parametrize("input_a, input_b, expected", [
    (1, 2, 3),
    (5, 5, 10),
    (-1, -1, -2),
    (0, 0, 0),
    (-1, 1, 0)
])
def test_add(input_a, input_b, expected):
    assert add(input_a, input_b) == expected
```

### サンプルコードの説明

```python:テストのパラメータ化
@pytest.mark.parametrize("input_a, input_b, expected", [
    (1, 2, 3),
    (5, 5, 10),
    (-1, -1, -2),
    (0, 0, 0),
    (-1, 1, 0)
])
```
このデコレーターは、pytestに対して、異なるパラメータセットでtest_add関数を5回実行するよう指示します。それぞれのタプルの値は

```python:テスト関数
def test_add(input_a, input_b, expected):
    assert add(input_a, input_b) == expected
```
test_add関数は、リスト内の各タプルに対して1回ずつ実行され、input_a、input_b、expectedは各タプルの値を取ります。

### パラメータ化の利点
- DRY原則: コードの重複を減らします
- 可読性: テストがより読みやすく、保守しやすくなります
- 包括的なテスト: さまざまな入力値をテストすることで、カバレッジが広がります

### 追加のパラメータ化機能
- 間接パラメータ化: パラメータの初期化方法を制御できます
- フィクスチャパラメータ化: より複雑なセットアップを作成するためにフィクスチャと組み合わせることができます

## math.nextafter

### nextafterとは
`nextafter`は、Pythonのmathモジュールにある関数で、指定した方向に向かってある数値の次の浮動小数点数を見つけるために使用されます。この関数は、数値解析や境界値テストなど、浮動小数点演算を精密に制御する必要があるタスクに特に便利です。

### 構文
```python
math.nextafter(x: float | int, y: float | int)
```

### パラメータ
1. x: 開始する浮動小数点数
2. y: 次の浮動小数点数を見つける方向
    - もしyがxより大きい場合、math.nextafterはxより大きい最小の浮動小数点数を返します
    - もしyがxより小さい場合、xより小さい最大の浮動小数点数を返します
### 戻り値
関数は、xからyの方向に向かって次の浮動小数点数を返します。パラメーターがint型でもfloatです。

### 例
異なるシナリオでmath.nextafterがどのように機能するかを理解するために、いくつかの例を見てみましょう。

```python:与えられた数値より大きい次の浮動小数点数を見つける
import math

# 0.85より大きい最小の浮動小数点数を見つける
result = math.nextafter(0.85, 1.0)
print(result)  # 出力: 0.8500000000000001
```
この例では、math.nextafter(0.85, 1.0)は0.8500000000000001を返します。これは、0.85より大きい最小の浮動小数点数です。

```python:与えられた数値より小さい次の浮動小数点数を見つける
import math

# 0.15より小さい最大の浮動小数点数を見つける
result = math.nextafter(0.15, 0.0)
print(result)  # 出力: 0.14999999999999999
```
ここでは、math.nextafter(0.15, 0.0)は0.14999999999999999を返します。これは、0.15より小さい最大の浮動小数点数です。

```python:特殊なケースの処理
import math

# xとyが等しい場合、結果はx
result = math.nextafter(1.0, 1.0)
print(result)  # 出力: 1.0

# 負の数を扱う
result = math.nextafter(-1.0, 0.0)
print(result)  # 出力: -0.9999999999999999
```
xとyが同じ場合、math.nextafter(x, y)はxを返します。
この関数は負の数にも正しく動作します。

## 実践

次のような境界値があるとします。

![Boundary Value Test.drawio.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/13252/39876e7f-55ba-31fe-1761-a60debd95db2.png)

⚪︎はその値を含まず、⚫︎はその値を含むとします。この時テストケースは次の4つになります。

|No.|試験値|期待値|
|--:|--|--|
|1.|0.14999999999999999|LOW|
|2.|0.15|MIDDLE|
|3.|0.85|MIDDLE|
|4.|0.8500000000000001|HIGH|

**値はpythonのfloat型で表現できる精度としています。**

以下のコードは、上記の条件に従って`HIGH`, `MIDDLE`, `LOW`の文字列を返す関数とそのテストコードになります。


```python:src/parameterizedtest_nextafter/main.py
import math
import pytest

# テスト対象の関数
def classify(a: float) -> str:
    if a < 0.15:
        return "LOW"
    if a <= 0.85:
        return "MIDDLE"
    else:
        return "HIGH"


# テストのパラメータ化
@pytest.mark.parametrize("input, expected", [
    (math.nextafter(0.15, 0.0), "LOW"),
    (0.15, "MIDDLE"),
    (0.85, "MIDDLE"),
    (math.nextafter(0.85, 1.0), "HIGH"),
])
def test_add(input, expected):
    assert classify(input) == expected

```

このようにすることで、試験条件に合わせて4つのほぼ重複したコードを書くことが避けられ、また0.15未満となる最大の値は何か？とか、0.85を超える最小の値は何かとか考える必要がなくなりました。

以下はpytestの実行結果です。

```terminal:pytest
% pytest src/parameterizedtest_nextafter/main.py -v
=================================================================================================== test session starts ===================================================================================================
platform darwin -- Python 3.11.5, pytest-7.4.0, pluggy-1.0.0 -- /Users/ttakahashi/anaconda3/bin/python
cachedir: .pytest_cache
rootdir: /Users/ttakahashi/git/parameterizedtest_nextafter
plugins: anyio-3.5.0
collected 4 items                                                                                                                                                                                                         

src/parameterizedtest_nextafter/main.py::test_add[0.14999999999999997-LOW] PASSED                                                                                                                                   [ 25%]
src/parameterizedtest_nextafter/main.py::test_add[0.15-MIDDLE] PASSED                                                                                                                                               [ 50%]
src/parameterizedtest_nextafter/main.py::test_add[0.85-MIDDLE] PASSED                                                                                                                                               [ 75%]
src/parameterizedtest_nextafter/main.py::test_add[0.8500000000000001-HIGH] PASSED                                                                                                                                   [100%]

==================================================================================================== 4 passed in 0.01s ====================================================================================================
```
無事4つのテストケースが実行されPASSしていることが確認できました。

## まとめ

テストコードを書く時、正直言って同じようなコードを書くことは非常に苦痛であり、そのテストコード自体もメンテナンス対象となるため、テストコード量が増えることは技術負債で増えることとも言えます。また、境界値を考えるときに自分の感覚でその値を決めてしまうことはバグを生み出しかねません。
このような状況を避けるためにpytest.parametrizeとmath.nextafterを用いることで、テストコードをシンプルかつ厳密にまとめることができました。

## 実践のコード
こちらにアップしております。

https://github.com/ttakahashi-umn/parameterizedtest_nextafter.git

以上