الفصل 05

الأعداد العشرية و pytest.approx — اختبار "القريب بما يكفي"

لماذا تكون 0.1 + 0.2 == 0.3 خاطئة، وما الهامش الافتراضي في pytest.approx، ومتى تحتاج rel و abs، ولماذا يحتاج المال إلى Decimal لا إلى approx. مع 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

الدالة صحيحة. الاختبار هو الخاطئ، فهو يطلب دقة لا تستطيع الأعداد العشرية ذات الفاصلة العائمة (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 واحد من تلك الكسور في النظام الثنائي، فتحتفظ بايثون بأقرب عدد تستطيع تخزينه.

يمكنك رؤية القيم المخزَّنة بطلب أرقام أكثر مما تعرضه بايثون عادةً:

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.0000003 مجرد ضجيج بجانب 0.3؛ أما بجانب 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 على واحد بالمئة من 100.

اختر وفي ذهنك جملة. "قراءة الحرارة قد تخطئ بنصف درجة" تعني abs=0.5. "التقدير قد يخطئ بواحد بالمئة" تعني rel=0.01. وعندما تكون القيمة المتوقعة صفراً، فلا يفيدك إلا abs.

الطريقة الخاطئة أن تبدأ من اختبار فاشل وتوسّع الهامش حتى يصبح أخضر. الهامش الذي اختير لإنجاح الاختبار سيُنجح الخطأ البرمجي بالسهولة نفسها:

python
import pytest

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

نتيجة خاطئة بخمسة بالمئة صارت تُحسب "مساوية". إذا كان يُفترض أن يكون الكود دقيقاً حتى خطأ التقريب، فاحتفظ بالقيمة الافتراضية. الهامش المتساهل يحتاج إلى سبب تستطيع قوله بصوت عالٍ، ومكان ذلك السبب تعليق بجانبه.

القوائم والصفوف والقواميس

تقبل 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])

القائمة تُقارن حسب الموضع. والقاموس يُقارن حسب المفتاح، فلا يهم ترتيب المفاتيح، لكن المفاتيح نفسها يجب أن تتطابق تماماً. ويجب أن تتطابق الأطوال أيضاً.

الفائدة الحقيقية تظهر عندما تفشل المقارنة. الملف 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"}

يتغير ترتيب النصوص في المجموعة من تشغيل لآخر، لأن تجزئة النصوص (hashing) عشوائية في كل عملية. تثبيت البذرة يجعل ذلك مرئياً. 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. تنبيه واحد: المجموعة تُخفي التكرارات أيضاً. إذا كانت التكرارات مهمة، فاستخدم 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

هذا الاختبار دقيق ولا يحتاج إلى أي هامش: يجب أن يقع الطابع بين لحظتين سجّلتهما. وإن فضّلت هامشاً، فإن 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 عملية حسابية على أعداد عشرية، لذا 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، واختبر الوقت كنافذة.