फ़्लोट और pytest.approx — "काफ़ी क़रीब" की टेस्टिंग
0.1 + 0.2 == 0.3 क्यों False है, pytest.approx डिफ़ॉल्ट रूप से कितना अंतर सहता है, rel और abs कब चाहिए, और पैसे के लिए approx नहीं बल्कि Decimal क्यों। साथ में NaN, बिना क्रम वाले नतीजे और टाइमस्टैम्प।
- 1समस्या
- 2समझें
- 3उदाहरण
- 4अनुमान
- 5स्वयं करें
- 6चुनौती
वह समस्या जिसे हम हल कर रहे हैं
पायथन से एक ऐसा सवाल पूछिए जिसका जवाब कोई बच्चा भी दे सकता है:
print(0.1 + 0.2)
print(0.1 + 0.2 == 0.3)0.30000000000000004
Falseयह आपकी मशीन का बग नहीं है, और न ही पायथन का। लगभग हर प्रोग्रामिंग भाषा दशमलव भिन्नों (decimal fractions) को इसी तरह स्टोर करती है। और यह बात सीधे आपके टेस्ट्स में घुस आती है।
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फ़ंक्शन सही है। टेस्ट ग़लत है। वह ऐसी सटीकता माँग रहा है जो floating-point संख्याएँ दे ही नहीं सकतीं। यह अध्याय सही सवाल पूछने के बारे में है: क्या यह काफ़ी क़रीब है? और उन मामलों के बारे में भी जहाँ "काफ़ी क़रीब" ही ग़लत सवाल है — और उनमें सबसे बड़ा मामला पैसा है।
इस अध्याय के अंत में आप कर पाएंगे
- दो वाक्यों में समझाना कि
0.1 + 0.2 == 0.3क्योंFalseहै - टेस्ट में
pytest.approxसे float की तुलना करना, और उसका डिफ़ॉल्ट टॉलरेंस पढ़ना rel=औरabs=से अपना टॉलरेंस तय करना, और जानना कि शून्य के पास कौन-सा चाहिए- list, tuple और dict पर
approxइस्तेमाल करना, और अंतर होने पर मिलने वाली रिपोर्ट पढ़ना pytest.approx,math.iscloseऔरDecimalमें से सही को चुनना- NaN, ऐसे नतीजे जिनके क्रम का वादा नहीं है, और टाइमस्टैम्प को टेस्ट करना
ज़रूरी शर्तें: exceptions की टेस्टिंग।
टेस्ट लिखने से पहले
संख्याओं के मामले में सबसे अहम फ़ैसला कोई भी कोड लिखने से पहले होता है: हर नतीजे के लिए, कोड ने किस तरह की बराबरी का वादा किया है? ग़लत चुनाव किया तो टेस्ट या तो flaky होगा (बहुत सख़्त) या अंधा (बहुत ढीला)। पहले तीन बातें तय कर लीजिए।
1. कॉन्ट्रैक्ट। वह छोटा-सा stats.py मॉड्यूल लीजिए जिस पर यह अध्याय ख़त्म होता है। सीधे शब्दों में, वह यह वादा करता है:
mean(values)floats की लिस्ट का औसत लौटाता है, और ख़ाली लिस्ट के लिएnan।shares(counts)गिनतियों को ऐसे हिस्सों (fractions) में बदलता है जिनका जोड़ एक होता है।with_vat(price)एकDecimalकीमत में 15% VAT जोड़ता है, और उसे सेंट तक half-up राउंड करता है।floatकीमत कोTypeErrorके साथ ठुकरा दिया जाता है।countries(orders)हर देश एक बार लौटाता है। यह किसी क्रम का वादा नहीं करता।
हर लाइन पहले से बता देती है कि तुलना कैसे करनी है। "floats का औसत" का मतलब है टॉलरेंस। "सेंट तक राउंड" का मतलब है सटीक। "ठुकरा दिया" का मतलब है pytest.raises। "किसी क्रम में नहीं" का मतलब है तुलना से पहले sort करना या set इस्तेमाल करना।
2. सेटअप। कुछ नया इंस्टॉल नहीं करना है। आपको चाहिए पहले अध्याय वाला venv जिसमें pytest हो, टेस्ट फ़ाइल से import हो सकने वाला मॉड्यूल (दोनों एक ही फ़ोल्डर में, और pytest वहीं से चलाएँ), और स्टैंडर्ड लाइब्रेरी के दो मॉड्यूल, math और decimal। न fixtures, न फ़ाइलें, न नेटवर्क।
3. योजना। टेस्ट लिखने से पहले केस लिख लीजिए। हर पंक्ति के लिए सिर्फ़ अपेक्षित वैल्यू नहीं, तुलना का तरीका भी तय कीजिए:
| केस | इनपुट | अपेक्षित | किससे तुलना | | --- | --- | --- | --- | | सामान्य रास्ता, float अंकगणित | 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 | | floats का कंटेनर | shares({"tea": 1, "coffee": 2}) | {"tea": 1/3, "coffee": 2/3} | पूरी dict पर 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 के। यह पायथन का व्यवहार है, आपका नहीं, और इसे बाँधने से आपका टेस्ट float फ़ॉर्मेट का टेस्ट बन जाता है। set से आने वाले नतीजे का क्रम टेस्ट न करें, न ही टाइमस्टैम्प का सटीक माइक्रोसेकंड। दोनों में से किसी का वादा नहीं किया गया है। और टॉलरेंस को तब तक चौड़ा करके न चुनें जब तक टेस्ट हरा न हो जाए। टॉलरेंस समस्या के बारे में एक बयान है ("आधा डिग्री", "एक प्रतिशत"), टेस्ट के बारे में कभी नहीं।
नीचे के सेक्शन उस टेबल का हर टूल सिखाते हैं। अंत का पूरा उदाहरण योजना को पंक्ति-दर-पंक्ति लागू करता है।
0.1 + 0.2 बराबर 0.3 क्यों नहीं है
float बाइनरी में, एक तय जगह (64 bits) में स्टोर होता है। कुछ भिन्नों का कोई सटीक बाइनरी रूप नहीं होता, ठीक वैसे ही जैसे 1/3 का कोई सटीक दशमलव रूप नहीं होता: 0.3333… अनंत तक चलता है, और कहीं न कहीं लिखना रोकना पड़ता है। बाइनरी में 0.1 ऐसा ही एक भिन्न है। पायथन उसके सबसे क़रीब की वह संख्या रखता है जो वह रख सकता है।
पायथन सामान्यतः जितने अंक दिखाता है उससे ज़्यादा माँगकर आप स्टोर की गई वैल्यूज़ देख सकते हैं:
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 भी। इन्हें जोड़ने पर दोनों ग़लतियाँ भी जुड़ जाती हैं। उधर 0.3 बाल भर छोटा स्टोर होता है। दोनों नतीजे पड़ोसी floats पर पड़ते हैं, और == floats की तुलना bit-दर-bit करता है, इसलिए वह False कहता है।
इससे इस अध्याय का एक ही नियम निकलता है: अंकगणित से निकले float पर कभी == इस्तेमाल न करें। जो float आपने ख़ुद टाइप किया और बिना बदले आगे भेजा, वह ठीक है। जिस float को जोड़ा, भाग दिया या गुणा किया गया हो, उसकी तुलना टॉलरेंस के साथ होनी चाहिए।
pytest.approx — एक टॉलरेंस के भीतर बराबर
टेस्ट की एक लाइन बदलिए:
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डिफ़ॉल्ट रूप से दो टॉलरेंस होते हैं, और दोनों में से बड़ा वाला जीतता है:
- relative
rel=1e-6: अपेक्षित वैल्यू का दस लाखवाँ हिस्सा।0.3के लिए यह0.0000003है; दस लाख के लिए1। - absolute
abs=1e-12: एक तय फ़्लोर, ताकि टॉलरेंस कभी पूरी तरह शून्य न हो।
लगभग हर जगह relative टॉलरेंस ही मायने रखता है, क्योंकि वह संख्या के साथ बढ़ता-घटता है। 0.3 के सामने 0.0000003 की ग़लती शोर भर है; 1_000_000 के सामने यही बेतुका सख़्त होता।
शून्य के पास तस्वीर बदल जाती है। शून्य का दस लाखवाँ हिस्सा शून्य ही है, इसलिए सिर्फ़ छोटा-सा absolute फ़्लोर बचता है:
import pytest
print(0.000001 == pytest.approx(0.0))
print(0.000001 == pytest.approx(0.0, abs=1e-5))False
Trueइंसान को दस लाखवाँ हिस्सा शून्य जैसा दिखता है, पर approx(0.0) को नहीं। अगर नतीजा शून्य या उसके पास होना चाहिए, तो approx को absolute टॉलरेंस ख़ुद दीजिए।
अपना टॉलरेंस चुनना: 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का मतलब है "आधी इकाई के भीतर, संख्या चाहे जितनी बड़ी हो"।rel=0.01का मतलब है "अपेक्षित वैल्यू के एक प्रतिशत के भीतर"।100के लिए यह± 1है।- दोनों दें तो, डिफ़ॉल्ट की तरह, बड़ा टॉलरेंस जीतता है: यहाँ
abs=5100 के एक प्रतिशत को हरा देता है।
चुनते समय एक वाक्य मन में रखिए। "तापमान की रीडिंग आधा डिग्री इधर-उधर हो सकती है" यानी abs=0.5। "अनुमान एक प्रतिशत इधर-उधर हो सकता है" यानी rel=0.01। और जब अपेक्षित वैल्यू शून्य हो, तो सिर्फ़ abs ही मदद कर सकता है।
ग़लत तरीका है फ़ेल होते टेस्ट से शुरू करना और टॉलरेंस को तब तक चौड़ा करना जब तक वह हरा न हो जाए। जो टॉलरेंस टेस्ट पास कराने के लिए चुना गया हो, वह बग को भी उतनी ही ख़ुशी से पास कर देगा:
import pytest
print(0.95 == pytest.approx(1.0, rel=0.1))Trueपाँच प्रतिशत ग़लत नतीजा अब "बराबर" गिना जाता है। अगर कोड को राउंडिंग की ग़लती तक सटीक होना चाहिए, तो डिफ़ॉल्ट रहने दीजिए। ढीले टॉलरेंस के पीछे एक ऐसी वजह होनी चाहिए जिसे आप ज़ोर से कह सकें, और वह वजह उसके बगल में एक कमेंट में लिखी होनी चाहिए।
List, tuple और dict
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])list की तुलना स्थिति (position) से होती है। dict की तुलना key से होती है, इसलिए keys का क्रम मायने नहीं रखता, पर keys ख़ुद बिल्कुल मेल खानी चाहिए। लंबाई भी मेल खानी चाहिए।
असली फ़ायदा तब दिखता है जब तुलना फ़ेल होती है। 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.01sइसे E लाइनों के नीचे से ऊपर की ओर पढ़िए। तीन में से एक तत्व मेल नहीं खाया। टेबल बताती है कि कौन-सा (index 1), क्या निकला (0.25) और क्या अपेक्षित था, उसके टॉलरेंस के साथ। तत्व 0, यानी 0.30000000000000004, सूची में नहीं है। वह काफ़ी क़रीब था, इसलिए समस्या वह नहीं है। dict के साथ Index कॉलम में key दिखती है।
approx किस काम के लिए नहीं है
approx floats के लिए बना है। उसे कुछ और दीजिए तो वह चुपचाप सादे == पर लौट आता है:
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
Truestring के लिए approx कुछ नहीं जोड़ता, और दिखाने को कोई टॉलरेंस नहीं होता। पूर्णांक के लिए वह कुछ ऐसा जोड़ता है जो शायद आप नहीं चाहते थे। अगर count_items() को 10 लौटाना है, तो 10.000001 एक बग है, और approx(10) उसे निकल जाने देता है। पूर्णांक, string, boolean और None की तुलना == से होती है।
math.isclose — स्टैंडर्ड लाइब्रेरी वाला तरीका
पायथन के पास टॉलरेंस जाँचने का अपना तरीका है, math.isclose। उसके डिफ़ॉल्ट अलग हैं: 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 के अलावा कुछ भी कभी शून्य के क़रीब नहीं होता। वही सबक जो पहले था, बस और तीखा।
एप्लिकेशन कोड में math.isclose ही सही टूल है, क्योंकि production में pytest इंस्टॉल नहीं होता। टेस्ट में 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 के पास list या dict के लिए कोई जवाब नहीं है।
पैसा टॉलरेंस की समस्या नहीं है
जब कीमतों का टेस्ट 0.30000000000000004 के साथ फ़ेल होता है, तो approx की ओर हाथ बढ़ाना लुभावना लगता है। देखिए कि किसी बड़े इनवॉइस के लिए उस टॉलरेंस का मतलब क्या है:
import pytest
expected = 1_000_000.00
charged = 1_000_000.99
print(charged == pytest.approx(expected))Trueनिन्यानवे सेंट का अंतर, और टेस्ट पास। दस लाख का दस लाखवाँ हिस्सा पूरी एक इकाई है। पैसे के लिए "काफ़ी क़रीब" सही जवाब नहीं है। जिस ग्राहक से एक सेंट ज़्यादा लिया गया, उससे ग़लत रक़म ली गई है।
सुधार टेस्ट में नहीं है। कोड को पैसा float में रखना ही नहीं चाहिए। पायथन का decimal.Decimal दशमलव अंकों को ठीक वैसे ही स्टोर करता है जैसे वे लिखे गए:
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 हमेशा string से बनाइए।
सेंट तक राउंडिंग स्पष्ट रूप से होती है, और नियम आप चुनते हैं:
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तो बँटवारा सीधा है। माप (लंबाई, औसत, अनुपात, सेंसर रीडिंग) floats हैं, जिन्हें approx से टेस्ट किया जाता है। पैसे की रक़में Decimal हैं, जिन्हें == से टेस्ट किया जाता है।
NaN अपने ही बराबर नहीं होता
float("nan") का मतलब है "not a number" — यह 0 * inf जैसी किसी चीज़ का या किसी ग़ायब रीडिंग का नतीजा होता है। परिभाषा से यह किसी के बराबर नहीं, ख़ुद के भी नहीं:
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 अपेक्षित है, और वह NaN से मेल खाता है"। एक अकेली वैल्यू के लिए assert math.isnan(result) ज़्यादा साफ़ पढ़ा जाता है। nan_ok=True कंटेनरों पर अपनी जगह बनाता है, जहाँ आम संख्याओं के बीच एक NaN बैठा हो: [1.5, nan, 2.0] == pytest.approx([1.5, nan, 2.0], nan_ok=True) का नतीजा True है।
वह क्रम जिसका वादा नहीं किया, और वह समय जो ठहरता नहीं
सिर्फ़ floats ही "लगभग" सही नहीं निकलते। दो और मामलों में यही सोच चाहिए: तय कीजिए कि वादा किस चीज़ का है, और सिर्फ़ उसी को टेस्ट कीजिए।
क्रम। यह फ़ंक्शन कुछ पोस्ट्स के टैग एक 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"}set में strings का क्रम हर रन में बदल सकता है, क्योंकि string hashing हर प्रोसेस में रैंडम होती है। seed तय करने से यह दिखने लगता है। 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.01sइसे PYTHONHASHSEED=2 के साथ दोबारा चलाइए, तीनों पास हो जाते हैं। वही कोड, वही टेस्ट, अलग नतीजा। यह एक flaky टेस्ट है, और यह फ़ेल होते टेस्ट से भी बुरा है। अगर फ़ंक्शन किसी क्रम का वादा नहीं करता, तो क्रम टेस्ट न करें। sorted(...) की तुलना कीजिए, या set के रूप में तुलना कीजिए। एक सावधानी: set डुप्लिकेट भी छिपा देता है। अगर डुप्लिकेट मायने रखते हैं, तो sorted इस्तेमाल कीजिए, या collections.Counter, जो उन्हें गिनता है।
समय। आपके कोड के अंदर लिया गया टाइमस्टैम्प टेस्ट में लिए गए टाइमस्टैम्प के कभी बराबर नहीं हो सकता, क्योंकि दोनों कॉल्स के बीच समय बीत जाता है। इसके बजाय एक खिड़की (window) टेस्ट कीजिए:
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यह टेस्ट सटीक है और इसे किसी टॉलरेंस की ज़रूरत नहीं: स्टैम्प को आपके दर्ज किए दो पलों के बीच पड़ना ही चाहिए। अगर आप टॉलरेंस पसंद करते हैं, तो approx एक datetime भी ले लेता है जब abs= एक timedelta हो: order["created_at"] == pytest.approx(now, abs=timedelta(seconds=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 ===============================आठ टेस्ट, "टेस्ट लिखने से पहले" वाली योजना की हर पंक्ति के लिए एक। हर टेस्ट अपनी तुलना सोच-समझकर चुनता है:
meanfloats पर अंकगणित है, इसलिए डिफ़ॉल्ट के साथapprox।- जो औसत शून्य होना चाहिए वह
1.9e-17निकलता है। शून्य पर relative टॉलरेंस बेकार है, इसलिए टेस्ट एक ऐसाabs=देता है जिसे वह सही ठहरा सके: यहाँ एक अरबवें हिस्से से नीचे की हर चीज़ राउंडिंग का शोर है। - ख़ाली औसत डिज़ाइन से NaN है, इसलिए
math.isnan, क्योंकि==कभी पास नहीं हो सकता। sharesfloats की dict लौटाता है, इसलिए पूरी dict परapprox। दूसरा टेस्ट अपना टॉलरेंस (abs=0.005, सौवें का आधा) बताता है, क्योंकि वह दो दशमलव तक राउंड की गई संख्याओं से तुलना करता है।with_vatपैसा है, इसलिएDecimalऔर सटीक==।0.10 × 1.15 = 0.115वह केस है जो राउंडिंग नियम को साबित करता है:ROUND_HALF_UPउसे0.12बना देता है।- float कीमत को चुपचाप बदलने के बजाय ठुकरा दिया जाता है। यह इनकार कॉन्ट्रैक्ट का हिस्सा है, इसलिए उसे
pytest.raisesके साथ अपना अलग टेस्ट मिलता है, जैसा पिछले अध्याय में था। countriesकिसी क्रम का वादा नहीं करता, इसलिए टेस्ट तुलना से पहले sort करता है।
जब यह काम न करे
assert 0.30000000000000004 == 0.3 गणना से निकले float की तुलना == से की गई है। अपेक्षित वैल्यू को लपेटिए: == 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. list और approx वाली list की लंबाई अलग है। टॉलरेंस वैल्यूज़ पर लागू होता है, उनकी गिनती पर कभी नहीं। रिपोर्ट अगली लाइन में दोनों लंबाइयाँ देती है।
assert nan == nan ± ??? दोनों तरफ़ NaN है, और NaN कभी किसी के बराबर नहीं होता। math.isnan(result) इस्तेमाल कीजिए, या अगर NaN ही अपेक्षित वैल्यू है तो nan_ok=True दीजिए।
*`TypeError: unsupported operand type(s) for : 'decimal.Decimal' and 'float'** Decimal जानबूझकर float के साथ मिलने से इनकार करता है, ताकि कोई असटीक संख्या चुपके से न घुस आए। दूसरे operand को भी Decimal के रूप में लिखिए: Decimal("1.15")`।
रिपोर्ट में Decimal('0.1000000000000000055511151231257827021181583404541015625') एक Decimal float से बनाया गया, Decimal(0.1), और उसने float की ग़लती विरासत में ले ली। उसे string से बनाइए: Decimal("0.1")।
ऐसा टेस्ट जो एक रन में पास और अगले में फ़ेल होता है set से आने वाले क्रम को खोजिए, या ऐसे टाइमस्टैम्प को जिसकी तुलना == से हुई हो। sorted(...) या set की तुलना कीजिए, और समय को एक खिड़की के रूप में टेस्ट कीजिए।
चरण 4 / 6 — अनुमान
अपनी समझ की जाँच करें
क्या दस लाख का एक हिस्सा शून्य के "काफ़ी क़रीब" है? दोनों लाइनें क्या छापेंगी?
import pytest
print(0.000001 == pytest.approx(0.0))
print(0.000001 == pytest.approx(0.0, abs=1e-5))- ATrue True
- BTrue False
- CFalse True
- DFalse False
यह टेस्ट पास हो जाता है। इसमें गड़बड़ क्या है?
import pytest
def test_invoice_total():
expected = 1_000_000.00
charged = 1_000_000.99
assert charged == pytest.approx(expected)- Aapprox इतना सख़्त है कि टेस्ट कभी-कभी फ़ेल होगा
- B99 सेंट ज़्यादा वसूलने पर भी टेस्ट पास है — पैसे के लिए Decimal और == चाहिए
- Cबड़ी संख्याओं पर approx काम नहीं करता, इसलिए नतीजा बेमानी है
- Dकुछ भी ग़लत नहीं — पैसे की तुलना का सही तरीका approx ही है
यह फ़ंक्शन किसी क्रम का वादा नहीं करता और हर टैग एक बार लौटाता है। कौन-सा assert सही है?
def unique_tags(posts):
tags = set()
for post in posts:
tags.update(post["tags"])
return list(tags)- Aassert unique_tags(POSTS) == ["floats", "python"]
- Bassert unique_tags(POSTS) == pytest.approx(["floats", "python"])
- Cassert set(unique_tags(POSTS)) is {"floats", "python"}
- Dassert sorted(unique_tags(POSTS)) == ["floats", "python"]
उत्तर देने के लिए अकाउंट आवश्यक है
अपने उत्तर जाँचने के लिए साइन इन करें
प्रश्न ऊपर दिए गए हैं, और मन में उत्तर सोचना ही मुख्य कार्य है। सही उत्तर, व्याख्या और तीन-स्तरीय संकेत देखने के लिए साइन इन करें।
आपकी बारी
तीन फ़ंक्शन्स के साथ geometry.py लिखिए:
circle_area(r), जोmath.pi * r * rलौटाता हैsplit_bill(total, people), जहाँtotalएकDecimalहै, और जो सेंट तक केDecimalहिस्सों की list लौटाता है जिनका जोड़ ठीकtotalके बराबर हो (अगर बराबर न बँटे, तो शुरुआत के कुछ लोग एक सेंट ज़्यादा देते हैं)normalise(values), जो हर वैल्यू को उनके जोड़ से भाग देकर list लौटाता है
फिर 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_000_000.00 के बिल के लिए split टेस्ट का float वाला वर्ज़न लिखिए, हिस्सों की तुलना approx से कीजिए, और एक अपेक्षित हिस्से को एक सेंट ग़लत कर दीजिए। क्या टेस्ट इसे पकड़ता है? इसका जवाब ही वह वजह है जिससे पैसा 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पूरे सेंट में काम करता है। वह total को सेंट की पूर्णांक संख्या में बदलता है,divmodसे भाग देता है, और बचे हुए सेंट एक-एक करके बाँट देता है। पूर्णांक सटीक होते हैं, इसलिए हिस्से बनावट से ही सही जुड़ते हैं, और टेस्टDecimalवैल्यूज़ पर सादा==इस्तेमाल कर सकता है।- बिल के लिए दो अलग टेस्ट, एक हिस्सों के लिए और एक जोड़ के लिए। अगर बँटवारे का नियम बदले (मान लीजिए, अतिरिक्त सेंट आख़िरी व्यक्ति दे), तो पहला टेस्ट फ़ेल होगा और दूसरा पास रहेगा, जो ठीक-ठीक बताता है कि कौन-सा वादा बदला।
normalisefloats लौटाता है, इसलिए उसके दोनों टेस्टapproxइस्तेमाल करते हैं, पूरी list पर और जोड़ पर।- आख़िरी टेस्ट प्रयोग है, जिसे टेस्ट के रूप में रखा गया है ताकि वह ख़ुद अपना दस्तावेज़ बने। इस आकार पर एक सेंट बड़ा हिस्सा भी
approxपास कर लेता है, क्योंकि 333,333.33 का दस लाखवाँ हिस्सा लगभग 0.33 है। वह पास होता है, और यही पास होना बग है: इसीलिएsplit_billDecimalऔर==इस्तेमाल करता है।
Step 6 of 6
चुनौती — the chapter quiz
सरल से कठिन — दस प्रश्न, अंतिम वाले जानबूझकर चुनौतीपूर्ण बनाए गए हैं।
Sign in to take the quiz