দশমিক সংখ্যা আর pytest.approx — "যথেষ্ট কাছাকাছি" টেস্ট করা
কেন 0.1 + 0.2 == 0.3 মিথ্যা, 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এটা আপনার কম্পিউটারের ত্রুটি নয়, পাইথনেরও ত্রুটি নয়। প্রায় সব প্রোগ্রামিং ভাষা দশমিক ভগ্নাংশ এভাবেই রাখে। আর এই জিনিসটা সোজা এসে ঢোকে আপনার টেস্টে।
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 - টেস্টে
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 এমনই একটি ভগ্নাংশ। পাইথন তার সবচেয়ে কাছের যে সংখ্যাটা রাখা সম্ভব, সেটাই রাখে।
পাইথন সাধারণত যত অঙ্ক দেখায় তার চেয়ে বেশি চাইলে আসল জমা থাকা মানগুলো দেখা যায়:
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 জমা আছে চুল পরিমাণ ছোট হয়ে। দুটো ফল গিয়ে পড়ে পাশাপাশি দুটি আলাদা ফ্লোটে, আর == ফ্লোটকে বিট ধরে ধরে মেলায়, তাই উত্তর আসে False।
এখান থেকেই এই অধ্যায়ের একমাত্র নিয়ম: হিসাব থেকে বেরিয়ে আসা কোনো ফ্লোটে কখনো == ব্যবহার করবেন না। যে ফ্লোট আপনি নিজে লিখেছেন আর অপরিবর্তিত অবস্থায় পার হয়ে এসেছে, সেটা ঠিক আছে। যে ফ্লোট যোগ, ভাগ বা গুণ হয়ে এসেছে, তাকে একটা সহনসীমা দিয়ে তুলনা করতে হবে।
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ডিফল্ট হিসেবে দুটি সহনসীমা থাকে, আর দুটোর মধ্যে যেটা বড়, সেটাই খাটে:
- আপেক্ষিক
rel=1e-6: প্রত্যাশিত মানের দশ লাখ ভাগের এক ভাগ।0.3-এর জন্য সেটা0.0000003; দশ লাখের জন্য1। - পরম
abs=1e-12: একটা স্থির ন্যূনতম সীমা, যাতে সহনসীমা কখনো পুরোপুরি শূন্যে না নামে।
প্রায় সব জায়গায় আপেক্ষিক সহনসীমাটাই আসল, কারণ সেটা সংখ্যার সাথে বাড়ে-কমে। 0.3-এর পাশে 0.0000003-এর ভুল নিছক গোলমাল; কিন্তু 1_000_000-এর পাশে একই সীমা হতো অযৌক্তিক রকম কড়া।
শূন্যের কাছে এসে ছবিটা বদলে যায়। শূন্যের দশ লাখ ভাগের এক ভাগ শূন্যই, তাই বাকি থাকে কেবল ছোট্ট পরম সীমাটি:
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-কে নিজে একটা পরম সহনসীমা দিন।
নিজের সহনসীমা বেছে নেওয়া: 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=5হারিয়ে দেয় ১০০-র এক শতাংশকে।
একটা বাক্য মাথায় রেখে বেছে নিন। "তাপমাত্রার রিডিং আধা ডিগ্রি এদিক-ওদিক হতে পারে" মানে abs=0.5। "আন্দাজটা এক শতাংশ এদিক-ওদিক হতে পারে" মানে rel=0.01। আর প্রত্যাশিত মান শূন্য হলে কেবল abs-ই কাজে আসে।
ভুল পথ হলো একটা ব্যর্থ টেস্ট থেকে শুরু করে সবুজ না হওয়া পর্যন্ত সহনসীমা বাড়াতে থাকা। টেস্ট পাস করানোর জন্য বাছা সহনসীমা একটা বাগকেও ঠিক ততটাই খুশিমনে পাস করিয়ে দেবে:
import pytest
print(0.95 == pytest.approx(1.0, rel=0.1))Trueপাঁচ শতাংশ ভুল একটা ফল এখন "সমান" বলে গণ্য। কোডের নির্ভুলতা যদি রাউন্ডিং-ত্রুটি পর্যন্ত হওয়ার কথা, ডিফল্টটাই রাখুন। ঢিলে সহনসীমার পেছনে এমন একটা কারণ থাকা চাই যা মুখে বলা যায়, আর সেই কারণটা তার পাশে একটা কমেন্টে লেখা থাকা উচিত।
লিস্ট, টাপল আর ডিকশনারি
approx সংখ্যার একটা পাত্রও (container) নিতে পারে, আর উপাদান ধরে ধরে তুলনা করে, প্রতিটির নিজস্ব সহনসীমা দিয়ে:
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])লিস্ট তুলনা হয় অবস্থান ধরে। ডিকশনারি তুলনা হয় কী (key) ধরে, তাই কী-গুলোর ক্রম কোনো ব্যাপার নয়, কিন্তু কী-গুলো নিজেরা হুবহু মিলতে হবে। দৈর্ঘ্যও মিলতে হবে।
আসল লাভটা দেখা যায় তুলনা ব্যর্থ হলে। 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 লাইনগুলো নিচ থেকে ওপরে পড়ুন। তিনটির মধ্যে একটি উপাদান মেলেনি। টেবিলটা বলে দেয় কোনটি (ইনডেক্স 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 — স্ট্যান্ডার্ড লাইব্রেরির সংস্করণ
পাইথনের নিজস্ব একটা সহনসীমা-পরীক্ষা আছে, 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-ই ঠিক হাতিয়ার, কারণ প্রোডাকশনে 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-এর কোনো উত্তর নেই।
টাকা সহনসীমার সমস্যা নয়
দামের কোনো টেস্ট 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) একটা ফ্লোট থেকে বানানো, তাই সে ফ্লোটের ভুলটাও বিশ্বস্তভাবে নকল করে নেয়। 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তাহলে ভাগটা সহজ। পরিমাপ (দৈর্ঘ্য, গড়, অনুপাত, সেন্সরের রিডিং) হলো ফ্লোট, টেস্ট হয় 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।
যে ক্রমের প্রতিশ্রুতি দেননি, আর যে সময় থেমে থাকে না
"প্রায়" ঠিক হয়ে বেরোনো মান কেবল ফ্লোটই নয়। আরও দুটি ক্ষেত্রে একই ভাবনা দরকার: কী প্রতিশ্রুতি দেওয়া হয়েছে তা ঠিক করুন, আর কেবল সেটুকুই টেস্ট করুন।
ক্রম। এই ফাংশনটি একটা 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"}একটা সেটে স্ট্রিংগুলোর ক্রম এক রান থেকে আরেক রানে বদলায়, কারণ স্ট্রিংয়ের হ্যাশিং প্রতিটি প্রসেসে এলোমেলো করা হয়। সিড (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.01sPYTHONHASHSEED=2 দিয়ে আবার চালান, তিনটিই পাস করবে। একই কোড, একই টেস্ট, ভিন্ন ফল। একে বলে flaky টেস্ট, আর এটা ব্যর্থ টেস্টের চেয়েও খারাপ। ফাংশন যদি কোনো ক্রমের প্রতিশ্রুতি না দেয়, তাহলে ক্রম টেস্ট করবেন না। sorted(...) তুলনা করুন, অথবা set হিসেবে তুলনা করুন। একটা সতর্কতা: set ডুপ্লিকেটও লুকিয়ে ফেলে। ডুপ্লিকেট গুরুত্বপূর্ণ হলে sorted ব্যবহার করুন, অথবা collections.Counter, যা সেগুলো গুনে রাখে।
সময়। আপনার কোডের ভেতরে নেওয়া টাইমস্ট্যাম্প কখনো টেস্টে নেওয়া টাইমস্ট্যাম্পের সমান হতে পারে না, কারণ দুটো কলের মাঝে সময় চলে যায়। তার বদলে একটা সময়-জানালা টেস্ট করুন:
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টেস্টটি নিখুঁত, কোনো সহনসীমারই দরকার নেই: স্ট্যাম্পটিকে আপনার রেকর্ড করা দুটি মুহূর্তের মাঝে পড়তে হবে। সহনসীমা পছন্দ হলে, abs= একটা timedelta হলে approx একটা datetime-ও নেয়: 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 ===============================আটটি টেস্ট, "টেস্ট লেখার আগে"-এর পরিকল্পনার প্রতিটি সারির জন্য একটি করে। প্রতিটি তার তুলনার ধরন ভেবেচিন্তে বেছেছে:
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 তুলনা করুন, আর সময়কে একটা জানালা হিসেবে টেস্ট করুন।
ধাপ ৪ / ৬ — অনুমান
যাচাই করুন
এক লক্ষ ভাগের এক ভাগ কি শূন্যের "যথেষ্ট কাছে"? দুটি লাইন কী ছাপবে?
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-এর সহনশীলতা এত কড়া যে টেস্টটি মাঝে মাঝে ব্যর্থ হবে
- B৯৯ সেন্ট বেশি নেওয়া হলেও টেস্ট পাস করছে — টাকার জন্য 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 * rsplit_bill(total, people), যেখানেtotalএকটিDecimal, আর যা সেন্ট পর্যন্ত মাপাDecimalভাগের একটা লিস্ট ফেরত দেয়, যেগুলো যোগ করলে হুবহুtotalহয় (সমানভাবে ভাগ না হলে প্রথম কয়েকজন এক সেন্ট বেশি দেবে)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_000_000.00-এর একটা বিল ভাগ করার টেস্টের একটা ফ্লোট সংস্করণ লিখুন, ভাগগুলো 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কাজ করে পূর্ণ সেন্টে। সে মোট অঙ্কটাকে সেন্টের একটা পূর্ণসংখ্যায় বদলায়,divmodদিয়ে ভাগ করে, আর ভাগশেষটা এক সেন্ট করে বিলিয়ে দেয়। পূর্ণসংখ্যা নিখুঁত, তাই ভাগগুলোর যোগফল গঠনগতভাবেই মেলে, আর টেস্টDecimalমানের ওপর সাধারণ==ব্যবহার করতে পারে।- বিলের জন্য দুটি আলাদা টেস্ট, একটি ভাগগুলোর জন্য, একটি যোগফলের জন্য। ভাগের নিয়ম বদলালে (ধরুন, বাড়তি সেন্টটা শেষ জন দেয়), প্রথম টেস্ট ব্যর্থ হয় আর দ্বিতীয়টি পাস করে — যা ঠিক বলে দেয় কোন প্রতিশ্রুতিটা বদলেছে।
normaliseফ্লোট ফেরত দেয়, তাই তার দুটি টেস্টইapproxব্যবহার করে, পুরো লিস্টের ওপর আর যোগফলের ওপর।- শেষ টেস্টটাই সেই পরীক্ষা, টেস্ট হিসেবে রেখে দেওয়া যাতে সে নিজেই নিজের দলিল হয়। এই আকারে এক সেন্ট বড় একটা ভাগও
approxপাস করে, কারণ 333,333.33-এর দশ লাখ ভাগের এক ভাগ প্রায় 0.33। সে পাস করে, আর এই পাস করাটাই বাগ: এ কারণেইsplit_billব্যবহার করেDecimalআর==।
ধাপ ৬ / ৬
কঠিন করা — অধ্যায়ের কুইজ
সহজ থেকে কঠিন — দশটি প্রশ্ন, শেষেরগুলো ইচ্ছে করেই কঠিন।
সাইন ইন করে কুইজ দিন