अध्याय 05

फ़्लोट और pytest.approx — "काफ़ी क़रीब" की टेस्टिंग

0.1 + 0.2 == 0.3 क्यों False है, pytest.approx डिफ़ॉल्ट रूप से कितना अंतर सहता है, rel और abs कब चाहिए, और पैसे के लिए approx नहीं बल्कि Decimal क्यों। साथ में NaN, बिना क्रम वाले नतीजे और टाइमस्टैम्प।

35 मिनटPython 3.12
  1. 1समस्या
  2. 2समझें
  3. 3उदाहरण
  4. 4अनुमान
  5. 5स्वयं करें
  6. 6चुनौती

वह समस्या जिसे हम हल कर रहे हैं

पायथन से एक ऐसा सवाल पूछिए जिसका जवाब कोई बच्चा भी दे सकता है:

python
print(0.1 + 0.2)
print(0.1 + 0.2 == 0.3)
text
0.30000000000000004
False

यह आपकी मशीन का बग नहीं है, और न ही पायथन का। लगभग हर प्रोग्रामिंग भाषा दशमलव भिन्नों (decimal fractions) को इसी तरह स्टोर करती है। और यह बात सीधे आपके टेस्ट्स में घुस आती है।

test_total.py:

python
def total(prices):
    return sum(prices)


def test_total():
    assert total([0.1, 0.2]) == 0.3

फिर pytest -q:

text
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 ऐसा ही एक भिन्न है। पायथन उसके सबसे क़रीब की वह संख्या रखता है जो वह रख सकता है।

पायथन सामान्यतः जितने अंक दिखाता है उससे ज़्यादा माँगकर आप स्टोर की गई वैल्यूज़ देख सकते हैं:

python
print(f"{0.1:.20f}")
print(f"{0.2:.20f}")
print(f"{0.3:.20f}")
print(f"{0.1 + 0.2:.20f}")
text
0.10000000000000000555
0.20000000000000001110
0.29999999999999998890
0.30000000000000004441

0.1 बाल भर बड़ा स्टोर होता है, और 0.2 भी। इन्हें जोड़ने पर दोनों ग़लतियाँ भी जुड़ जाती हैं। उधर 0.3 बाल भर छोटा स्टोर होता है। दोनों नतीजे पड़ोसी floats पर पड़ते हैं, और == floats की तुलना bit-दर-bit करता है, इसलिए वह False कहता है।

इससे इस अध्याय का एक ही नियम निकलता है: अंकगणित से निकले float पर कभी == इस्तेमाल न करें। जो float आपने ख़ुद टाइप किया और बिना बदले आगे भेजा, वह ठीक है। जिस float को जोड़ा, भाग दिया या गुणा किया गया हो, उसकी तुलना टॉलरेंस के साथ होनी चाहिए।

pytest.approx — एक टॉलरेंस के भीतर बराबर

टेस्ट की एक लाइन बदलिए:

python
import pytest


def total(prices):
    return sum(prices)


def test_total():
    assert total([0.1, 0.2]) == pytest.approx(0.3)
text
.                                                                        [100%]
1 passed in 0.01s

pytest.approx(0.3) एक ऐसा ऑब्जेक्ट बनाता है जो 0.3 के काफ़ी क़रीब किसी भी संख्या के "बराबर" है। कितना क़रीब? एक को प्रिंट कीजिए, वह ख़ुद बता देगा:

python
import pytest

print(pytest.approx(0.3))
print(pytest.approx(250.0))
print(pytest.approx(1_000_000.0))
print(pytest.approx(0.0))
text
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 फ़्लोर बचता है:

python
import pytest

print(0.000001 == pytest.approx(0.0))
print(0.000001 == pytest.approx(0.0, abs=1e-5))
text
False
True

इंसान को दस लाखवाँ हिस्सा शून्य जैसा दिखता है, पर approx(0.0) को नहीं। अगर नतीजा शून्य या उसके पास होना चाहिए, तो approx को absolute टॉलरेंस ख़ुद दीजिए।

अपना टॉलरेंस चुनना: rel= और abs=

डिफ़ॉल्ट "वही गणना, थोड़े अलग तरीके से" के लिए ठीक हैं। जब आपका कोड राउंड करता है, मापता है या अनुमान लगाता है, तो तय कीजिए कि "काफ़ी क़रीब" का मतलब क्या है, और उसे लिखकर कहिए:

python
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))
text
True
False
True
100 ± 0.5
100 ± 1
100 ± 5
  • abs=0.5 का मतलब है "आधी इकाई के भीतर, संख्या चाहे जितनी बड़ी हो"।
  • rel=0.01 का मतलब है "अपेक्षित वैल्यू के एक प्रतिशत के भीतर"। 100 के लिए यह ± 1 है।
  • दोनों दें तो, डिफ़ॉल्ट की तरह, बड़ा टॉलरेंस जीतता है: यहाँ abs=5 100 के एक प्रतिशत को हरा देता है।

चुनते समय एक वाक्य मन में रखिए। "तापमान की रीडिंग आधा डिग्री इधर-उधर हो सकती है" यानी abs=0.5। "अनुमान एक प्रतिशत इधर-उधर हो सकता है" यानी rel=0.01। और जब अपेक्षित वैल्यू शून्य हो, तो सिर्फ़ abs ही मदद कर सकता है।

ग़लत तरीका है फ़ेल होते टेस्ट से शुरू करना और टॉलरेंस को तब तक चौड़ा करना जब तक वह हरा न हो जाए। जो टॉलरेंस टेस्ट पास कराने के लिए चुना गया हो, वह बग को भी उतनी ही ख़ुशी से पास कर देगा:

python
import pytest

print(0.95 == pytest.approx(1.0, rel=0.1))
text
True

पाँच प्रतिशत ग़लत नतीजा अब "बराबर" गिना जाता है। अगर कोड को राउंडिंग की ग़लती तक सटीक होना चाहिए, तो डिफ़ॉल्ट रहने दीजिए। ढीले टॉलरेंस के पीछे एक ऐसी वजह होनी चाहिए जिसे आप ज़ोर से कह सकें, और वह वजह उसके बगल में एक कमेंट में लिखी होनी चाहिए।

List, tuple और dict

approx संख्याओं का कंटेनर भी लेता है और उसकी तुलना तत्व-दर-तत्व करता है, हर तत्व के अपने टॉलरेंस के साथ:

python
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]))
text
True
True
True
approx([0.3 ± 3.0e-07, 0.7 ± 7.0e-07])

list की तुलना स्थिति (position) से होती है। dict की तुलना key से होती है, इसलिए keys का क्रम मायने नहीं रखता, पर keys ख़ुद बिल्कुल मेल खानी चाहिए। लंबाई भी मेल खानी चाहिए।

असली फ़ायदा तब दिखता है जब तुलना फ़ेल होती है। test_shares.py:

python
import pytest


def test_shares():
    assert [0.1 + 0.2, 0.25, 0.6] == pytest.approx([0.3, 0.2, 0.6])
text
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 के लिए बना है। उसे कुछ और दीजिए तो वह चुपचाप सादे == पर लौट आता है:

python
import pytest

print("abc" == pytest.approx("abc"))
print(pytest.approx("abc"))
print(10 == pytest.approx(10))
print(10.000001 == pytest.approx(10))
text
True
abc
True
True

string के लिए approx कुछ नहीं जोड़ता, और दिखाने को कोई टॉलरेंस नहीं होता। पूर्णांक के लिए वह कुछ ऐसा जोड़ता है जो शायद आप नहीं चाहते थे। अगर count_items() को 10 लौटाना है, तो 10.000001 एक बग है, और approx(10) उसे निकल जाने देता है। पूर्णांक, string, boolean और None की तुलना == से होती है।

math.isclose — स्टैंडर्ड लाइब्रेरी वाला तरीका

पायथन के पास टॉलरेंस जाँचने का अपना तरीका है, math.isclose। उसके डिफ़ॉल्ट अलग हैं: rel_tol=1e-9 (approx से ज़्यादा सख़्त) और abs_tol=0.0 (कोई फ़्लोर ही नहीं):

python
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))
text
True
False
True
True

abs_tol=0.0 के साथ, ठीक 0.0 के अलावा कुछ भी कभी शून्य के क़रीब नहीं होता। वही सबक जो पहले था, बस और तीखा।

एप्लिकेशन कोड में math.isclose ही सही टूल है, क्योंकि production में pytest इंस्टॉल नहीं होता। टेस्ट में approx को तरजीह दीजिए, और वजह है रिपोर्ट। test_close.py:

python
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)
text
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.01s

assert False बस इतना बताता है कि संख्याएँ अलग हैं। approx यह भी बताता है कि कितनी अलग, और कितने टॉलरेंस की छूट थी। ऊपर से math.isclose के पास list या dict के लिए कोई जवाब नहीं है।

पैसा टॉलरेंस की समस्या नहीं है

जब कीमतों का टेस्ट 0.30000000000000004 के साथ फ़ेल होता है, तो approx की ओर हाथ बढ़ाना लुभावना लगता है। देखिए कि किसी बड़े इनवॉइस के लिए उस टॉलरेंस का मतलब क्या है:

python
import pytest

expected = 1_000_000.00
charged = 1_000_000.99

print(charged == pytest.approx(expected))
text
True

निन्यानवे सेंट का अंतर, और टेस्ट पास। दस लाख का दस लाखवाँ हिस्सा पूरी एक इकाई है। पैसे के लिए "काफ़ी क़रीब" सही जवाब नहीं है। जिस ग्राहक से एक सेंट ज़्यादा लिया गया, उससे ग़लत रक़म ली गई है।

सुधार टेस्ट में नहीं है। कोड को पैसा float में रखना ही नहीं चाहिए। पायथन का decimal.Decimal दशमलव अंकों को ठीक वैसे ही स्टोर करता है जैसे वे लिखे गए:

python
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))
text
0.30
True
False
0.1000000000000000055511151231257827021181583404541015625

Decimal के साथ == फिर से सटीक हो जाता है, और पैसे के टेस्ट को यही चाहिए। आख़िरी लाइन जाल है। Decimal(0.1) एक float से बना है, इसलिए वह float की ग़लती को पूरी ईमानदारी से कॉपी कर लेता है। Decimal हमेशा string से बनाइए।

सेंट तक राउंडिंग स्पष्ट रूप से होती है, और नियम आप चुनते हैं:

python
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))
text
2.9985
3.00

तो बँटवारा सीधा है। माप (लंबाई, औसत, अनुपात, सेंसर रीडिंग) floats हैं, जिन्हें approx से टेस्ट किया जाता है। पैसे की रक़में Decimal हैं, जिन्हें == से टेस्ट किया जाता है।

NaN अपने ही बराबर नहीं होता

float("nan") का मतलब है "not a number" — यह 0 * inf जैसी किसी चीज़ का या किसी ग़ायब रीडिंग का नतीजा होता है। परिभाषा से यह किसी के बराबर नहीं, ख़ुद के भी नहीं:

python
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))
text
False
False
True
True

approx भी इसी नियम पर चलता है, जब तक आप 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:

python
def unique_tags(posts):
    tags = set()
    for post in posts:
        tags.update(post["tags"])
    return list(tags)

test_tags.py:

python
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:

text
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:

python
from datetime import datetime, timezone


def make_order(item):
    return {"item": item, "created_at": datetime.now(timezone.utc)}

test_orders.py:

python
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
text
.                                                                        [100%]
1 passed in 0.01s

यह टेस्ट सटीक है और इसे किसी टॉलरेंस की ज़रूरत नहीं: स्टैम्प को आपके दर्ज किए दो पलों के बीच पड़ना ही चाहिए। अगर आप टॉलरेंस पसंद करते हैं, तो approx एक datetime भी ले लेता है जब abs= एक timedelta हो: order["created_at"] == pytest.approx(now, abs=timedelta(seconds=1))। जब किसी टेस्ट को समय को एक सटीक वैल्यू पर बाँधना ही हो, तो जवाब है घड़ी को अपने नियंत्रण में लेना, और वह कोर्स में आगे monkeypatch के साथ आता है।


पूर्ण उदाहरण

stats.py:

python
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:

python
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:

text
============================= 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 ===============================

आठ टेस्ट, "टेस्ट लिखने से पहले" वाली योजना की हर पंक्ति के लिए एक। हर टेस्ट अपनी तुलना सोच-समझकर चुनता है:

  • mean floats पर अंकगणित है, इसलिए डिफ़ॉल्ट के साथ approx।
  • जो औसत शून्य होना चाहिए वह 1.9e-17 निकलता है। शून्य पर relative टॉलरेंस बेकार है, इसलिए टेस्ट एक ऐसा abs= देता है जिसे वह सही ठहरा सके: यहाँ एक अरबवें हिस्से से नीचे की हर चीज़ राउंडिंग का शोर है।
  • ख़ाली औसत डिज़ाइन से NaN है, इसलिए math.isnan, क्योंकि == कभी पास नहीं हो सकता।
  • shares floats की 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 की तुलना कीजिए, और समय को एक खिड़की के रूप में टेस्ट कीजिए।