built-in fixture — tmp_path, capsys আর caplog
ফাইল, ছাপা আউটপুট আর লগ — টেস্টের ঝামেলার জায়গাগুলোর জন্য pytest যে fixture সাথে দেয়। আসল পাথে না লিখে CSV ও JSON টেস্ট করা, print আর logging যাচাই করা।
- 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 থাকত, টেস্টটি এইমাত্র সেটি মুছে নতুন করে লিখে দিয়েছে।
একটি টেস্টের উচিত পৃথিবীকে ঠিক যেমন পেয়েছিল তেমনই রেখে যাওয়া। ফাইল সবচেয়ে স্পষ্ট উদাহরণ, কিন্তু ছাপা আউটপুট আর লগ মেসেজ একই প্রশ্ন তোলে উল্টো দিক থেকে: ফাংশনটি কিছু একটা বাইরে পাঠায়, আর টেস্টকে সেটা ধরতে হয়।
আগের দুটি অধ্যায়ে আপনি 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 আপনার টেস্টকে এমন একটি ডিরেক্টরি দেয় যা এক মুহূর্ত আগেও ছিল না:
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 function-scoped: প্রতিটি টেস্টের জন্য নতুন ফোল্ডার। কখনো কখনো এটা অপচয়। ধরুন কয়েকটি টেস্ট একটি বড় নমুনা ফাইল পড়ে, যা বানাতে সময় লাগে। গত অধ্যায়ে আপনি হাত বাড়াতেন scope="session"-এর দিকে — কিন্তু একটি session fixture tmp_path ব্যবহার করতে পারে না, কারণ সেশনজুড়ে বাঁচে এমন fixture এমন কিছুর উপর নির্ভর করতে পারে না যা প্রতিটি টেস্টের পর ফেলে দেওয়া হয়।
এর জন্য আছে tmp_path_factory, যা নিজেই session-scoped। আপনি তার কাছে 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.09sprint লেখা পাঠায়; ফেরত দেয় না, তাই 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আগে ফাংশনটি ডাকুন, তারপর পড়ুন সে কী ছেপেছে। আসল কমান্ড-লাইন প্রোগ্রাম একাধিক লাইন ছাপে, দুটি স্ট্রিমে, আর একটি exit code ফেরত দেয়। এখানে তেমন একটি, টেস্টসহ:
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.08scapsys.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-এ কিছু যায়নি।
«শেষবার চাওয়ার পর থেকে» কথাটা আক্ষরিক — পড়লে বাফার খালি হয়ে যায়:
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 extension — তা এর পাশ কাটিয়ে যায়। 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.07scaplog একই লগ আপনাকে তিনভাবে দেয়:
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কিছুই ধরা পড়েনি। ফাংশনটি লগ করেছিল ঠিকই — কিন্তু পাইথনের logging শুরু হয় 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
আরও একটি built-in fixture অন্য fixture-এর ভেতরে দেখা দেয়। 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একটি parametrize করা টেস্টের নামে বর্গবন্ধনীর ভেতরে তার প্যারামিটারগুলো থাকে। আর শেষ টেস্টটি দেখায় pytestconfig: রানটির কনফিগারেশন অবজেক্ট, যাতে আছে প্রজেক্টের রুট ফোল্ডার আর প্রতিটি কমান্ড-লাইন অপশন (-q verbosity-কে -1 করে)। কনফিগারেশন আর plugin-এর অধ্যায়গুলোতে এর সাথে আবার দেখা হবে।
একটা সম্পূর্ণ উদাহরণ
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 targettest_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" বেঁধে রাখে, তাকে টেস্ট করা যায় কেবল সেই আসল পাথে লিখে — আর ঠিক সেটাই একটি টেস্টের কখনো করা উচিত নয়।
দ্বিতীয়ত, প্রতিটি টেস্ট কেবল সেই 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 একটি টেস্ট দুটোই চেয়েছে। তারা একই স্ট্রিম ভিন্ন স্তরে ধরে; একটি বেছে নিন।
ধাপ ৪ / ৬ — অনুমান
যাচাই করুন
শেষ লাইনের পর 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"]- Alogger-এর নাম `"shop"`; `caplog` কেবল root logger শোনে
- B`INFO` ডিফল্ট `WARNING` লেভেলের নিচে, তাই কিছুই ধরা পড়েনি; আগে `caplog.set_level(logging.INFO)` ডাকুন
- C`caplog.messages`-এ লেভেলের নামও থাকে, তাই `'INFO restocked pen'`-এর সাথে মেলাতে হবে
- D`%s` পূরণ হয় না, তাই মেসেজটি `'restocked %s'`
একটি scope="session" fixture একটি বড় নমুনা ফাইল একবার বানাতে চায়। ফাইলটি কোথায় রাখা উচিত?
- A`tmp_path`-এ — fixture-এর প্যারামিটারে এটি চাইলেই হলো
- B`tmpdir`-এ — এটি `tmp_path`-এর session সংস্করণ
- Cপ্রজেক্টের ভেতরে একটি `data/` ফোল্ডারে, যাতে পরের রানেও থাকে
- D`tmp_path_factory.mktemp("data")` যে ফোল্ডার দেয়, তাতে
উত্তর দিতে অ্যাকাউন্ট লাগবে
উত্তর মিলিয়ে দেখতে সাইন ইন করুন
প্রশ্নগুলো উপরে আছে, আর মাথায় মাথায় উত্তর ভেবে নেওয়াই আসল কাজ। সঠিক উত্তর, ব্যাখ্যা আর তিন ধাপের ইঙ্গিত দেখতে সাইন ইন করুন।
নিজে করুন
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 লিখুন যা, কেবল built-in fixture ব্যবহার করে, যাচাই করে:
- JSON ফাইলটি পড়লে ঠিক বৈধ নোটগুলোই ফেরত আসে
tmp_path / "a" / "b"-এর মতো অনুপস্থিত একটি সাবফোল্ডার তৈরি হয়- সতর্কবার্তাটি
levelname == "WARNING"সহ লগ হয়, আরINFOমেসেজটিও ধরা পড়ে (তার জন্য আগে কী ডাকতে হবে?) - ঠিক
saved notes.json\nছাপা হয়, আর standard error-এ কিছু যায় না
pytest -q চালান, তারপর ls (Windows-এ dir)। আপনার প্রজেক্ট ফোল্ডারে কোনো notes.json দেখা দিলে, আপনার কোনো টেস্ট — বা আপনার ফাংশন — একটি আসল পাথে লিখছে। সেটা খুঁজে বের করে ঠিক করা, এই অভ্যাসটিই এই অধ্যায়ের বিষয়।
সমাধান
প্রথমে পরিকল্পনা, «টেস্ট লেখার আগে» অংশের টেবিলের আকারে:
| কেস | ইনপুট | প্রত্যাশিত ফল | |---|---|---| | বৈধ নোট থাকে | তিনটি নোট, একটির শিরোনাম " " | ফাইলে বাকি দুটি, অপরিবর্তিত | | ফোল্ডার নেই | tmp_path / "a" / "b" | দুটি ফোল্ডারই তৈরি, ফাইল ভেতরে | | ফাঁকা শিরোনাম | একই তিনটি নোট | একটি WARNING, তারপর INFO exported 2 notes | | আউটপুট | একই তিনটি নোট | stdout-এ saved notes.json\n, 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দিয়ে পড়ে, লেখা মেলানোর বদলে। প্রতিশ্রুতিটি হলো «এই নোটগুলো, ডেটা হিসেবে»; হুবহু indentation হলোjson.dumps-এর ব্যাপার, আমাদের নয়।NOTES[0]আরNOTES[2]-এর সাথে মেলানো এটাও প্রমাণ করে যে বৈধ নোটগুলো অপরিবর্তিত লেখা হয়েছে। - শিরোনাম
" "হলো স্পেস,""নয়। শুধু স্পেস দিয়ে তৈরি শিরোনাম হলো সীমানার কেস —if not note["title"]-এর চোখে সেটা ফাঁকা নয়, শুধু.strip()তাকে ধরে। টেস্টটি সেই সিদ্ধান্তটি পাকা করে রাখে। caplog.set_levelআসে প্রথমে, আর লক্ষ্য করে"notes"-কে। এটা ছাড়াINFOলাইনটি কখনো ধরা পড়ে না।(levelname, message)জোড়া মেলানো এক assert-এই লেভেল, শব্দ আর ক্রম যাচাই করে —caplog.textনা ছুঁয়ে, যার ফাইলের নাম আর লাইন নম্বর প্রতিটি সম্পাদনায় টেস্টটি ভেঙে দিত।err == ""ইচ্ছে করেই assert করা। «কিছু ভুল হয়নি» চুক্তিরই অংশ, আর না হলে standard error-এ একটি বেখেয়ালি debugprintনজর এড়িয়ে যেত।- ফাঁকা তালিকার নিজের টেস্ট আছে। নতুন অ্যাকাউন্টে ফাইলের একজন পাঠক প্রথমেই এই সীমার কেসে পড়বে, আর কিছু না লিখে
[]লেখা একটি সিদ্ধান্ত যা ধরে রাখার মতো।
ধাপ ৬ / ৬
কঠিন করা — অধ্যায়ের কুইজ
সহজ থেকে কঠিন — দশটি প্রশ্ন, শেষেরগুলো ইচ্ছে করেই কঠিন।
সাইন ইন করে কুইজ দিন