الفصل 09

الـ fixtures المدمجة — tmp_path و capsys و caplog

الـ fixtures التي يأتي بها pytest للمواضع المزعجة في الاختبار: الملفات والمخرجات المطبوعة والسجلات. اختبار كود يكتب CSV و JSON دون لمس مسارات حقيقية، والتحقق مما يطبعه ويسجّله.

45 دقيقةPython 3.12
  1. 1المشكلة
  2. 2الفهم
  3. 3أمثلة محلولة
  4. 4التوقع
  5. 5التطبيق
  6. 6التحدي

المشكلة التي نقوم بحلها

إليك دالة تكتب ملف CSV، واختباراً لها:

python
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"
text
$ 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 اختبارك مجلداً لم يكن موجوداً قبل لحظة:

python
def test_first(tmp_path):
    print(tmp_path)
    print(type(tmp_path))


def test_second(tmp_path):
    print(tmp_path)
text
$ 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، لأن حفظ الإعدادات هو مهمة الكتابة في الملفات الأخرى التي لا يكاد يخلو منها برنامج:

python
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()
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:

python
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
text
$ 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 — اختبار ما يطبعه البرنامج

لنبدأ بالخطأ الذي يقع فيه الجميع تقريباً مرة واحدة. دالة تطبع تحية؛ والاختبار يتحقق من نتيجتها:

python
def greet(name):
    print(f"hello, {name}")


def test_greet():
    assert greet("Rina") == "hello, Rina"
text
$ 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 هو طريقتك للوصول إليه:

python
def greet(name):
    print(f"hello, {name}")


def test_greet(capsys):
    greet("Rina")

    assert capsys.readouterr().out == "hello, Rina\n"
text
$ pytest -q
.                                                                        [100%]
1 passed in 0.07s

استدعِ الدالة أولاً، ثم اقرأ ما طبعته. برامج سطر الأوامر الحقيقية تطبع أكثر من سطر، على مجريين، وتعيد رمز خروج. إليك واحداً، مع اختباراته:

python
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
text
$ 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 يتحقق من أنها ذهبت إلى هناك و أن شيئاً لم يذهب إلى المخرج القياسي.

عبارة «منذ آخر مرة سألت فيها» مقصودة حرفياً — القراءة تفرغ المخزن المؤقت:

python
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"
text
$ pytest -q
.                                                                        [100%]
1 passed in 0.07s

هذا مفيد: يمكنك التحقق من المخرجات خطوة بخطوة. وهو أيضاً سبب عودة readouterr() الثانية فارغة أحياناً حين لا تتوقع ذلك.

capfd — حين لا تمر المخرجات عبر بايثون

يستبدل capsys الكائنين sys.stdout و sys.stderr في بايثون. أما ما يكتب مباشرة إلى مخرجات نظام التشغيل — عملية فرعية، أو امتداد مكتوب بلغة C — فيلتف حوله. يلتقط capfd على مستوى أدنى، عند واصف الملف (file descriptor)، فيلتقط ذلك أيضاً:

python
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"
text
$ pytest -q
..                                                                       [100%]
2 passed in 0.07s

الدالة readouterr() نفسها، و .out و .err نفساهما. استخدم capsys افتراضياً، ولا تنتقل إلى capfd إلا حين تكون مخرجات آتية من خارج بايثون مفقودة.

caplog — اختبار ما يسجّله البرنامج

البرامج الحقيقية تسجّل بدلاً من أن تطبع. والتحذير في السجل سلوك كأي سلوك آخر، فهو يستحق اختباراً:

python
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
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. أضف له اختباراً:

python
def test_summary_is_logged(caplog):
    parse_quantities(["pen,12"])

    assert caplog.messages == ["parsed 1 of 1 lines"]
text
$ 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:

python
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"]
text
$ pytest -q
..                                                                       [100%]
2 passed in 0.07s

استدعِ set_level قبل الكود الذي يسجّل. والصيغة الثانية، مع logger="importer"، تخفض المستوى لذلك الـ logger وحده، فتبقى المكتبات الثرثارة صامتة. وفي الحالتين يعيد pytest المستوى القديم عند انتهاء الاختبار.

request و pytestconfig

ثمة fixture مدمجة أخرى تظهر داخل fixtures أخرى. تصف request الاختبار الذي طلب الـ fixture؛ و request.node.name هو اسم ذلك الاختبار. وهذا مفيد لإعطاء الملفات أسماء مقروءة وفريدة:

python
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"))
text
$ 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، ويسجّل تحذيراً لكل صف يتخطاه، ويطبع ملخصاً:

python
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 من مخرجاته الثلاثة كلها — الملف، والسجل، والطرفية:

python
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"
text
$ 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 طلب اختبار كليهما. إنهما يلتقطان المجاري نفسها على مستويين مختلفين؛ اختر واحداً.