الأعداد العشرية و pytest.approx — اختبار "القريب بما يكفي"
لماذا تكون 0.1 + 0.2 == 0.3 خاطئة، وما الهامش الافتراضي في pytest.approx، ومتى تحتاج rel و abs، ولماذا يحتاج المال إلى Decimal لا إلى approx. مع 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الدالة صحيحة. الاختبار هو الخاطئ، فهو يطلب دقة لا تستطيع الأعداد العشرية ذات الفاصلة العائمة (floating-point) أن تقدمها. يتناول هذا الفصل طرح السؤال الصحيح بدلاً من ذلك: هل هي قريبة بما يكفي؟ ويتناول أيضاً الحالات التي يكون فيها "قريب بما يكفي" هو السؤال الخاطئ، والمال أبرزها.
بنهاية هذا الفصل ستكون قادراً على
- أن تشرح في جملتين لماذا تكون
0.1 + 0.2 == 0.3مساوية لـFalse - أن تقارن الأعداد العشرية في اختبار باستخدام
pytest.approx، وأن تقرأ هامشها الافتراضي - أن تحدد هامشك الخاص بـ
rel=وabs=، وأن تعرف أيهما تحتاج قرب الصفر - أن تستخدم
approxمع القوائم والصفوف (tuples) والقواميس، وأن تقرأ التقرير الذي تعطيه عند الاختلاف - أن تختار بين
pytest.approxوmath.iscloseوDecimal - أن تختبر NaN، والنتائج التي لا يُوعد بترتيبها، والطوابع الزمنية
المتطلبات المسبقة: اختبار الاستثناءات.
قبل أن تكتب الاختبار
مع الأعداد، يأتي القرار الأهم قبل أي كود: لكل نتيجة، أي نوع من المساواة وعد به الكود؟ إن أخطأت الاختيار فالاختبار إما متقلب (صارم أكثر من اللازم) وإما أعمى (متساهل أكثر من اللازم). حسم ثلاثة أمور أولاً.
1. العقد. خذ الوحدة الصغيرة stats.py التي ينتهي بها هذا الفصل. بكلمات بسيطة، هي تَعِد بما يلي:
- الدالة
mean(values)تعيد متوسط قائمة من الأعداد العشرية، وتعيدnanلقائمة فارغة. - الدالة
shares(counts)تحوّل الأعداد إلى كسور مجموعها واحد. - الدالة
with_vat(price)تضيف ضريبة قيمة مضافة بنسبة 15% إلى سعر من نوعDecimal، مقرّبة إلى السنت بالتقريب نصفاً إلى الأعلى. أما السعر من نوعfloatفيُرفض بخطأTypeError. - الدالة
countries(orders)تعيد كل دولة مرة واحدة. وهي لا تَعِد بأي ترتيب.
كل سطر يخبرك مسبقاً كيف تقارن. "متوسط أعداد عشرية" يعني هامشاً. "مقرّب إلى السنت" يعني مقارنة دقيقة. "يُرفض" يعني pytest.raises. "بلا ترتيب" يعني أن ترتّب أو تستخدم مجموعة (set) قبل المقارنة.
2. التجهيز. لا شيء جديد لتثبيته. تحتاج إلى البيئة الافتراضية (venv) من الفصل الأول وفيها pytest، وإلى أن تكون الوحدة قابلة للاستيراد من ملف الاختبار (ضع الملفين في مجلد واحد وشغّل pytest من هناك)، وإلى وحدتين من المكتبة القياسية هما math و decimal. لا fixtures، ولا ملفات، ولا شبكة.
3. الخطة. اكتب الحالات قبل كتابة الاختبارات. ولكل صف، حدّد طريقة المقارنة أيضاً، لا القيمة المتوقعة وحدها:
| الحالة | المدخل | المتوقع | المقارنة بـ | | --- | --- | --- | --- | | المسار السليم، حساب بأعداد عشرية | mean([0.1, 0.2, 0.3]) | 0.2 | approx (الهامش الافتراضي) | | حدّ: نتيجة قرب الصفر | mean([0.1, 0.2, -0.3]) | 0.0 | approx(0.0, abs=1e-9) | | حالة طرفية: مدخل فارغ | mean([]) | nan | math.isnan | | حاوية من الأعداد العشرية | shares({"tea": 1, "coffee": 2}) | {"tea": 1/3, "coffee": 2/3} | approx على القاموس | | مال، المسار السليم | with_vat(Decimal("19.99")) | Decimal("22.99") | == الدقيقة | | مال، حدّ التقريب | with_vat(Decimal("0.10")) | Decimal("0.12") (0.115 تُقرَّب إلى الأعلى) | == الدقيقة | | مدخل غير صالح | with_vat(19.99) | TypeError | pytest.raises | | ترتيب غير موعود به | countries(...) مع عنصر مكرر | ["BD", "IN"] | sorted(...) == |
ما لا يجب اختباره. لا تختبر أن 0.1 + 0.2 تساوي 0.30000000000000004. هذا سلوك بايثون لا سلوكك، وتثبيته يجعل اختبارك اختباراً لصيغة الأعداد العشرية. لا تختبر ترتيب نتيجة آتية من مجموعة، ولا الميكروثانية الدقيقة لطابع زمني؛ فلا هذا ولا ذاك موعود به. ولا تختر الهامش بتوسيعه حتى يصبح الاختبار أخضر. الهامش عبارة عن المسألة ("نصف درجة"، "واحد بالمئة")، لا عن الاختبار أبداً.
الأقسام التالية تشرح كل أداة في ذلك الجدول، والمثال الكامل في النهاية ينفّذ الخطة صفاً صفاً.
لماذا لا تساوي 0.1 + 0.2 القيمة 0.3
يُخزَّن الـ float بالنظام الثنائي، في مساحة ثابتة (64 بت). بعض الكسور ليس لها شكل ثنائي دقيق، تماماً كما أن 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.30000000000000004441تُخزَّن 0.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.01sتبني pytest.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.0000003 مجرد ضجيج بجانب 0.3؛ أما بجانب 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على واحد بالمئة من 100.
اختر وفي ذهنك جملة. "قراءة الحرارة قد تخطئ بنصف درجة" تعني abs=0.5. "التقدير قد يخطئ بواحد بالمئة" تعني rel=0.01. وعندما تكون القيمة المتوقعة صفراً، فلا يفيدك إلا abs.
الطريقة الخاطئة أن تبدأ من اختبار فاشل وتوسّع الهامش حتى يصبح أخضر. الهامش الذي اختير لإنجاح الاختبار سيُنجح الخطأ البرمجي بالسهولة نفسها:
import pytest
print(0.95 == pytest.approx(1.0, rel=0.1))Trueنتيجة خاطئة بخمسة بالمئة صارت تُحسب "مساوية". إذا كان يُفترض أن يكون الكود دقيقاً حتى خطأ التقريب، فاحتفظ بالقيمة الافتراضية. الهامش المتساهل يحتاج إلى سبب تستطيع قوله بصوت عالٍ، ومكان ذلك السبب تعليق بجانبه.
القوائم والصفوف والقواميس
تقبل approx أيضاً حاوية من الأعداد وتقارنها عنصراً عنصراً، لكل عنصر هامشه الخاص:
import pytest
shares = [0.1 + 0.2, 0.7]
point = (1 / 3, 2 / 3)
rates = {"tax": 0.1 + 0.05, "tip": 0.1}
print(shares == pytest.approx([0.3, 0.7]))
print(point == pytest.approx((0.333333, 0.666667), rel=1e-5))
print(rates == pytest.approx({"tax": 0.15, "tip": 0.1}))
print(pytest.approx([0.3, 0.7]))True
True
True
approx([0.3 ± 3.0e-07, 0.7 ± 7.0e-07])القائمة تُقارن حسب الموضع. والقاموس يُقارن حسب المفتاح، فلا يهم ترتيب المفاتيح، لكن المفاتيح نفسها يجب أن تتطابق تماماً. ويجب أن تتطابق الأطوال أيضاً.
الفائدة الحقيقية تظهر عندما تفشل المقارنة. الملف test_shares.py:
import pytest
def test_shares():
assert [0.1 + 0.2, 0.25, 0.6] == pytest.approx([0.3, 0.2, 0.6])F [100%]
=================================== FAILURES ===================================
_________________________________ test_shares __________________________________
def test_shares():
> assert [0.1 + 0.2, 0.25, 0.6] == pytest.approx([0.3, 0.2, 0.6])
E assert [0.3000000000...04, 0.25, 0.6] == approx([0.3 ±....6 ± 6.0e-07])
E
E comparison failed. Mismatched elements: 1 / 3:
E Max absolute difference: 0.04999999999999999
E Max relative difference: 0.19999999999999996
E Index | Obtained | Expected
E 1 | 0.25 | 0.2 ± 2.0e-07
test_shares.py:5: AssertionError
=========================== short test summary info ============================
FAILED test_shares.py::test_shares - assert [0.3000000000...04, 0.25, 0.6] ==...
1 failed in 0.01sاقرأه من أسفل أسطر E صعوداً. عنصر واحد من ثلاثة لم يتطابق. يخبرك الجدول بأيّها (الفهرس 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
Trueمع abs_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.01sتخبرك assert 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.1000000000000000055511151231257827021181583404541015625مع Decimal تعود == دقيقة من جديد، وهذا ما يحتاجه اختبار المال. السطر الأخير هو الفخ: 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
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:
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"}يتغير ترتيب النصوص في المجموعة من تشغيل لآخر، لأن تجزئة النصوص (hashing) عشوائية في كل عملية. تثبيت البذرة يجعل ذلك مرئياً. 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. تنبيه واحد: المجموعة تُخفي التكرارات أيضاً. إذا كانت التكرارات مهمة، فاستخدم 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هذا الاختبار دقيق ولا يحتاج إلى أي هامش: يجب أن يقع الطابع بين لحظتين سجّلتهما. وإن فضّلت هامشاً، فإن 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 ===============================ثمانية اختبارات، واحد لكل صف من الخطة في "قبل أن تكتب الاختبار". كل منها يختار مقارنته عن قصد:
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.- السعر من نوع
floatيُرفض بدلاً من تحويله بصمت. هذا الرفض جزء من العقد، لذا له اختباره الخاص بـpytest.raises، كما في الفصل السابق. - لا تَعِد
countriesبأي ترتيب، لذا يرتّب الاختبار قبل المقارنة.
الأخطاء الشائعة وحلولها
assert 0.30000000000000004 == 0.3 عدد عشري محسوب قورن بـ ==. غلّف القيمة المتوقعة: == pytest.approx(0.3). وإذا كانت القيمة مالاً، فأصلح الكود ليستخدم Decimal بدلاً من ذلك.
AssertionError: approx() is not supported in a boolean context. كتبت assert pytest.approx(x) دون مقارنة. لا معنى لـ approx إلا على أحد طرفي ==، كما تقترح الرسالة: assert a == approx(b).
TypeError: '>' not supported between instances of 'ApproxScalar' and 'float' لا تدعم approx إلا == و !=. من أجل "على الأقل" أو "على الأكثر"، قارن الأعداد العادية: assert result > 0.2.
Impossible to compare lists with different sizes. القائمة وقائمة approx بطولين مختلفين. الهامش يُطبَّق على القيم، لا على عددها أبداً. ويعرض التقرير الطولين في السطر التالي.
assert nan == nan ± ??? قيمة NaN على الطرفين، و NaN لا تساوي أي شيء أبداً. استخدم math.isnan(result)، أو مرّر nan_ok=True إذا كانت NaN هي القيمة المتوقعة.
*`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، واختبر الوقت كنافذة.
Step 4 of 6 — Predict
Check your understanding
هل جزء من مليون "قريب بما يكفي" من الصفر؟ ماذا يطبع السطران؟
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)- Aالدالة approx صارمة جداً لدرجة أن الاختبار سيفشل أحياناً
- Bينجح رغم أن المبلغ المحصَّل أعلى بـ 99 سنتاً — المال يحتاج 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"]
Answering needs an account
Sign in to check your answers
The questions are above, and working them out in your head is the part that matters. Sign in to see the answers, the explanations and the three-level hints.
دورك الآن
اكتب الملف 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و==.
Step 6 of 6
التحدي — the chapter quiz
عشرة أسئلة متدرجة من السهل إلى الصعب. الأسئلة الأخيرة صعبة عن قصد.
Sign in to take the quiz