অধ্যায় 09

built-in fixture — tmp_path, capsys আর caplog

ফাইল, ছাপা আউটপুট আর লগ — টেস্টের ঝামেলার জায়গাগুলোর জন্য pytest যে fixture সাথে দেয়। আসল পাথে না লিখে CSV ও JSON টেস্ট করা, print আর logging যাচাই করা।

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 থাকত, টেস্টটি এইমাত্র সেটি মুছে নতুন করে লিখে দিয়েছে।

একটি টেস্টের উচিত পৃথিবীকে ঠিক যেমন পেয়েছিল তেমনই রেখে যাওয়া। ফাইল সবচেয়ে স্পষ্ট উদাহরণ, কিন্তু ছাপা আউটপুট আর লগ মেসেজ একই প্রশ্ন তোলে উল্টো দিক থেকে: ফাংশনটি কিছু একটা বাইরে পাঠায়, আর টেস্টকে সেটা ধরতে হয়।

আগের দুটি অধ্যায়ে আপনি fixture লিখতে শিখেছেন। এই অধ্যায় সেই fixture-গুলো নিয়ে যেগুলো pytest এই ঝামেলার জায়গাগুলোর জন্য আগে থেকেই সাথে দেয় — আপনাকে শুধু নাম ধরে চাইতে হবে।

এই অধ্যায় শেষে আপনি পারবেন

  • tmp_path দিয়ে প্রতিটি টেস্টকে তার নিজের খালি ফোল্ডার দিতে, আর CSV বা JSON লেখে এমন ফাংশন টেস্ট করতে
  • tmp_path_factory দিয়ে গোটা সেশনজুড়ে একটি অস্থায়ী ফোল্ডার ভাগ করে নিতে
  • capsys দিয়ে একটি প্রোগ্রাম কী ছাপে তা টেস্ট করতে, আর বুঝতে কখন তার বদলে capfd লাগে
  • caplog দিয়ে একটি প্রোগ্রাম কী লগ করে তা টেস্ট করতে, WARNING-এর নিচের মেসেজসহ
  • request.node.name দিয়ে জানতে কোন টেস্টটি চলছে
  • এই fixture-গুলো ভুলভাবে ব্যবহার করলে যে ত্রুটিগুলো আসে সেগুলো পড়তে

আগে যা জানা লাগবে: fixture-এর scope আর conftest.py।


টেস্ট লেখার আগে

যে কোড ফাইল, টার্মিনাল বা লগ ছোঁয়, তার পার্শ্বপ্রতিক্রিয়া (side effect) আছে: তার আসল ফল হলো পৃথিবীতে সে যা রেখে যায়, শুধু যা ফেরত দেয় তা নয়। তাই কোনো টেস্ট কোড লেখার আগে লিখে ফেলুন প্রতিটি প্রভাব কোথায় যায়।

১. প্রতিটি আউটপুটের নাম দিন। স্টকের সারি এক্সপোর্ট করে এমন একটি ফাংশনের চারটি পর্যন্ত আউটপুট থাকতে পারে:

  • ফেরত দেওয়া মান (যে পাথে সে লিখেছে)
  • ফাইলের ভেতরের লেখা
  • সে কী ছাপে — standard output আর standard error-এ ভাগ করে
  • সে কী লগ করে, আর কোন লেভেলে

প্রতিটি আলাদা একটি প্রতিশ্রুতি, আর প্রতিটিকে দেখার একটি উপায় লাগে। ফেরত মান আপনি এমনিতেই পান। বাকি তিনটির জন্য pytest-এর একটি করে fixture আছে: ফাইলের জন্য tmp_path, ছাপা লেখার জন্য capsys, লগের জন্য caplog।

২. নিশ্চিত করুন কোডটি টেস্টকে ঠিক করতে দেয় ফাইল কোথায় যাবে। এই প্রশ্নটিই প্রথমে করতে হয়, কারণ উত্তর «না» হলে কোনো fixture আপনাকে বাঁচাতে পারবে না। যে ফাংশন নিজেই নিজের পাথ বানায় — open("/var/reports/stock.csv", "w") — তাকে টেস্ট করার একমাত্র উপায় সেই আসল জায়গায় লেখা। যে ফাংশন ফোল্ডার বা পাথ প্যারামিটার হিসেবে নেয়, তাকে tmp_path-এর দিকে তাক করা যায়। কোডটি আপনার হলে, সেটিকে পাথ নেওয়ার মতো করে বদলে দিন। (বদলানো না গেলে, পরের অধ্যায়ের monkeypatch হলো বেরোনোর পথ।)

৩. আপনার সেটআপ দেখে নিন। কিছুই ইনস্টল করতে হবে না — এই অধ্যায়ের প্রতিটি fixture pytest-এর সাথেই আসে। দরকার সেই চেনা জিনিসগুলো: pytest সহ একটি virtual environment, আর টেস্ট ফাইল থেকে কোডটি import করা যাওয়া। আপনার logger-এর নাম জেনে রাখুন (logging.getLogger("export")), কারণ caplog.set_level সেটিকে লক্ষ্য করতে পারে।

৪. কেসগুলোর পরিকল্পনা করুন। 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)] | শুধু হেডার; standard error-এ nothing exported | | পরিষ্কার আউটপুট | যেকোনো বৈধ সারি | standard error-এ কিছু নেই |

৫. ঠিক করুন কী টেস্ট করবেন না। পাইথনের 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 function-scoped: প্রতিটি টেস্টের জন্য নতুন ফোল্ডার। কখনো কখনো এটা অপচয়। ধরুন কয়েকটি টেস্ট একটি বড় নমুনা ফাইল পড়ে, যা বানাতে সময় লাগে। গত অধ্যায়ে আপনি হাত বাড়াতেন scope="session"-এর দিকে — কিন্তু একটি session fixture tmp_path ব্যবহার করতে পারে না, কারণ সেশনজুড়ে বাঁচে এমন fixture এমন কিছুর উপর নির্ভর করতে পারে না যা প্রতিটি টেস্টের পর ফেলে দেওয়া হয়।

এর জন্য আছে tmp_path_factory, যা নিজেই session-scoped। আপনি তার কাছে 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

আগে ফাংশনটি ডাকুন, তারপর পড়ুন সে কী ছেপেছে। আসল কমান্ড-লাইন প্রোগ্রাম একাধিক লাইন ছাপে, দুটি স্ট্রিমে, আর একটি exit code ফেরত দেয়। এখানে তেমন একটি, টেস্টসহ:

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() ফেরত দেয় standard output আর standard error-এ লেখা সবকিছু শেষবার আপনি চাওয়ার পর থেকে, দুটি স্ট্রিং ঘরসহ একটি অবজেক্ট হিসেবে: .out আর .err। এটি একটি named tuple, তাই out, err = capsys.readouterr()-ও কাজ করে।

বাস্তবে দুটি খুঁটিনাটি গুরুত্বপূর্ণ:

  • print একটি নতুন লাইন যোগ করে। ধরা লেখাটি "pen: 12 in stock\n", "pen: 12 in stock" নয়। কয়েক লাইনের জন্য .splitlines() মেলানোর মতো একটি পরিষ্কার তালিকা দেয়।
  • ত্রুটি যায় err-এ। usage মেসেজের জায়গা standard error, আর test_usage_goes_to_stderr যাচাই করে যে সেটা সেখানেই গেছে এবং standard output-এ কিছু যায়নি।

«শেষবার চাওয়ার পর থেকে» কথাটা আক্ষরিক — পড়লে বাফার খালি হয়ে যায়:

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 extension — তা এর পাশ কাটিয়ে যায়। 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

কিছুই ধরা পড়েনি। ফাংশনটি লগ করেছিল ঠিকই — কিন্তু পাইথনের logging শুরু হয় 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

আরও একটি built-in fixture অন্য fixture-এর ভেতরে দেখা দেয়। 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

একটি parametrize করা টেস্টের নামে বর্গবন্ধনীর ভেতরে তার প্যারামিটারগুলো থাকে। আর শেষ টেস্টটি দেখায় pytestconfig: রানটির কনফিগারেশন অবজেক্ট, যাতে আছে প্রজেক্টের রুট ফোল্ডার আর প্রতিটি কমান্ড-লাইন অপশন (-q verbosity-কে -1 করে)। কনফিগারেশন আর plugin-এর অধ্যায়গুলোতে এর সাথে আবার দেখা হবে।


একটা সম্পূর্ণ উদাহরণ

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" বেঁধে রাখে, তাকে টেস্ট করা যায় কেবল সেই আসল পাথে লিখে — আর ঠিক সেটাই একটি টেস্টের কখনো করা উচিত নয়।

দ্বিতীয়ত, প্রতিটি টেস্ট কেবল সেই fixture-গুলো চায় যা সে যাচাই করে। তাদের দুটি tmp_path-এর সাথে capsys বা caplog মেলায়; built-in fixture-গুলো একে অপরের সাথে এবং আপনার নিজেরগুলোর সাথে অবাধে মেশে।

তৃতীয়ত, পাঁচটি টেস্ট ফাইল লেখার পরও working directory অপরিবর্তিত।


কিছু ভাঙা অবস্থা ও তার সমাধান

fixture 'tmp_dir' not found প্যারামিটারের নামে টাইপো। pytest fixture মেলায় হুবহু নাম ধরে, আর ত্রুটিটি এরপর available fixtures: দিয়ে তার জানা প্রতিটি fixture ছাপে — tmp_path, tmp_path_factory, capsys, capfd, caplog এবং বাকিগুলো। সেই তালিকা থেকে সঠিক নামটি কপি করুন। (সেখানে tmpdir-ও দেখবেন; এটি পুরনো সংস্করণ, যা pathlib.Path-এর বদলে একটি py.path অবজেক্ট দেয়। নতুন কোডে tmp_path ব্যবহার করুন।)

ScopeMismatch: You tried to access the function scoped fixture tmp_path with a session scoped request object. scope="session" (বা "module") সহ একটি fixture tmp_path চেয়েছে। দীর্ঘজীবী একটি fixture প্রতি-টেস্টের কোনো 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"।

caplog-এর সাথে AssertionError: assert [] == ['parsed 1 of 1 lines'] মেসেজটি WARNING-এর নিচে লগ হয়েছিল আর কখনো ধরা পড়েনি। কোড চালানোর আগে caplog.set_level(logging.INFO) — বা logging.DEBUG — ডাকুন।

লেখা আশা করছিলেন, কিন্তু capsys.readouterr().out হলো "" হয় একই টেস্টে আগের একটি readouterr() আউটপুটটি আগেই নিয়ে নিয়েছে, নয়তো সেটা পাইথনের sys.stdout দিয়ে যায়নি — একটি subprocess বা os.system ডাক। দ্বিতীয় ক্ষেত্রে capfd-তে যান।

cannot use capfd and capsys at the same time একটি টেস্ট দুটোই চেয়েছে। তারা একই স্ট্রিম ভিন্ন স্তরে ধরে; একটি বেছে নিন।