الـ fixtures المدمجة — tmp_path و capsys و caplog
الـ fixtures التي يأتي بها pytest للمواضع المزعجة في الاختبار: الملفات والمخرجات المطبوعة والسجلات. اختبار كود يكتب CSV و JSON دون لمس مسارات حقيقية، والتحقق مما يطبعه ويسجّله.
- 1المشكلة
- 2الفهم
- 3أمثلة محلولة
- 4التوقع
- 5التطبيق
- 6التحدي
المشكلة التي نقوم بحلها
إليك دالة تكتب ملف CSV، واختباراً لها:
import csv
def write_csv(path, rows):
with open(path, "w", newline="") as f:
writer = csv.writer(f)
writer.writerow(["product", "quantity"])
writer.writerows(rows)
def test_write_csv():
write_csv("report.csv", [("pen", 12), ("bag", 2)])
with open("report.csv") as f:
assert f.read().splitlines()[1] == "pen,12"$ pytest -q
. [100%]
1 passed in 0.08s
$ ls
report.csv test_report.pyنجح الاختبار، وترك شيئاً خلفه. يقبع report.csv الآن في مشروعك، بجوار الكود مباشرة. شغّل pytest من مجلد آخر وسيظهر الملف هناك بدلاً من ذلك، لأن "report.csv" مسار نسبي — نسبةً إلى المكان الذي تصادف أنك تقف فيه. واختباران يكتب كلاهما report.csv سيقرأ كل منهما بقايا الآخر. وإن كان في مشروعك ملف report.csv حقيقي من قبل، فقد كتب الاختبار فوقه للتو.
ينبغي للاختبار أن يترك العالم تماماً كما وجده. الملفات هي الحالة الأوضح، لكن المخرجات المطبوعة ورسائل السجل تطرح السؤال نفسه من الجهة الأخرى: الدالة ترسل شيئاً إلى الخارج، والاختبار يحتاج إلى التقاطه.
عرض عليك الفصلان السابقان كيف تكتب fixtures. أما هذا الفصل فعن الـ fixtures التي يأتي بها pytest جاهزة لهذه المواضع المزعجة — يكفي أن تطلبها باسمها.
بنهاية هذا الفصل ستكون قادراً على
- منح كل اختبار مجلده الفارغ الخاص عبر
tmp_path، واختبار الدوال التي تكتب ملفات CSV أو JSON - مشاركة مجلد مؤقت واحد طوال الجلسة عبر
tmp_path_factory - اختبار ما يطبعه البرنامج عبر
capsys، ومعرفة متى تحتاج إلىcapfdبدلاً منه - اختبار ما يسجّله البرنامج عبر
caplog، بما في ذلك الرسائل التي تحت مستوىWARNING - استخدام
request.node.nameلمعرفة أي اختبار يعمل الآن - قراءة الأخطاء التي تنتج عن سوء استخدام هذه الـ fixtures
المتطلبات المسبقة: نطاق الـ fixture وملف conftest.py.
قبل أن تكتب الاختبار
الكود الذي يلمس الملفات أو الطرفية أو السجل له آثار جانبية (side effects): نتيجته الحقيقية هي ما يتركه في العالم، لا ما يعيده فقط. لذا قبل كتابة أي كود اختبار، دوّن أين يذهب كل أثر.
1. سمِّ كل مُخرَج. دالة تصدّر صفوف المخزون قد يكون لها ما يصل إلى أربعة مخرجات:
- القيمة المعادة (المسار الذي كتبت فيه)
- محتوى الملف
- ما تطبعه، مقسوماً بين المخرج القياسي (standard output) ومخرج الأخطاء القياسي (standard error)
- ما تسجّله، وبأي مستوى
كل واحد منها وعد مستقل، وكل واحد يحتاج إلى طريقة لمراقبته. القيمة المعادة تحصل عليها مجاناً. وللثلاثة الباقية يوفّر pytest لكل منها fixture: tmp_path للملفات، و capsys للنص المطبوع، و caplog للسجلات.
2. تأكد أن الكود يسمح للاختبار باختيار مكان الملفات. هذا هو السؤال الذي يُطرح أولاً، لأنه إن كان الجواب «لا» فلن تنقذك أي fixture. الدالة التي تبني مسارها بنفسها — open("/var/reports/stock.csv", "w") — لا يمكن اختبارها إلا بالكتابة في ذلك المكان الحقيقي. أما الدالة التي تأخذ المجلد أو المسار معاملاً فيمكن توجيهها إلى tmp_path. إن كان الكود ملكك، فعدّله ليأخذ المسار. (وحين لا تستطيع تعديله، فإن monkeypatch في الفصل القادم هو المخرج.)
3. تحقق من إعداداتك. لا شيء يحتاج إلى تثبيت — كل fixture في هذا الفصل تأتي مع pytest. تحتاج إلى المعتاد: بيئة افتراضية فيها pytest، وكود يمكن استيراده من ملف الاختبار. واعرف اسم الـ logger لديك (logging.getLogger("export")) لأن caplog.set_level يستطيع استهدافه.
4. خطّط للحالات. بالنسبة إلى export_stock(rows, out_dir)، التي تكتب الصفوف الصالحة في stock.csv، وتحذّر من الكميات السالبة، وتطبع ملخصاً:
| الحالة | المدخل | المتوقع | |---|---|---| | المسار الطبيعي | [("pen", 12), ("eraser", 30)]، tmp_path | الملف فيه الترويسة + صفّان؛ يطبع wrote 2 rows to stock.csv | | صف غير صالح | صف كميته -1 | يُستبعد الصف؛ رسالة WARNING واحدة تذكره | | مجلد غير موجود | tmp_path / "exports" / "2026" | تُنشأ المجلدات؛ والملف بداخلها | | لا شيء صالح | [("bag", -1)] | الترويسة فقط؛ nothing exported على مخرج الأخطاء | | مخرجات نظيفة | أي صفوف صالحة | لا شيء على مخرج الأخطاء |
5. قرّر ما لن تختبره. لا تختبر أن وحدة csv في بايثون تضع الفواصل بين علامات اقتباس بشكل صحيح، أو أن json.dumps تنتج JSON صالحاً — تلك وعود غيرك، ومختبرة سلفاً. ولا تطابق caplog.text كاملاً، فهو يتضمن أسماء ملفات وأرقام أسطر تتغير كلما عدّلت الكود. ولا تختبر الموقع الدقيق لـ tmp_path؛ فهو يختلف من جهاز لآخر. اختبر قراراتك أنت: أي الصفوف تبقى، وما الذي يذهب إلى أين، وماذا تقول الرسائل.
يقدّم بقية الفصل أداة لكل عمود في ذلك الجدول، والمثال المتكامل ينفّذ الخطة.
tmp_path — مجلد جديد لكل اختبار
اطلب tmp_path فيسلّم pytest اختبارك مجلداً لم يكن موجوداً قبل لحظة:
def test_first(tmp_path):
print(tmp_path)
print(type(tmp_path))
def test_second(tmp_path):
print(tmp_path)$ pytest -q -s
/tmp/pytest-of-you/pytest-0/test_first0
<class 'pathlib.PosixPath'>
./tmp/pytest-of-you/pytest-0/test_second0
.
2 passed in 0.07s(الخيار -s يوقف التقاط pytest للمخرجات كي تصل استدعاءات print إلى الطرفية. على Windows يبدأ المسار في مكان ما داخل مجلد Temp الخاص بمستخدمك، ويكون النوع WindowsPath.)
ثلاثة أشياء تُقرأ في ذلك المخرج:
- إنه
pathlib.Path، لا سلسلة نصية. تضمّ الأجزاء بـ/، وتقرأ بـ.read_text()، وتتحقق بـ.exists(). - كل اختبار يحصل على مجلده الخاص، مسمّى باسم الاختبار. لا يلتقي
test_first0وtest_second0أبداً، لذا يستطيع كلاهما كتابة ملف اسمهreport.csv. - كل تشغيل يحصل على مجلد أساسي جديد:
pytest-0، ثمpytest-1، وهكذا. يحتفظ pytest بمجلدات آخر ثلاثة تشغيلات كي تتمكن من النظر داخلها بعد الفشل، ويحذف الأقدم بنفسه. لن تحتاج إلى التنظيف أبداً.
اختبار دالة تكتب ملفاً
والآن اختبار CSV مرة أخرى، بالطريقة الصحيحة هذه المرة — ومعه زوج من دوال JSON، لأن حفظ الإعدادات هو مهمة الكتابة في الملفات الأخرى التي لا يكاد يخلو منها برنامج:
import csv
import json
def write_csv(path, rows):
with open(path, "w", newline="") as f:
writer = csv.writer(f)
writer.writerow(["product", "quantity"])
writer.writerows(rows)
def save_settings(path, settings):
path.write_text(json.dumps(settings, indent=2))
def load_settings(path):
return json.loads(path.read_text())
def test_write_csv(tmp_path):
target = tmp_path / "report.csv"
write_csv(target, [("pen", 12), ("bag", 2)])
assert target.exists()
assert target.read_text().splitlines() == [
"product,quantity",
"pen,12",
"bag,2",
]
def test_csv_reads_back(tmp_path):
target = tmp_path / "report.csv"
write_csv(target, [("pen", 12)])
with target.open(newline="") as f:
rows = list(csv.DictReader(f))
assert rows == [{"product": "pen", "quantity": "12"}]
def test_settings_round_trip(tmp_path):
target = tmp_path / "settings.json"
settings = {"currency": "BDT", "tax": 0.15}
save_settings(target, settings)
assert load_settings(target) == settings
assert '"currency": "BDT"' in target.read_text()$ pytest -q
... [100%]
3 passed in 0.07s
$ ls
test_files.pyلم يبقَ شيء خلفنا هذه المرة. النمط واحد دائماً: ابنِ مساراً داخل tmp_path، وسلّمه إلى الكود المختبَر، ثم اقرأ الملف وتحقق منه.
هناك طريقتان للتحقق، والاختبارات أعلاه تستخدم كلتيهما. يتحقق test_write_csv من النص بحرفه — وهو أشد الاختبارات صرامة، والصحيح حين سيقرأ الملفَ برنامجٌ آخر. أما test_csv_reads_back و test_settings_round_trip فيقرآن الملف كما سيقرؤه قارئ حقيقي ويقارنان البيانات. لاحظ أن DictReader أعاد "12"، أي سلسلة نصية: لا أرقام في CSV، بل نصوص فقط. واختبار الذهاب والإياب (round-trip) هو المكان الذي تظهر فيه مفاجآت كهذه.
يبدأ tmp_path فارغاً. إن كان كودك يفترض وجود مجلد فرعي، فإما أن تنشئه في الاختبار بـ (tmp_path / "exports").mkdir() أو — وهو الأفضل — أن تجعل الكود ينشئه. سترى ما يحدث حين لا يفعل أحد ذلك في «الأخطاء الشائعة وحلولها».
tmp_path_factory — مجلد واحد للجلسة كلها
نطاق tmp_path هو الدالة: مجلد جديد لكل اختبار. وهذا أحياناً هدر. افترض أن عدة اختبارات تقرأ ملف عينة كبيراً يستغرق بناؤه وقتاً. في الفصل الماضي كنت ستلجأ إلى scope="session" — لكن fixture الجلسة لا تستطيع استخدام tmp_path، لأن fixture تعيش طوال الجلسة لا يمكن أن تعتمد على شيء يُرمى بعد كل اختبار.
لهذا يوجد tmp_path_factory، ونطاقه هو الجلسة أصلاً. تطلب منه المجلدات عبر mktemp:
import pytest
@pytest.fixture(scope="session")
def big_catalogue(tmp_path_factory):
folder = tmp_path_factory.mktemp("data")
target = folder / "catalogue.csv"
lines = ["product,quantity"]
lines += [f"item{i},{i}" for i in range(10_000)]
target.write_text("\n".join(lines))
print("built", target)
return target
def test_has_header(big_catalogue):
assert big_catalogue.read_text().startswith("product,quantity")
def test_has_every_row(big_catalogue):
assert len(big_catalogue.read_text().splitlines()) == 10_001$ pytest -q -s
built /tmp/pytest-of-you/pytest-6/data0/catalogue.csv
..
2 passed in 0.07sظهرت built مرة واحدة لاختبارين. يضيف mktemp("data") رقماً إلى الاسم — data0، ثم data1 إن استدعيته ثانية — فلا يتصادم استدعاءان أبداً.
والمقايضة هي نفسها التي رأيتها في الفصل الماضي: الاختباران يتشاركان الآن ملفاً واحداً. لا بأس هنا لأنهما يقرآن فقط. أما الاختبار الذي يعدّل ملفاً مشتركاً فيحتاج إلى نسخته الخاصة في tmp_path.
capsys — اختبار ما يطبعه البرنامج
لنبدأ بالخطأ الذي يقع فيه الجميع تقريباً مرة واحدة. دالة تطبع تحية؛ والاختبار يتحقق من نتيجتها:
def greet(name):
print(f"hello, {name}")
def test_greet():
assert greet("Rina") == "hello, Rina"$ pytest -q
F [100%]
=================================== FAILURES ===================================
__________________________________ test_greet __________________________________
def test_greet():
> assert greet("Rina") == "hello, Rina"
E AssertionError: assert None == 'hello, Rina'
E + where None = greet('Rina')
test_wrong.py:6: AssertionError
----------------------------- Captured stdout call -----------------------------
hello, Rina
=========================== short test summary info ============================
FAILED test_wrong.py::test_greet - AssertionError: assert None == 'hello, Rina'
1 failed in 0.09sالدالة print ترسل النص؛ لا تعيده، لذا تعيد greet القيمة None. لكن انظر إلى أسفل التقرير: Captured stdout call — لقد التقط pytest النص سلفاً. يفعل ذلك مع كل اختبار ليبقي الطرفية مرتبة. و capsys هو طريقتك للوصول إليه:
def greet(name):
print(f"hello, {name}")
def test_greet(capsys):
greet("Rina")
assert capsys.readouterr().out == "hello, Rina\n"$ pytest -q
. [100%]
1 passed in 0.07sاستدعِ الدالة أولاً، ثم اقرأ ما طبعته. برامج سطر الأوامر الحقيقية تطبع أكثر من سطر، على مجريين، وتعيد رمز خروج. إليك واحداً، مع اختباراته:
import sys
def main(argv):
if not argv:
print("usage: stock ITEM QUANTITY", file=sys.stderr)
return 2
item, quantity = argv[0], int(argv[1])
print(f"{item}: {quantity} in stock")
if quantity < 5:
print("low stock")
return 0
def test_prints_the_stock(capsys):
code = main(["pen", "12"])
captured = capsys.readouterr()
assert code == 0
assert captured.out == "pen: 12 in stock\n"
assert captured.err == ""
def test_warns_when_low(capsys):
main(["bag", "2"])
out = capsys.readouterr().out
assert out.splitlines() == ["bag: 2 in stock", "low stock"]
def test_usage_goes_to_stderr(capsys):
code = main([])
captured = capsys.readouterr()
assert code == 2
assert captured.out == ""
assert "usage:" in captured.err$ pytest -q
... [100%]
3 passed in 0.08sتعيد capsys.readouterr() كل ما كُتب على المخرج القياسي ومخرج الأخطاء منذ آخر مرة سألت فيها، في كائن له حقلان نصيان: .out و .err. وهو named tuple، لذا تعمل out, err = capsys.readouterr() أيضاً.
تفصيلتان مهمتان في الممارسة:
printتضيف سطراً جديداً. النص الملتقط هو"pen: 12 in stock\n"، لا"pen: 12 in stock". ولعدة أسطر، تعطيك.splitlines()قائمة نظيفة للمقارنة.- الأخطاء تذهب إلى
err. مكان رسالة الاستخدام هو مخرج الأخطاء، وtest_usage_goes_to_stderrيتحقق من أنها ذهبت إلى هناك و أن شيئاً لم يذهب إلى المخرج القياسي.
عبارة «منذ آخر مرة سألت فيها» مقصودة حرفياً — القراءة تفرغ المخزن المؤقت:
def test_read_twice(capsys):
print("first")
one = capsys.readouterr()
print("second")
two = capsys.readouterr()
assert one.out == "first\n"
assert two.out == "second\n"$ pytest -q
. [100%]
1 passed in 0.07sهذا مفيد: يمكنك التحقق من المخرجات خطوة بخطوة. وهو أيضاً سبب عودة readouterr() الثانية فارغة أحياناً حين لا تتوقع ذلك.
capfd — حين لا تمر المخرجات عبر بايثون
يستبدل capsys الكائنين sys.stdout و sys.stderr في بايثون. أما ما يكتب مباشرة إلى مخرجات نظام التشغيل — عملية فرعية، أو امتداد مكتوب بلغة C — فيلتف حوله. يلتقط capfd على مستوى أدنى، عند واصف الملف (file descriptor)، فيلتقط ذلك أيضاً:
import os
def test_with_capsys(capsys):
os.system("echo from the shell")
assert capsys.readouterr().out == ""
def test_with_capfd(capfd):
os.system("echo from the shell")
assert capfd.readouterr().out == "from the shell\n"$ pytest -q
.. [100%]
2 passed in 0.07sالدالة readouterr() نفسها، و .out و .err نفساهما. استخدم capsys افتراضياً، ولا تنتقل إلى capfd إلا حين تكون مخرجات آتية من خارج بايثون مفقودة.
caplog — اختبار ما يسجّله البرنامج
البرامج الحقيقية تسجّل بدلاً من أن تطبع. والتحذير في السجل سلوك كأي سلوك آخر، فهو يستحق اختباراً:
import logging
logger = logging.getLogger("importer")
def parse_quantities(lines):
result = {}
for line in lines:
item, _, raw = line.partition(",")
if not raw.strip().isdigit():
logger.warning("skipped %r: bad quantity", item)
continue
result[item] = int(raw)
logger.info("parsed %d of %d lines", len(result), len(lines))
return result
def test_bad_row_is_logged(caplog):
result = parse_quantities(["pen,12", "bag,two"])
assert result == {"pen": 12}
assert len(caplog.records) == 1
record = caplog.records[0]
assert record.levelname == "WARNING"
assert record.getMessage() == "skipped 'bag': bad quantity"
def test_text_is_one_string(caplog):
parse_quantities(["bag,two"])
print(repr(caplog.text))
assert "bad quantity" in caplog.text$ pytest -q -s
."WARNING importer:test_importer.py:11 skipped 'bag': bad quantity\n"
.
2 passed in 0.07sيعطيك caplog السجل نفسه بثلاث طرق:
caplog.records— قائمة من كائناتlogging.LogRecord، واحد لكل رسالة. لكل منها.levelname("WARNING")، و.name(اسم الـ logger،"importer")، و.getMessage()التي تضع القيمة مكان%r.caplog.messages— نصوص الرسائل النهائية فقط، في قائمة.caplog.text— كل شيء في سلسلة نصية واحدة منسقة. جيدة لفحص سريع بـin؛ وفضفاضة أكثر من اللازم لأي شيء دقيق.
فضّل records حين يهم المستوى، و messages حين لا تهم إلا الصياغة.
الرسائل التي تحت WARNING لا تُلتقط افتراضياً
تسجّل parse_quantities أيضاً ملخصاً بمستوى INFO. أضف له اختباراً:
def test_summary_is_logged(caplog):
parse_quantities(["pen,12"])
assert caplog.messages == ["parsed 1 of 1 lines"]$ pytest -q -k summary
F [100%]
=================================== FAILURES ===================================
____________________________ test_summary_is_logged ____________________________
caplog = <_pytest.logging.LogCaptureFixture object at 0x70495b4f43e0>
def test_summary_is_logged(caplog):
parse_quantities(["pen,12"])
> assert caplog.messages == ["parsed 1 of 1 lines"]
E AssertionError: assert [] == ['parsed 1 of 1 lines']
E
E Right contains one more item: 'parsed 1 of 1 lines'
E Use -v to get more diff
test_importer.py:38: AssertionError
=========================== short test summary info ============================
FAILED test_importer.py::test_summary_is_logged - AssertionError: assert [] =...
1 failed, 2 deselected in 0.08sلم يُلتقط شيء. لقد سجّلت الدالة فعلاً — لكن نظام التسجيل في بايثون يبدأ من WARNING، فأُسقطت رسالة INFO قبل أن يراها caplog. اخفض المستوى للاختبار عبر caplog.set_level:
import logging
def test_summary_is_logged(caplog):
caplog.set_level(logging.INFO)
parse_quantities(["pen,12"])
assert caplog.messages == ["parsed 1 of 1 lines"]
def test_only_the_importer_logger(caplog):
caplog.set_level(logging.INFO, logger="importer")
parse_quantities(["pen,12", "bag,two"])
assert [r.levelname for r in caplog.records] == ["WARNING", "INFO"]$ pytest -q
.. [100%]
2 passed in 0.07sاستدعِ set_level قبل الكود الذي يسجّل. والصيغة الثانية، مع logger="importer"، تخفض المستوى لذلك الـ logger وحده، فتبقى المكتبات الثرثارة صامتة. وفي الحالتين يعيد pytest المستوى القديم عند انتهاء الاختبار.
request و pytestconfig
ثمة fixture مدمجة أخرى تظهر داخل fixtures أخرى. تصف request الاختبار الذي طلب الـ fixture؛ و request.node.name هو اسم ذلك الاختبار. وهذا مفيد لإعطاء الملفات أسماء مقروءة وفريدة:
import pytest
@pytest.fixture
def export_file(tmp_path, request):
return tmp_path / f"{request.node.name}.csv"
def test_pens(export_file):
print(export_file.name)
@pytest.mark.parametrize("quantity", [1, 50])
def test_bulk(export_file, quantity):
print(export_file.name)
def test_config(pytestconfig):
print(pytestconfig.rootpath.name, pytestconfig.getoption("verbose"))$ pytest -q -s
test_pens.csv
.test_bulk[1].csv
.test_bulk[50].csv
.shop -1
.
4 passed in 0.07sاسم الاختبار المزوّد بمعاملات (parametrized) يتضمن معاملاته بين قوسين مربعين. ويعرض الاختبار الأخير pytestconfig: كائن إعدادات التشغيل، وفيه المجلد الجذر للمشروع وكل خيارات سطر الأوامر (الخيار -q يجعل مستوى الإسهاب -1). ستلتقي به مجدداً في فصلي الإعدادات والإضافات.
مثال تطبيقي متكامل
يكتب export.py صفوف المخزون الصالحة في ملف CSV، ويسجّل تحذيراً لكل صف يتخطاه، ويطبع ملخصاً:
import csv
import logging
import sys
logger = logging.getLogger("export")
def export_stock(rows, out_dir):
"""Write the valid rows to out_dir/stock.csv and return its path."""
out_dir.mkdir(parents=True, exist_ok=True)
target = out_dir / "stock.csv"
written = 0
with target.open("w", newline="") as f:
writer = csv.writer(f)
writer.writerow(["product", "quantity"])
for product, quantity in rows:
if quantity < 0:
logger.warning("skipped %s: negative quantity %d", product, quantity)
continue
writer.writerow([product, quantity])
written += 1
print(f"wrote {written} rows to {target.name}")
if written == 0:
print("nothing exported", file=sys.stderr)
return targetويتحقق test_export.py من مخرجاته الثلاثة كلها — الملف، والسجل، والطرفية:
import csv
from export import export_stock
ROWS = [("pen", 12), ("bag", -1), ("eraser", 30)]
def read_rows(path):
with path.open(newline="") as f:
return list(csv.reader(f))
def test_writes_only_valid_rows(tmp_path):
target = export_stock(ROWS, tmp_path)
assert target == tmp_path / "stock.csv"
assert read_rows(target) == [
["product", "quantity"],
["pen", "12"],
["eraser", "30"],
]
def test_creates_missing_folders(tmp_path):
out_dir = tmp_path / "exports" / "2026"
target = export_stock(ROWS, out_dir)
assert target.parent == out_dir
assert target.exists()
def test_skipped_row_is_logged(tmp_path, caplog):
export_stock(ROWS, tmp_path)
assert [r.levelname for r in caplog.records] == ["WARNING"]
assert caplog.messages == ["skipped bag: negative quantity -1"]
def test_prints_a_summary(tmp_path, capsys):
export_stock(ROWS, tmp_path)
captured = capsys.readouterr()
assert captured.out == "wrote 2 rows to stock.csv\n"
assert captured.err == ""
def test_empty_export_warns_on_stderr(tmp_path, capsys):
export_stock([("bag", -1)], tmp_path)
out, err = capsys.readouterr()
assert out == "wrote 0 rows to stock.csv\n"
assert err == "nothing exported\n"$ pytest -v
collecting ... collected 5 items
test_export.py::test_writes_only_valid_rows PASSED [ 20%]
test_export.py::test_creates_missing_folders PASSED [ 40%]
test_export.py::test_skipped_row_is_logged PASSED [ 60%]
test_export.py::test_prints_a_summary PASSED [ 80%]
test_export.py::test_empty_export_warns_on_stderr PASSED [100%]
============================== 5 passed in 0.09s ===============================
$ ls
export.py test_export.pyثلاثة أمور تستحق الانتباه.
أولاً، تأخذ export_stock المجلد معاملاً. هذا القرار الواحد هو ما يجعلها قابلة للاختبار: البرنامج يمرر مجلداً حقيقياً، والاختبارات تمرر tmp_path. أما الدالة التي تثبّت "/var/reports/stock.csv" في داخلها فلا يمكن اختبارها إلا بالكتابة في ذلك المسار الحقيقي — وهذا بالضبط ما يجب ألا يفعله اختبار أبداً.
ثانياً، كل اختبار لا يطلب إلا الـ fixtures التي يتحقق منها. اثنان منها يجمعان tmp_path مع capsys أو caplog؛ فالـ fixtures المدمجة تمتزج بحرية فيما بينها ومع الـ fixtures الخاصة بك.
ثالثاً، مجلد العمل لم يتغير بعد خمسة اختبارات كتبت كلها ملفات.
الأخطاء الشائعة وحلولها
fixture 'tmp_dir' not found خطأ إملائي في اسم المعامل. يطابق pytest الـ fixtures بالاسم الحرفي، ثم يطبع الخطأ available fixtures: مع كل fixture يعرفها — tmp_path و tmp_path_factory و capsys و capfd و caplog والبقية. انسخ الاسم الصحيح من تلك القائمة. (سترى هناك tmpdir أيضاً؛ وهي النسخة الأقدم التي تعيد كائن py.path بدلاً من pathlib.Path. استخدم tmp_path في الكود الجديد.)
ScopeMismatch: You tried to access the function scoped fixture tmp_path with a session scoped request object. طلبت fixture نطاقها scope="session" (أو "module") الـ tmp_path. لا يمكن لـ fixture طويلة العمر أن تعتمد على أخرى خاصة بكل اختبار. استخدم tmp_path_factory.mktemp("name") بدلاً منها.
FileNotFoundError: [Errno 2] No such file or directory: '/tmp/pytest-of-you/pytest-3/test_nested0/exports/report.csv' المجلد tmp_path موجود، لكن المجلد exports بداخله غير موجود — لا أحد ينشئ المجلدات نيابة عنك. استدعِ .mkdir(parents=True, exist_ok=True) على المجلد الأب أولاً، ويفضّل أن يكون ذلك داخل الكود المختبَر، كما تفعل export_stock.
يظهر ملف في /tmp/pytest-of-you/pytest-5/test_str0report.csv لا خطأ على الإطلاق — بُني المسار بالسلاسل النصية: str(tmp_path) + "report.csv". ضاع الفاصل، فوقع الملف بجوار مجلد الاختبار لا داخله. اضمم الأجزاء بـ / على كائن Path نفسه: tmp_path / "report.csv".
AssertionError: assert [] == ['parsed 1 of 1 lines'] مع caplog سُجّلت الرسالة بمستوى أدنى من WARNING ولم تُلتقط قط. استدعِ caplog.set_level(logging.INFO) — أو logging.DEBUG — قبل تشغيل الكود.
القيمة capsys.readouterr().out هي "" بينما كنت تتوقع نصاً إما أن readouterr() سابقة في الاختبار نفسه استهلكت المخرجات، أو أنها لم تمر عبر sys.stdout في بايثون — استدعاء لعملية فرعية أو os.system. في الحالة الثانية انتقل إلى capfd.
cannot use capfd and capsys at the same time طلب اختبار كليهما. إنهما يلتقطان المجاري نفسها على مستويين مختلفين؛ اختر واحداً.
Step 4 of 6 — Predict
Check your understanding
بعد السطر الأخير، ما الذي يحتويه captured.out بالضبط؟
def test_twice(capsys):
print("first")
capsys.readouterr()
print("second")
print("third")
captured = capsys.readouterr()- A'first\nsecond\nthird\n'
- B'third\n'
- C'second\nthird\n'
- D''
الدالة restock تسجّل فعلاً، ومع ذلك يفشل الاختبار بـ assert [] == ['restocked pen']. لماذا؟
import logging
logger = logging.getLogger("shop")
def restock(item):
logger.info("restocked %s", item)
def test_restock_is_logged(caplog):
restock("pen")
assert caplog.messages == ["restocked pen"]- Aاسم الـ logger هو `"shop"`؛ و `caplog` لا يستمع إلا إلى الـ logger الجذر
- Bمستوى `INFO` أدنى من المستوى الافتراضي `WARNING`، فلم يُلتقط شيء؛ استدعِ `caplog.set_level(logging.INFO)` أولاً
- Cتتضمن `caplog.messages` اسم المستوى، فقارن بـ `'INFO restocked pen'`
- Dلا تُملأ `%s` أبداً، فالرسالة هي `'restocked %s'`
تريد fixture نطاقها scope="session" أن تبني ملف عينة كبيراً مرة واحدة. أين يجب أن يوضع الملف؟
- Aفي `tmp_path` — يكفي طلبه في معاملات الـ fixture
- Bفي `tmpdir` — فهو نسخة الجلسة من `tmp_path`
- Cفي مجلد `data/` داخل المشروع، كي يبقى إلى التشغيل التالي
- Dفي المجلد الذي يعيده `tmp_path_factory.mktemp("data")`
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.
دورك الآن
اكتب وحدة notes.py فيها دالة export_notes(notes, out_dir). تأخذ قائمة من القواميس مثل {"title": "Buy pens", "done": False} و:
- تنشئ
out_dirإن لم يكن موجوداً، وتكتب الملاحظات فيout_dir / "notes.json" - تسجّل
WARNINGلكل ملاحظة عنوانها فارغ، وتستبعدها من الملف - تسجّل رسالة
INFOنصها"exported N notes" - تطبع
saved notes.jsonحين تنتهي
ثم اكتب test_notes.py يتحقق، باستخدام الـ fixtures المدمجة وحدها، من أن:
- ملف JSON يُقرأ فيعيد بالضبط الملاحظات التي كانت صالحة
- مجلداً فرعياً غير موجود مثل
tmp_path / "a" / "b"يُنشأ - التحذير يُسجَّل مع
levelname == "WARNING"، ورسالةINFOتُلتقط أيضاً (ما الذي يجب أن تستدعيه أولاً؟) - ما يُطبع هو
saved notes.json\nبالضبط، ولا شيء يذهب إلى مخرج الأخطاء
شغّل pytest -q، ثم ls (أو dir على Windows). إن ظهر أي notes.json في مجلد مشروعك، فأحد اختباراتك — أو دالتك — يكتب في مسار حقيقي. والعثور على ذلك وإصلاحه هو العادة التي يدور حولها هذا الفصل.
الحل
الخطة أولاً، على شكل جدول «قبل أن تكتب الاختبار»:
| الحالة | المدخل | المتوقع | |---|---|---| | تبقى الملاحظات الصالحة | ثلاث ملاحظات، إحداها عنوانها " " | الملف فيه الاثنتان الأخريان، دون تغيير | | مجلد غير موجود | tmp_path / "a" / "b" | يُنشأ المجلدان، والملف بداخلهما | | عنوان فارغ | الملاحظات الثلاث نفسها | WARNING واحدة، ثم INFO نصها exported 2 notes | | المخرجات | الملاحظات الثلاث نفسها | saved notes.json\n على stdout، ولا شيء على stderr | | لا شيء للتصدير | [] | الملف فيه [] |
notes.py:
import json
import logging
logger = logging.getLogger("notes")
def export_notes(notes, out_dir):
"""Write notes with a title to out_dir/notes.json and return its path."""
out_dir.mkdir(parents=True, exist_ok=True)
valid = []
for note in notes:
if not note["title"].strip():
logger.warning("skipped a note with an empty title")
continue
valid.append(note)
target = out_dir / "notes.json"
target.write_text(json.dumps(valid, indent=2))
logger.info("exported %d notes", len(valid))
print("saved notes.json")
return targettest_notes.py:
import json
import logging
from notes import export_notes
NOTES = [
{"title": "Buy pens", "done": False},
{"title": " ", "done": True},
{"title": "Call the printer", "done": True},
]
def test_file_holds_only_valid_notes(tmp_path):
target = export_notes(NOTES, tmp_path)
assert target == tmp_path / "notes.json"
assert json.loads(target.read_text()) == [NOTES[0], NOTES[2]]
def test_creates_missing_folders(tmp_path):
out_dir = tmp_path / "a" / "b"
target = export_notes(NOTES, out_dir)
assert target.parent == out_dir
assert target.exists()
def test_empty_title_is_warned_about(tmp_path, caplog):
caplog.set_level(logging.INFO, logger="notes")
export_notes(NOTES, tmp_path)
assert [(r.levelname, r.getMessage()) for r in caplog.records] == [
("WARNING", "skipped a note with an empty title"),
("INFO", "exported 2 notes"),
]
def test_prints_one_line_and_no_errors(tmp_path, capsys):
export_notes(NOTES, tmp_path)
out, err = capsys.readouterr()
assert out == "saved notes.json\n"
assert err == ""
def test_no_notes_still_writes_an_empty_list(tmp_path):
target = export_notes([], tmp_path)
assert json.loads(target.read_text()) == []$ pytest -q
..... [100%]
5 passed in 0.10s
$ ls
notes.py test_notes.pyسبب كل قرار:
out_dirمعامل. هذا ما يسمح لكل اختبار بتمريرtmp_path؛ ويُظهرlsالأخير أنه لا يوجدnotes.jsonفي المشروع.- اختبار JSON يقرأ الملف عبر
json.loadsبدلاً من مقارنة النص. الوعد هو «هذه الملاحظات، بوصفها بيانات»؛ أما المسافات البادئة بالضبط فشأنjson.dumpsلا شأننا. والمقارنة معNOTES[0]وNOTES[2]تثبت أيضاً أن الملاحظات الصالحة كُتبت دون تغيير. - العنوان
" "مسافات، لا"". العنوان المكوّن من مسافات فقط هو الحالة الحدّية — فهو لا يبدو فارغاً لـif not note["title"]، ولا يلتقطه إلا.strip(). والاختبار يثبّت ذلك القرار. caplog.set_levelيأتي أولاً، ويستهدف"notes". من دونه لا يُلتقط سطرINFOأبداً. ومقارنة أزواج(levelname, message)تتحقق من المستوى والصياغة والترتيب فيassertواحدة — دون لمسcaplog.text، الذي كانت أسماء ملفاته وأرقام أسطره ستكسر الاختبار مع كل تعديل.err == ""مؤكَّدة عن قصد. «لم يحدث خطأ» جزء من العقد، ولولا ذلك لمرّprintتصحيحي شارد إلى مخرج الأخطاء دون أن يلاحظه أحد.- للقائمة الفارغة اختبارها الخاص. إنها الحالة الطرفية التي سيصطدم بها قارئ الملف أولاً في حساب جديد، وكتابة
[]بدلاً من لا شيء على الإطلاق قرار يستحق الحفاظ عليه.
Step 6 of 6
التحدي — the chapter quiz
عشرة أسئلة متدرجة من السهل إلى الصعب. الأسئلة الأخيرة صعبة عن قصد.
Sign in to take the quiz