浮動小数点数とpytest.approx — 「十分に近い」をテストする
なぜ 0.1 + 0.2 == 0.3 が False になるのか、pytest.approxのデフォルトの許容誤差、relとabsが必要なケース、そしてなぜ金額にはapproxではなくDecimalを使うべきなのかを学びます。NaN、順序が保証されない結果、タイムスタンプの扱いも解説します。
どうやって学びますか?
解決する問題
子どもでも答えられるような簡単な計算を、Pythonに尋ねてみましょう:
print(0.1 + 0.2)
print(0.1 + 0.2 == 0.3)0.30000000000000004
Falseこれはお使いのマシンのバグでも、Pythonのバグでもありません。ほぼすべてのプログラミング言語が小数を格納する仕組みそのものによるものです。そしてこの問題は、テストにもそのまま入り込んできます。
test_total.py:
def total(prices):
return sum(prices)
def test_total():
assert total([0.1, 0.2]) == 0.3続けてpytest -qを実行します:
F [100%]
=================================== FAILURES ===================================
__________________________________ test_total __________________________________
def test_total():
> assert total([0.1, 0.2]) == 0.3
E assert 0.30000000000000004 == 0.3
E + where 0.30000000000000004 = total([0.1, 0.2])
test_total.py:6: AssertionError
=========================== short test summary info ============================
FAILED test_total.py::test_total - assert 0.30000000000000004 == 0.3
1 failed in 0.01s関数自体は正しく動作しています。間違っているのはテストの方です。テストは、浮動小数点数では提供できないほどの厳密さを求めてしまっています。この章で学ぶのは、代わりに適切な問いを投げかける方法です。つまり、「十分に近いか?」ということです。また、「十分に近い」と考えるべきではないケースについても学びます。その最たる例がお金です。
この章を終える頃には
- なぜ
0.1 + 0.2 == 0.3がFalseになるのかを2文で説明できるようになる - テストで
pytest.approxを使って浮動小数点数を比較し、デフォルトの許容誤差を読み解けるようになる rel=とabs=を使って独自の許容誤差を設定し、ゼロ付近でどちらが必要になるかを判断できるようになる- リスト、タプル、辞書に対して
approxを使用し、不一致があったときに出力されるレポートを読み解けるようになる pytest.approx、math.isclose、Decimalを適切に使い分けられるようになる- NaN、順序が保証されていない結果、タイムスタンプをテストできるようになる
前提条件: 例外のテスト。
テストを書く前に
数値を扱う際、コードを書く前に下すべき最も重要な決定があります。それは、「それぞれの結果に対して、コードはどのような等価性を保証しているのか?」ということです。これを見誤ると、テストが不安定(厳格すぎる)になるか、バグを見逃す(緩すぎる)ことになります。まずは3つのことを整理しておきましょう。
1. 保証内容(契約)。 この章の最後で作成する小さなstats.pyモジュールを例にとりましょう。平易な言葉で言えば、このモジュールは以下を保証しています:
mean(values)は浮動小数点数のリストの平均値を返し、空リストに対してはnanを返す。shares(counts)はカウント数を合計が1になる比率に変換する。with_vat(price)はDecimal型の価格に15%の消費税(VAT)を加算し、四捨五入(端数が0.5の場合は切り上げ)でセント単位に丸める。float型の価格はTypeErrorで拒否する。countries(orders)は各国の名前を1回ずつ返す。順序は一切保証しない。
それぞれの行が、すでに比較方法を教えてくれています。「浮動小数点数の平均値」は許容誤差を意味します。「セント単位に丸める」は厳密な一致を意味します。「拒否する」はpytest.raisesを意味します。「順序は一切保証しない」は、比較する前にソートするかセットを使うことを意味します。
2. セットアップ。 新しくインストールするものはありません。第1章で作成したpytest入りの仮想環境(venv)、テストファイルからインポート可能なモジュール(両方を1つのフォルダに置き、そこからpytestを実行します)、そして2つの標準ライブラリモジュールmathとdecimalが必要です。フィクスチャも、ファイルも、ネットワークも必要ありません。
3. 計画。 テストコードを書く前に、テストケースを書き出しておきます。すべての行について、期待値だけでなく比較方法も決定します:
| ケース | 入力 | 期待値 | 比較方法 | | --- | --- | --- | --- | | 正常系、浮動小数点数の計算 | mean([0.1, 0.2, 0.3]) | 0.2 | approx(デフォルトの許容誤差) | | 境界値:ゼロに近い結果 | mean([0.1, 0.2, -0.3]) | 0.0 | approx(0.0, abs=1e-9) | | エッジケース:空の入力 | mean([]) | nan | math.isnan | | 浮動小数点数のコンテナ | shares({"tea": 1, "coffee": 2}) | {"tea": 1/3, "coffee": 2/3} | 辞書に対するapprox | | 金額、正常系 | with_vat(Decimal("19.99")) | Decimal("22.99") | 厳密な== | | 金額、丸めの境界値 | with_vat(Decimal("0.10")) | Decimal("0.12")(0.115を切り上げ) | 厳密な== | | 不正な入力 | with_vat(19.99) | TypeError | pytest.raises | | 順序が保証されていない | 重複を含むcountries(...) | ["BD", "IN"] | sorted(...) == |
テストすべきではないこと。 0.1 + 0.2が0.30000000000000004になることをテストしてはいけません。それはPythonの振る舞いであって、あなたが書いたコードの振る舞いではないからです。それを固定してしまうと、浮動小数点数のフォーマット自体をテストすることになってしまいます。セットから返される結果の順序や、タイムスタンプのミリ秒・マイクロ秒単位の厳密な数値をテストしてはいけません。どちらも保証されていないからです。また、テストがグリーンになるまで許容誤差を広げて決めるようなことはしないでください。許容誤差とは問題そのものに対する表明(「0.5度以内」「1%以内」など)であり、テストの都合に合わせて決めるものではありません。
以降のセクションでは、上記の表にある各ツールを順に解説していきます。最後の完全な例では、この計画を行ごとに実装していきます。
なぜ0.1 + 0.2は0.3にならないのか
float(浮動小数点数)は2進数として、固定されたサイズ(64ビット)で格納されます。10進数で1/3が正確に表せない(0.3333…と無限に続き、どこかで書くのをやめなければならない)のと同じように、小数のなかには2進数で正確に表せないものがあります。0.1は2進数においてまさにそうした小数の1つです。Pythonは保持できる範囲で最も近い数値を格納します。
Pythonが通常表示する桁数よりも多くの桁数を指定することで、実際に格納されている値を確認できます:
print(f"{0.1:.20f}")
print(f"{0.2:.20f}")
print(f"{0.3:.20f}")
print(f"{0.1 + 0.2:.20f}")0.10000000000000000555
0.20000000000000001110
0.29999999999999998890
0.300000000000000044410.1はごくわずかに大きく格納され、0.2も同様です。これらを足し算すると、2つの誤差が合算されます。一方、0.3はごくわずかに小さく格納されます。計算結果と0.3は隣り合う別々の浮動小数点数になり、==は浮動小数点数をビット単位で比較するため、Falseと判定されます。
ここから、この章における唯一のルールが導かれます。それは、計算によって得られた浮動小数点数に対して決して==を使ってはならないということです。自分で直接入力してそのまま手を加えずに渡した浮動小数点数であれば問題ありません。しかし、足し算、割り算、掛け算などの計算が行われた浮動小数点数は、許容誤差を持たせて比較する必要があります。
pytest.approx — 許容誤差の範囲内で等しい
テストの1行だけを変更してみましょう:
import pytest
def total(prices):
return sum(prices)
def test_total():
assert total([0.1, 0.2]) == pytest.approx(0.3). [100%]
1 passed in 0.01spytest.approx(0.3)は、0.3に十分に近いあらゆる数値と「等しい」オブジェクトを生成します。どれくらい近ければよいのでしょうか? オブジェクトを出力してみると分かります:
import pytest
print(pytest.approx(0.3))
print(pytest.approx(250.0))
print(pytest.approx(1_000_000.0))
print(pytest.approx(0.0))0.3 ± 3.0e-07
250.0 ± 2.5e-04
1000000.0 ± 1
0.0 ± 1.0e-12デフォルトでは2つの許容誤差が用意されており、そのうち大きい方が採用されます:
- 相対許容誤差
rel=1e-6: 期待値の100万分の1です。0.3の場合は0.0000003になり、100万の場合は1になります。 - 絶対許容誤差
abs=1e-12: 固定された下限であり、許容誤差が完全にゼロになってしまうのを防ぎます。
数値の大きさに応じて許容範囲が伸縮するため、ほぼあらゆる場面で重要になるのは相対許容誤差の方です。0.0000003という誤差は0.3に対しては単なるノイズですが、1_000_000に対してこの数値を要求すると途方もなく厳格になってしまいます。
ただし、ゼロ付近では状況が一変します。ゼロの100万分の1はゼロなので、極めて小さな絶対許容誤差の下限だけが残ることになります:
import pytest
print(0.000001 == pytest.approx(0.0))
print(0.000001 == pytest.approx(0.0, abs=1e-5))False
True人間から見れば100万分の1はゼロのように思えますが、approx(0.0)にとってはそうではありません。結果がゼロ、またはゼロ付近になると予想される場合は、自分でapproxに絶対許容誤差を指定してください。
独自の許容誤差を指定する:rel=とabs=
デフォルトの設定は、「わずかに異なる方法で行われた同じ計算」に適しています。コードが数値を丸めたり、測定したり、推定したりする場合は、「十分に近い」とはどういう意味かを明確にして指定します:
import pytest
print(100.4 == pytest.approx(100, abs=0.5))
print(100.6 == pytest.approx(100, abs=0.5))
print(100.6 == pytest.approx(100, rel=0.01))
print(pytest.approx(100, abs=0.5))
print(pytest.approx(100, rel=0.01))
print(pytest.approx(100, rel=0.01, abs=5))True
False
True
100 ± 0.5
100 ± 1
100 ± 5abs=0.5は、「数値の大きさにかかわらず0.5単位以内」を意味します。rel=0.01は、「期待値の1%以内」を意味します。100の場合は± 1になります。- 両方を指定した場合、デフォルトと同様に大きい方の許容誤差が採用されます。ここでは
abs=5が100の1%(1)より大きいため優先されます。
具体的な言葉を思い浮かべながら選んでください。「測定された温度の誤差は0.5度以内かもしれない」ならabs=0.5です。「推定値のズレは1%以内かもしれない」ならrel=0.01です。そして期待値がゼロの場合は、absだけが頼りになります。
やってはいけないのは、失敗しているテストから始めて、グリーンになるまで許容誤差を広げていくことです。テストをパスさせるためだけに選ばれた許容誤差は、バグも同じように易々と見逃してしまいます:
import pytest
print(0.95 == pytest.approx(1.0, rel=0.1))True5%も間違っている結果が、これで「等しい」とみなされてしまいます。コードが丸め誤差のレベルで正確であるべきなら、デフォルトのままにしておいてください。緩い許容誤差を設定するには口に出して説明できる理由が必要であり、その理由はテストの隣にコメントとして記載すべきです。
リスト、タプル、辞書
approxは数値のコンテナも受け取り、要素ごとにそれぞれの許容誤差で比較を行います:
import pytest
shares = [0.1 + 0.2, 0.7]
point = (1 / 3, 2 / 3)
rates = {"tax": 0.1 + 0.05, "tip": 0.1}
print(shares == pytest.approx([0.3, 0.7]))
print(point == pytest.approx((0.333333, 0.666667), rel=1e-5))
print(rates == pytest.approx({"tax": 0.15, "tip": 0.1}))
print(pytest.approx([0.3, 0.7]))True
True
True
approx([0.3 ± 3.0e-07, 0.7 ± 7.0e-07])リストは位置(インデックス)で比較されます。辞書はキーで比較されるため、キーの順序は関係ありませんが、キー自体は完全に一致している必要があります。また、要素数(長さ)も一致していなければなりません。
この機能の真価は、比較が失敗したときに現れます。test_shares.py:
import pytest
def test_shares():
assert [0.1 + 0.2, 0.25, 0.6] == pytest.approx([0.3, 0.2, 0.6])F [100%]
=================================== FAILURES ===================================
_________________________________ test_shares __________________________________
def test_shares():
> assert [0.1 + 0.2, 0.25, 0.6] == pytest.approx([0.3, 0.2, 0.6])
E assert [0.3000000000...04, 0.25, 0.6] == approx([0.3 ±....6 ± 6.0e-07])
E
E comparison failed. Mismatched elements: 1 / 3:
E Max absolute difference: 0.04999999999999999
E Max relative difference: 0.19999999999999996
E Index | Obtained | Expected
E 1 | 0.25 | 0.2 ± 2.0e-07
test_shares.py:5: AssertionError
=========================== short test summary info ============================
FAILED test_shares.py::test_shares - assert [0.3000000000...04, 0.25, 0.6] ==...
1 failed in 0.01sEの行を下から上に向かって読んでみましょう。3つの要素のうち1つが一致しませんでした。テーブルには、どの要素か(インデックス1)、実際に出力された値(0.25)、そして許容誤差を含めた期待値が示されています。要素0である0.30000000000000004はリストアップされていません。十分に近かったため、問題ではないからです。辞書の場合、Indexの列には代わりにキーが表示されます。
approxが適していないケース
approxは浮動小数点数のために存在します。それ以外のものを渡すと、静かに通常の==へとフォールバックします:
import pytest
print("abc" == pytest.approx("abc"))
print(pytest.approx("abc"))
print(10 == pytest.approx(10))
print(10.000001 == pytest.approx(10))True
abc
True
True文字列の場合、approxは何の役にも立たず、表示される許容誤差もありません。整数の場合、おそらく望んでいない動作を引き起こします。もしcount_items()が10を返すべきだとすれば、10.000001はバグですが、approx(10)はそれをパスさせてしまいます。整数、文字列、ブール値、そしてNoneは、通常の==で比較してください。
math.isclose — 標準ライブラリの関数
Pythonには独自の許容誤差チェック関数であるmath.iscloseが用意されています。そのデフォルト値はapproxとは異なり、rel_tol=1e-9(approxより厳格)で、abs_tol=0.0(下限がまったくない)となっています:
import math
print(math.isclose(0.1 + 0.2, 0.3))
print(math.isclose(0.000001, 0.0))
print(math.isclose(0.000001, 0.0, abs_tol=1e-5))
print(math.isclose(100.6, 100, rel_tol=0.01))True
False
True
Trueabs_tol=0.0では、厳密に0.0であるもの以外は決してゼロに近いとはみなされません。先ほどと同じ教訓ですが、より顕著に現れています。
本番環境にはpytestがインストールされていないため、アプリケーションコードの中ではmath.iscloseを使うのが適切です。しかしテストの中ではapproxを優先すべきであり、その理由は失敗時のレポートにあります。test_close.py:
import math
import pytest
def test_with_isclose():
assert math.isclose(0.3001, 0.3)
def test_with_approx():
assert 0.3001 == pytest.approx(0.3)FF [100%]
=================================== FAILURES ===================================
______________________________ test_with_isclose _______________________________
def test_with_isclose():
> assert math.isclose(0.3001, 0.3)
E assert False
E + where False = <built-in function isclose>(0.3001, 0.3)
E + where <built-in function isclose> = math.isclose
test_close.py:7: AssertionError
_______________________________ test_with_approx _______________________________
def test_with_approx():
> assert 0.3001 == pytest.approx(0.3)
E assert 0.3001 == 0.3 ± 3.0e-07
E
E comparison failed
E Obtained: 0.3001
E Expected: 0.3 ± 3.0e-07
test_close.py:11: AssertionError
=========================== short test summary info ============================
FAILED test_close.py::test_with_isclose - assert False
FAILED test_close.py::test_with_approx - assert 0.3001 == 0.3 ± 3.0e-07
2 failed in 0.01sassert Falseは、数値が異なっていることしか教えてくれません。対照的にapproxは、どれだけ離れていたのか、そしてどのような許容誤差が認められていたのかまで教えてくれます。また、math.iscloseはリストや辞書の比較にも対応していません。
お金は許容誤差の問題ではない
価格を扱うテストが0.30000000000000004で失敗すると、ついapproxを使いたくなります。しかし、高額な請求書においてその許容誤差が何を意味するのかを見てみましょう:
import pytest
expected = 1_000_000.00
charged = 1_000_000.99
print(charged == pytest.approx(expected))True99セントもの差があるにもかかわらず、テストはパスしてしまいます。100万の100万分の1は丸々1単位になります。金額において、「十分に近い」は正しい答えではありません。1セントでも多く請求された顧客は、誤った金額を請求されたことになります。
修正すべきはテストではありません。そもそもコード側で金額をfloatで保持すべきではないのです。Pythonのdecimal.Decimalは、記述された通りの10進数の桁を正確に保持します:
from decimal import Decimal
print(Decimal("0.10") + Decimal("0.20"))
print(Decimal("0.10") + Decimal("0.20") == Decimal("0.30"))
print(Decimal("1000000.99") == Decimal("1000000.00"))
print(Decimal(0.1))0.30
True
False
0.1000000000000000055511151231257827021181583404541015625Decimalを使えば、==は再び厳密な比較になり、これこそが金額のテストに必要なものです。最後の行はよくある落とし穴です。Decimal(0.1)はfloatから生成されるため、floatが抱える誤差をそのまま忠実に引き継いでしまいます。Decimalは常に文字列から生成してください。
セント単位への丸め処理は明示的に行われ、ルールも自分で選択できます:
from decimal import ROUND_HALF_UP, Decimal
price = Decimal("19.99")
vat = price * Decimal("0.15")
print(vat)
print(vat.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))2.9985
3.00使い分けはとてもシンプルです。測定値(長さ、平均値、比率、センサーの測定値など)は浮動小数点数(float)であり、approxを使ってテストします。金額はDecimalであり、==を使ってテストします。
NaNは自分自身とも等しくない
float("nan")は「非数(not a number)」を意味し、0 * infのような計算や欠損値の結果として生じます。定義上、NaNは自分自身を含め何とも等しくありません:
import math
import pytest
missing = float("nan")
print(missing == missing)
print(missing == pytest.approx(float("nan")))
print(missing == pytest.approx(float("nan"), nan_ok=True))
print(math.isnan(missing))False
False
True
Trueapproxも、nan_ok=Trueを渡さない限りこのルールに従います。nan_ok=Trueは「ここではNaNが期待されており、NaNはNaNと一致する」と指示します。単一の値であれば、assert math.isnan(result)と書く方がより直感的でわかりやすいでしょう。nan_ok=Trueが真価を発揮するのは、通常の数値の中に1つのNaNが混ざっているようなコンテナを扱う場合です。たとえば、[1.5, nan, 2.0] == pytest.approx([1.5, nan, 2.0], nan_ok=True)はTrueになります。
保証していない順序と、止まってくれない時間
「ほぼ合っている」という結果になるのは浮動小数点数だけではありません。同じ考え方が必要となるケースがさらに2つあります。何を保証しているかを明確にし、保証されていることだけをテストすることです。
順序。 以下の関数は、set(セット)を使って複数の投稿からタグを収集します:
tags.py:
def unique_tags(posts):
tags = set()
for post in posts:
tags.update(post["tags"])
return list(tags)test_tags.py:
from tags import unique_tags
POSTS = [
{"tags": ["python", "testing"]},
{"tags": ["testing", "floats"]},
]
def test_order_is_a_guess():
assert unique_tags(POSTS) == ["floats", "python", "testing"]
def test_sorted():
assert sorted(unique_tags(POSTS)) == ["floats", "python", "testing"]
def test_as_a_set():
assert set(unique_tags(POSTS)) == {"floats", "python", "testing"}文字列のハッシュ化はプロセスごとにランダム化されるため、セット内の文字列の順序は実行ごとに変化します。シード値を固定するとそれがはっきりと分かります。PYTHONHASHSEED=1 pytest -q:
F.. [100%]
=================================== FAILURES ===================================
____________________________ test_order_is_a_guess _____________________________
def test_order_is_a_guess():
> assert unique_tags(POSTS) == ["floats", "python", "testing"]
E AssertionError: assert ['python', 't...ng', 'floats'] == ['floats', 'p...n', 'testing']
E
E At index 0 diff: 'python' != 'floats'
E Use -v to get more diff
test_tags.py:10: AssertionError
=========================== short test summary info ============================
FAILED test_tags.py::test_order_is_a_guess - AssertionError: assert ['python'...
1 failed, 2 passed in 0.01sPYTHONHASHSEED=2を指定してもう一度実行すると、3つともすべてパスします。同じコード、同じテストなのに、異なる結果になります。これがいわゆる不安定なテスト(flaky test)であり、常に失敗するテストよりも厄介です。関数が順序を保証していないのであれば、順序をテストしてはいけません。sorted(...)で比較するか、setとして比較してください。1つ注意点があります。setに変換すると重複も消えてしまいます。重複が重要である場合は、sortedを使うか、要素数をカウントするcollections.Counterを使用してください。
時間。 コードの内部で取得されたタイムスタンプと、テスト内で取得されたタイムスタンプが完全に等しくなることは決してありません。2つの呼び出しの間に時間が経過するからです。代わりに、時間の範囲(ウィンドウ)をテストしてください:
orders.py:
from datetime import datetime, timezone
def make_order(item):
return {"item": item, "created_at": datetime.now(timezone.utc)}test_orders.py:
from datetime import datetime, timezone
from orders import make_order
def test_created_between_before_and_after():
before = datetime.now(timezone.utc)
order = make_order("pen")
after = datetime.now(timezone.utc)
assert before <= order["created_at"] <= after. [100%]
1 passed in 0.01sこのテストは厳密であり、許容誤差をまったく必要としません。タイムスタンプが、記録した2つの時刻の間に収まっていればよいのです。もし許容誤差を使いたい場合、abs=にtimedeltaを指定すれば、approxはdatetimeも受け付けます: order["created_at"] == pytest.approx(now, abs=timedelta(seconds=1))。テストにおいて時間を1つの厳密な値に固定する必要がある場合の解決策は、時計そのものを制御することです。これはコースの後半でmonkeypatchを使って学びます。
完全な例
stats.py:
import math
from decimal import ROUND_HALF_UP, Decimal
CENT = Decimal("0.01")
def mean(values):
if not values:
return math.nan
return sum(values) / len(values)
def shares(counts):
total = sum(counts.values())
return {name: count / total for name, count in counts.items()}
def with_vat(price, rate=Decimal("0.15")):
return (price * (1 + rate)).quantize(CENT, rounding=ROUND_HALF_UP)
def countries(orders):
return list({order["country"] for order in orders})test_stats.py:
import math
from decimal import Decimal
import pytest
from stats import countries, mean, shares, with_vat
def test_mean_of_measurements():
# 0.6000000000000001 / 3 -- close, never exact
assert mean([0.1, 0.2, 0.3]) == pytest.approx(0.2)
def test_mean_near_zero():
# (0.1 + 0.2 - 0.3) / 3 is about 1.9e-17, not 0.0
assert mean([0.1, 0.2, -0.3]) == pytest.approx(0.0, abs=1e-9)
def test_mean_of_nothing_is_nan():
assert math.isnan(mean([]))
def test_shares_are_fractions():
result = shares({"tea": 1, "coffee": 2})
assert result == pytest.approx({"tea": 1 / 3, "coffee": 2 / 3})
assert sum(result.values()) == pytest.approx(1.0)
def test_shares_to_two_places():
result = shares({"tea": 1, "coffee": 2})
assert result == pytest.approx({"tea": 0.33, "coffee": 0.67}, abs=0.005)
def test_vat_is_exact_to_the_cent():
assert with_vat(Decimal("19.99")) == Decimal("22.99")
assert with_vat(Decimal("0.10")) == Decimal("0.12")
def test_vat_refuses_a_float():
with pytest.raises(TypeError):
with_vat(19.99)
def test_countries_ignores_order_and_duplicates():
orders = [{"country": "BD"}, {"country": "IN"}, {"country": "BD"}]
assert sorted(countries(orders)) == ["BD", "IN"]続けてpytest -vを実行します:
============================= test session starts ==============================
collecting ... collected 8 items
test_stats.py::test_mean_of_measurements PASSED [ 12%]
test_stats.py::test_mean_near_zero PASSED [ 25%]
test_stats.py::test_mean_of_nothing_is_nan PASSED [ 37%]
test_stats.py::test_shares_are_fractions PASSED [ 50%]
test_stats.py::test_shares_to_two_places PASSED [ 62%]
test_stats.py::test_vat_is_exact_to_the_cent PASSED [ 75%]
test_stats.py::test_vat_refuses_a_float PASSED [ 87%]
test_stats.py::test_countries_ignores_order_and_duplicates PASSED [100%]
============================== 8 passed in 0.01s ===============================8つのテストがあり、「テストを書く前に」の計画の各行に1つずつ対応しています。それぞれが意図を持って比較方法を選択しています:
meanは浮動小数点数の計算なので、デフォルトのapproxを使用しています。- ゼロになるはずの平均値は
1.9e-17として算出されます。ゼロに対しては相対許容誤差が役に立たないため、テスト側で正当な理由のあるabs=を指定しています。ここでは10億分の1未満のズレは単なる丸めノイズとみなせます。 - 空の入力に対する平均値は設計上NaNとなるため、
==では決してパスしないことからmath.isnanを使用しています。 sharesは浮動小数点数の辞書を返すため、辞書全体に対してapproxを使用しています。2つ目のテストでは、小数第2位に丸められた数値と比較するため、明示的に許容誤差(abs=0.005、100分の1の半分)を指定しています。with_vatはお金を扱うため、Decimalと厳密な==を使用しています。0.10 × 1.15 = 0.115は丸めルールを証明するケースであり、ROUND_HALF_UPによって0.12になります。float型の価格は、暗黙的に変換されるのではなくエラーとして拒否されます。この拒否の振る舞いも仕様(契約)の一部であるため、前の章で学んだようにpytest.raisesを使った独自のテストを用意しています。countriesは順序を保証しないため、テストでは比較する前にソートしています。
問題が起きたとき
assert 0.30000000000000004 == 0.3 計算によって得られた浮動小数点数が==で比較されています。期待値をラップしてください: == pytest.approx(0.3)。その値がお金である場合は、コードを修正して代わりにDecimalを使用してください。
AssertionError: approx() is not supported in a boolean context. 比較演算子を書かずにassert pytest.approx(x)と記述しています。エラーメッセージが示唆しているように、approxは==の片側に置かれたときにのみ意味を持ちます: assert a == approx(b)。
TypeError: '>' not supported between instances of 'ApproxScalar' and 'float' approxが対応しているのは==と!=だけです。「以上」や「以下」を検証したい場合は、通常の数値同士を比較してください: assert result > 0.2。
Impossible to compare lists with different sizes. リストとapproxのリストの長さが異なっています。許容誤差が適用されるのは値に対してのみであり、要素数に対してではありません。レポートの次の行に両方の長さが表示されます。
assert nan == nan ± ??? 両辺にNaNがあり、NaNは何とも等しくなりません。math.isnan(result)を使用するか、期待値がNaNである場合はnan_ok=Trueを渡してください。
*`TypeError: unsupported operand type(s) for : 'decimal.Decimal' and 'float'** 不正確な数値が紛れ込むのを防ぐため、Decimalは意図的にfloatとの混在演算を拒否します。もう一方のオペランドもDecimalとして記述してください: Decimal("1.15")`。
レポート内にDecimal('0.1000000000000000055511151231257827021181583404541015625')が表示される DecimalがDecimal(0.1)のようにfloatから生成され、その誤差を引き継いでしまっています。文字列から生成してください: Decimal("0.1")。
ある実行ではパスし、次の実行では失敗するテスト setに由来する順序や、==で比較されているタイムスタンプがないか探してみてください。sorted(...)またはsetで比較し、時間は範囲(ウィンドウ)としてテストしてください。
ステップ 4 / 6 — 予想する
理解度をチェック
100万分の1はゼロに対して「十分に近い」でしょうか? この2行は何を出力しますか?
import pytest
print(0.000001 == pytest.approx(0.0))
print(0.000001 == pytest.approx(0.0, abs=1e-5))- AFalse True
- BTrue True
- CFalse False
- DTrue False
このテストはパスします。何が問題なのでしょうか?
import pytest
def test_invoice_total():
expected = 1_000_000.00
charged = 1_000_000.99
assert charged == pytest.approx(expected)- A何も問題はない — `approx`は金額を比較する正しい方法である
- B`approx`が厳格すぎるため、時々テストが失敗する
- C`approx`は大きな数値には機能しないため、結果には意味がない
- D請求額が99セント高すぎるにもかかわらずパスしてしまう — 金額には`Decimal`と`==`が必要である
この関数は順序を保証しておらず、各タグを1回ずつ返します。どのアサートが正しいですか?
def unique_tags(posts):
tags = set()
for post in posts:
tags.update(post["tags"])
return list(tags)- Aassert unique_tags(POSTS) == ["floats", "python"]
- Bassert set(unique_tags(POSTS)) is {"floats", "python"}
- Cassert sorted(unique_tags(POSTS)) == ["floats", "python"]
- Dassert unique_tags(POSTS) == pytest.approx(["floats", "python"])
回答にはアカウントが必要です
ログインして答えを確認
問題は上にあります。頭の中で答えを考えることがいちばん大切です。ログインすると、正解・解説・3 段階のヒントを確認できます。
やってみよう
3つの関数を持つgeometry.pyを作成してください:
circle_area(r):math.pi * r * rを返すsplit_bill(total, people):totalはDecimal型で、セント単位のDecimalの分担額リストを返す。この分担額の合計は厳密にtotalと一致しなければならない(均等に割り切れない場合、最初の数人が1セント多く支払う)normalise(values): すべての値をその合計で割り、そのリストを返す
次に、test_geometry.pyを作成してください:
circle_area(1)とcircle_area(0.1)をapproxでテストする。次にcircle_area(0)をapprox(0.0)に対してテストし、なぜこのケースではabs=が必要ないのかを自分自身に説明してみる。split_bill(Decimal("10.00"), 3)を厳密な==でテストし、結果のsum(...)がDecimal("10.00")と等しくなることをテストする。normalise([1, 1, 1])がおよそ[1/3, 1/3, 1/3]になること、およびその合計がapprox(1.0)になることをテストする。
その上で、1つの実験を行ってみてください。3人で1_000_000.00の勘定を割り勘にするテストをfloat版で作成し、分担額をapproxで比較し、期待される分担額の1つを1セントずらしてみます。テストはそのズレに気づくでしょうか? その答えこそが、お金をDecimalで扱うべき理由です。
解答例
geometry.py:
import math
from decimal import Decimal
def circle_area(r):
return math.pi * r * r
def split_bill(total, people):
cents = int(total * 100)
base, extra = divmod(cents, people)
return [Decimal(base + (1 if i < extra else 0)) / 100 for i in range(people)]
def normalise(values):
total = sum(values)
return [value / total for value in values]test_geometry.py:
import math
from decimal import Decimal
import pytest
from geometry import circle_area, normalise, split_bill
def test_area_of_unit_circle():
assert circle_area(1) == pytest.approx(math.pi)
def test_area_of_small_circle():
# 0.1 * 0.1 is not exactly 0.01, so the result needs a tolerance
assert circle_area(0.1) == pytest.approx(math.pi / 100)
def test_area_of_point_is_zero():
# 0 * anything is exactly 0.0, so the tiny default abs floor is enough
assert circle_area(0) == pytest.approx(0.0)
def test_split_is_exact_to_the_cent():
assert split_bill(Decimal("10.00"), 3) == [
Decimal("3.34"),
Decimal("3.33"),
Decimal("3.33"),
]
def test_split_adds_up_to_the_bill():
assert sum(split_bill(Decimal("10.00"), 3)) == Decimal("10.00")
def test_normalise_gives_fractions():
assert normalise([1, 1, 1]) == pytest.approx([1 / 3, 1 / 3, 1 / 3])
def test_normalised_values_sum_to_one():
assert sum(normalise([1, 1, 1])) == pytest.approx(1.0)
def test_float_split_misses_a_cent():
# The experiment: a share that is one cent wrong still "passes" with floats
wrong_share = 333_333.33 + 0.01
assert wrong_share == pytest.approx(333_333.33)続けてpytest -vを実行します:
============================= test session starts ==============================
collecting ... collected 8 items
test_geometry.py::test_area_of_unit_circle PASSED [ 12%]
test_geometry.py::test_area_of_small_circle PASSED [ 25%]
test_geometry.py::test_area_of_point_is_zero PASSED [ 37%]
test_geometry.py::test_split_is_exact_to_the_cent PASSED [ 50%]
test_geometry.py::test_split_adds_up_to_the_bill PASSED [ 62%]
test_geometry.py::test_normalise_gives_fractions PASSED [ 75%]
test_geometry.py::test_normalised_values_sum_to_one PASSED [ 87%]
test_geometry.py::test_float_split_misses_a_cent PASSED [100%]
============================== 8 passed in 0.01s ===============================それぞれの設計判断の理由:
- 期待される面積は
math.piおよびmath.pi / 100と記述し、実行結果からコピーした長い小数にはしていません。コード自身の出力からコピーした期待値は何のテストにもなりません。バグがあっても一致してしまうからです。 circle_area(0)にabs=が必要ないのは、math.pi * 0 * 0が厳密に0.0だからです。吸収すべき丸め誤差が存在せず、デフォルトの下限である1e-12で十分すぎます。abs=は、本来ゼロになるはずなのに極めて小さなゼロ以外の数値として得られる結果のために使います。split_billはセント単位の整数で処理しています。 合計金額をセント単位の整数に変換し、divmodで割り算を行って、余りを1セントずつ配分しています。整数は厳密であるため、構造上、分担額の合計は必ず一致します。そのため、テストではDecimal値に対して通常の==を使用できます。- 請求額に対して2つの独立したテストを用意しています。 1つは各人の分担額のため、もう1つは合計額のためです。もし割り勘のルールが変わった場合(たとえば、最後の人が余りの1セントを払うなど)、1つ目のテストは失敗しますが2つ目のテストは依然としてパスするため、どの保証内容が変わったのかが正確に分かります。
normaliseは浮動小数点数を返すため、リスト全体に対するテストと合計に対するテストの両方でapproxを使用しています。- 最後のテストは実験そのものであり、テストそれ自体が自己ドキュメントとなるようにテスト関数として残しています。この桁数になると、1セント多すぎる分担額であっても
approxをパスしてしまいます。333,333.33の100万分の1は約0.33だからです。パスしてしまうこと自体がバグであり、これこそがsplit_billでDecimalと==を使用する理由です。