অধ্যায় 05

দশমিক সংখ্যা আর pytest.approx — "যথেষ্ট কাছাকাছি" টেস্ট করা

কেন 0.1 + 0.2 == 0.3 মিথ্যা, 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

এটা আপনার কম্পিউটারের ত্রুটি নয়, পাইথনেরও ত্রুটি নয়। প্রায় সব প্রোগ্রামিং ভাষা দশমিক ভগ্নাংশ এভাবেই রাখে। আর এই জিনিসটা সোজা এসে ঢোকে আপনার টেস্টে।

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

ফাংশনটি ঠিক আছে। ভুল টেস্টটিতে। সে এমন নিখুঁত মিল চাইছে, যা ফ্লোটিং-পয়েন্ট সংখ্যা দিতে পারে না। এই অধ্যায় সঠিক প্রশ্নটা করা নিয়ে: মানটা কি যথেষ্ট কাছাকাছি? আর সেইসব ক্ষেত্র নিয়েও, যেখানে "যথেষ্ট কাছাকাছি" প্রশ্নটাই ভুল — যার প্রধানটি হলো টাকা।

এই অধ্যায় শেষে আপনি পারবেন

  • দুই বাক্যে বোঝাতে, কেন 0.1 + 0.2 == 0.3-এর ফল False
  • টেস্টে pytest.approx দিয়ে ফ্লোট তুলনা করতে, আর তার ডিফল্ট সহনসীমা পড়তে
  • rel= আর abs= দিয়ে নিজের সহনসীমা ঠিক করতে, আর শূন্যের কাছে কোনটা লাগে তা জানতে
  • লিস্ট, টাপল আর ডিকশনারিতে approx ব্যবহার করতে, আর অমিল হলে তার রিপোর্ট পড়তে
  • pytest.approx, math.isclose আর Decimal-এর মধ্যে বেছে নিতে
  • NaN, যে ফলের ক্রমের কোনো প্রতিশ্রুতি নেই, আর টাইমস্ট্যাম্প টেস্ট করতে

আগে যা জানা লাগবে: এক্সেপশন টেস্ট করা।


টেস্ট লেখার আগে

সংখ্যার ক্ষেত্রে সবচেয়ে গুরুত্বপূর্ণ সিদ্ধান্তটা আসে কোনো কোড লেখার আগেই: প্রতিটি ফলের জন্য কোড কোন ধরনের সমতার প্রতিশ্রুতি দিয়েছে? ভুল বাছলে টেস্ট হয় flaky (বেশি কড়া), নয়তো অন্ধ (বেশি ঢিলে)। আগে তিনটি জিনিস ঠিক করে নিন।

১. চুক্তি। এই অধ্যায় যে ছোট stats.py মডিউল দিয়ে শেষ হয়, সেটা ধরুন। সহজ কথায় সে প্রতিশ্রুতি দেয়:

  • mean(values) ফ্লোটের একটা লিস্টের গড় ফেরত দেয়, আর খালি লিস্টের জন্য nan।
  • shares(counts) গণনাগুলোকে এমন ভগ্নাংশে বদলায়, যাদের যোগফল এক।
  • with_vat(price) একটা Decimal দামের সাথে ১৫% VAT যোগ করে, সেন্ট পর্যন্ত half-up রাউন্ড করে। float দাম দিলে TypeError দিয়ে প্রত্যাখ্যান করে।
  • countries(orders) প্রতিটি দেশ একবার করে ফেরত দেয়। কোনো ক্রমের প্রতিশ্রুতি সে দেয় না।

প্রতিটি লাইনই বলে দেয় কীভাবে তুলনা করতে হবে। "ফ্লোটের গড়" মানে সহনসীমা। "সেন্ট পর্যন্ত রাউন্ড করা" মানে নিখুঁত। "প্রত্যাখ্যান" মানে pytest.raises। "কোনো ক্রমে নয়" মানে তুলনার আগে সাজিয়ে নেওয়া বা সেট ব্যবহার করা।

২. প্রস্তুতি। নতুন কিছু ইনস্টল করতে হবে না। লাগবে প্রথম অধ্যায়ের সেই venv, যাতে pytest আছে; টেস্ট ফাইল থেকে মডিউলটি ইমপোর্ট করা যাবে এমন ব্যবস্থা (দুটোই এক ফোল্ডারে, সেখান থেকেই pytest চালান); আর স্ট্যান্ডার্ড লাইব্রেরির দুটি মডিউল, math আর decimal। কোনো fixture নয়, কোনো ফাইল নয়, কোনো নেটওয়ার্ক নয়।

৩. পরিকল্পনা। টেস্ট লেখার আগে কেসগুলো লিখে ফেলুন। প্রতিটি সারির জন্য কেবল প্রত্যাশিত মান নয়, তুলনার ধরনটাও ঠিক করুন:

| কেস | ইনপুট | প্রত্যাশিত | কী দিয়ে তুলনা | | --- | --- | --- | --- | | স্বাভাবিক পথ, ফ্লোটের হিসাব | 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, সেটা টেস্ট করবেন না। ওটা পাইথনের আচরণ, আপনার নয়, আর ওটাকে বেঁধে দিলে আপনার টেস্ট হয়ে যায় ফ্লোট ফরম্যাটের টেস্ট। সেট থেকে আসা ফলের ক্রম টেস্ট করবেন না, টাইমস্ট্যাম্পের নিখুঁত মাইক্রোসেকেন্ডও নয়। কোনোটারই প্রতিশ্রুতি নেই। আর টেস্ট সবুজ না হওয়া পর্যন্ত সহনসীমা বাড়িয়ে বাড়িয়ে সেটা বেছে নেবেন না। সহনসীমা হলো সমস্যাটা সম্পর্কে একটা বক্তব্য ("আধা ডিগ্রি", "এক শতাংশ"), টেস্ট সম্পর্কে কখনো নয়।

নিচের অংশগুলো সেই টেবিলের প্রতিটি হাতিয়ার শেখায়। শেষের সম্পূর্ণ উদাহরণটি পরিকল্পনাটা সারি ধরে ধরে বাস্তবায়ন করে।


কেন 0.1 + 0.2 মানে 0.3 নয়

একটি float বাইনারিতে, নির্দিষ্ট পরিমাণ জায়গায় (৬৪ বিট) রাখা হয়। কিছু ভগ্নাংশের কোনো নিখুঁত বাইনারি রূপ নেই, ঠিক যেমন 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 জমা আছে চুল পরিমাণ ছোট হয়ে। দুটো ফল গিয়ে পড়ে পাশাপাশি দুটি আলাদা ফ্লোটে, আর == ফ্লোটকে বিট ধরে ধরে মেলায়, তাই উত্তর আসে False।

এখান থেকেই এই অধ্যায়ের একমাত্র নিয়ম: হিসাব থেকে বেরিয়ে আসা কোনো ফ্লোটে কখনো == ব্যবহার করবেন না। যে ফ্লোট আপনি নিজে লিখেছেন আর অপরিবর্তিত অবস্থায় পার হয়ে এসেছে, সেটা ঠিক আছে। যে ফ্লোট যোগ, ভাগ বা গুণ হয়ে এসেছে, তাকে একটা সহনসীমা দিয়ে তুলনা করতে হবে।

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

ডিফল্ট হিসেবে দুটি সহনসীমা থাকে, আর দুটোর মধ্যে যেটা বড়, সেটাই খাটে:

  • আপেক্ষিক rel=1e-6: প্রত্যাশিত মানের দশ লাখ ভাগের এক ভাগ। 0.3-এর জন্য সেটা 0.0000003; দশ লাখের জন্য 1।
  • পরম abs=1e-12: একটা স্থির ন্যূনতম সীমা, যাতে সহনসীমা কখনো পুরোপুরি শূন্যে না নামে।

প্রায় সব জায়গায় আপেক্ষিক সহনসীমাটাই আসল, কারণ সেটা সংখ্যার সাথে বাড়ে-কমে। 0.3-এর পাশে 0.0000003-এর ভুল নিছক গোলমাল; কিন্তু 1_000_000-এর পাশে একই সীমা হতো অযৌক্তিক রকম কড়া।

শূন্যের কাছে এসে ছবিটা বদলে যায়। শূন্যের দশ লাখ ভাগের এক ভাগ শূন্যই, তাই বাকি থাকে কেবল ছোট্ট পরম সীমাটি:

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-কে নিজে একটা পরম সহনসীমা দিন।

নিজের সহনসীমা বেছে নেওয়া: 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 হারিয়ে দেয় ১০০-র এক শতাংশকে।

একটা বাক্য মাথায় রেখে বেছে নিন। "তাপমাত্রার রিডিং আধা ডিগ্রি এদিক-ওদিক হতে পারে" মানে abs=0.5। "আন্দাজটা এক শতাংশ এদিক-ওদিক হতে পারে" মানে rel=0.01। আর প্রত্যাশিত মান শূন্য হলে কেবল abs-ই কাজে আসে।

ভুল পথ হলো একটা ব্যর্থ টেস্ট থেকে শুরু করে সবুজ না হওয়া পর্যন্ত সহনসীমা বাড়াতে থাকা। টেস্ট পাস করানোর জন্য বাছা সহনসীমা একটা বাগকেও ঠিক ততটাই খুশিমনে পাস করিয়ে দেবে:

python
import pytest

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

পাঁচ শতাংশ ভুল একটা ফল এখন "সমান" বলে গণ্য। কোডের নির্ভুলতা যদি রাউন্ডিং-ত্রুটি পর্যন্ত হওয়ার কথা, ডিফল্টটাই রাখুন। ঢিলে সহনসীমার পেছনে এমন একটা কারণ থাকা চাই যা মুখে বলা যায়, আর সেই কারণটা তার পাশে একটা কমেন্টে লেখা থাকা উচিত।

লিস্ট, টাপল আর ডিকশনারি

approx সংখ্যার একটা পাত্রও (container) নিতে পারে, আর উপাদান ধরে ধরে তুলনা করে, প্রতিটির নিজস্ব সহনসীমা দিয়ে:

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])

লিস্ট তুলনা হয় অবস্থান ধরে। ডিকশনারি তুলনা হয় কী (key) ধরে, তাই কী-গুলোর ক্রম কোনো ব্যাপার নয়, কিন্তু কী-গুলো নিজেরা হুবহু মিলতে হবে। দৈর্ঘ্যও মিলতে হবে।

আসল লাভটা দেখা যায় তুলনা ব্যর্থ হলে। 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 লাইনগুলো নিচ থেকে ওপরে পড়ুন। তিনটির মধ্যে একটি উপাদান মেলেনি। টেবিলটা বলে দেয় কোনটি (ইনডেক্স 1), কী এসেছে (0.25), আর কী প্রত্যাশিত ছিল, তার সহনসীমাসহ। উপাদান 0, অর্থাৎ 0.30000000000000004, তালিকায় নেই। সেটা যথেষ্ট কাছাকাছি ছিল, তাই সমস্যা সেখানে নয়। ডিকশনারি হলে Index কলামে থাকে কী-টি।

approx যার জন্য নয়

approx বানানো হয়েছে ফ্লোটের জন্য। অন্য কিছু দিলে সে চুপচাপ সাধারণ ==-এ ফিরে যায়:

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

স্ট্রিংয়ের বেলায় approx কিছুই যোগ করে না, দেখানোর মতো কোনো সহনসীমাও নেই। পূর্ণসংখ্যার বেলায় সে এমন কিছু যোগ করে, যা সম্ভবত আপনি চাননি। count_items() যদি 10 ফেরত দেওয়ার কথা হয়, তাহলে 10.000001 একটা বাগ, আর approx(10) সেটাকে ছেড়ে দেয়। পূর্ণসংখ্যা, স্ট্রিং, বুলিয়ান আর 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-ই ঠিক হাতিয়ার, কারণ প্রোডাকশনে 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-এর কোনো উত্তর নেই।

টাকা সহনসীমার সমস্যা নয়

দামের কোনো টেস্ট 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) একটা ফ্লোট থেকে বানানো, তাই সে ফ্লোটের ভুলটাও বিশ্বস্তভাবে নকল করে নেয়। Decimal সবসময় বানান একটা স্ট্রিং থেকে।

সেন্টে রাউন্ড করাটা স্পষ্টভাবে লিখতে হয়, আর নিয়মটা আপনি বেছে নেন:

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

তাহলে ভাগটা সহজ। পরিমাপ (দৈর্ঘ্য, গড়, অনুপাত, সেন্সরের রিডিং) হলো ফ্লোট, টেস্ট হয় 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।

যে ক্রমের প্রতিশ্রুতি দেননি, আর যে সময় থেমে থাকে না

"প্রায়" ঠিক হয়ে বেরোনো মান কেবল ফ্লোটই নয়। আরও দুটি ক্ষেত্রে একই ভাবনা দরকার: কী প্রতিশ্রুতি দেওয়া হয়েছে তা ঠিক করুন, আর কেবল সেটুকুই টেস্ট করুন।

ক্রম। এই ফাংশনটি একটা 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"}

একটা সেটে স্ট্রিংগুলোর ক্রম এক রান থেকে আরেক রানে বদলায়, কারণ স্ট্রিংয়ের হ্যাশিং প্রতিটি প্রসেসে এলোমেলো করা হয়। সিড (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, যা সেগুলো গুনে রাখে।

সময়। আপনার কোডের ভেতরে নেওয়া টাইমস্ট্যাম্প কখনো টেস্টে নেওয়া টাইমস্ট্যাম্পের সমান হতে পারে না, কারণ দুটো কলের মাঝে সময় চলে যায়। তার বদলে একটা সময়-জানালা টেস্ট করুন:

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

টেস্টটি নিখুঁত, কোনো সহনসীমারই দরকার নেই: স্ট্যাম্পটিকে আপনার রেকর্ড করা দুটি মুহূর্তের মাঝে পড়তে হবে। সহনসীমা পছন্দ হলে, abs= একটা timedelta হলে approx একটা datetime-ও নেয়: 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 ফ্লোটের ওপর হিসাব, তাই ডিফল্টসহ approx।
  • যে গড় শূন্য হওয়ার কথা, সেটা আসে 1.9e-17 হয়ে। শূন্যে আপেক্ষিক সহনসীমা অকেজো, তাই টেস্ট এমন একটা abs= দেয় যার পক্ষে যুক্তি দেখানো যায়: এখানে একশো কোটি ভাগের এক ভাগের নিচের যেকোনো কিছু রাউন্ডিংয়ের গোলমাল।
  • খালি তালিকার গড় ইচ্ছাকৃতভাবেই NaN, তাই math.isnan, কারণ == কখনো পাস করতে পারত না।
  • shares ফ্লোটের একটা ডিকশনারি ফেরত দেয়, তাই পুরো ডিকশনারির ওপর approx। দ্বিতীয় টেস্টটি তার সহনসীমা নিজে বলে দেয় (abs=0.005, এক শতাংশের অর্ধেক), কারণ সে দুই দশমিক ঘরে রাউন্ড করা সংখ্যার সাথে তুলনা করছে।
  • with_vat টাকা, তাই Decimal আর নিখুঁত ==। 0.10 × 1.15 = 0.115 সেই ক্ষেত্র, যা রাউন্ডিংয়ের নিয়মটা প্রমাণ করে: ROUND_HALF_UP এটাকে বানায় 0.12।
  • ফ্লোট দাম চুপচাপ রূপান্তরিত না হয়ে প্রত্যাখ্যাত হয়। সেই প্রত্যাখ্যানও চুক্তির অংশ, তাই আগের অধ্যায়ের মতো 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), আর সে তার ভুলটাও উত্তরাধিকারে পেয়েছে। স্ট্রিং থেকে বানান: Decimal("0.1")।

যে টেস্ট এক রানে পাস করে আর পরের রানে ব্যর্থ হয় খুঁজে দেখুন set থেকে আসা কোনো ক্রম, বা == দিয়ে তুলনা করা কোনো টাইমস্ট্যাম্প আছে কি না। sorted(...) বা set তুলনা করুন, আর সময়কে একটা জানালা হিসেবে টেস্ট করুন।