monkeypatch — এনভায়রনমেন্ট, সময় আর নেটওয়ার্ক নিয়ন্ত্রণে
এনভায়রনমেন্ট ভেরিয়েবল, ঘড়ি, র্যান্ডম সংখ্যা বা নেটওয়ার্কের ওপর নির্ভর করা কোড টেস্ট করা। setenv, delenv, setattr, setitem, chdir, প্রতিটি টেস্টের পর স্বয়ংক্রিয় undo, "যেখানে খোঁজা হয় সেখানে প্যাচ করুন" নিয়ম, আর প্যাচ ছাড়াই dependency injection।
- 1সমস্যা
- 2বোঝা
- 3উদাহরণ
- 4অনুমান
- 5নিজে করা
- 6কঠিন করা
যে সমস্যাটা আমরা সমাধান করছি
এই ফাংশনটি ঠিক করে একটি অ্যাপ্লিকেশন ডিবাগ মোডে চলবে কি না। সে একটি এনভায়রনমেন্ট ভেরিয়েবল পড়ে:
settings.py:
import os
def debug_enabled():
return os.environ.get("APP_DEBUG", "0") == "1"test_settings.py:
from settings import debug_enabled
def test_debug_is_off_by_default():
assert debug_enabled() is Falseনিজের টার্মিনালে pytest -q চালান — টেস্ট পাস করবে। এবার এমন একটি মেশিনে চালান যেখানে কেউ APP_DEBUG=1 এক্সপোর্ট করে রেখেছে — কোনো সহকর্মীর ল্যাপটপ, বা ডিবাগিংয়ের জন্য সাজানো একটি CI সার্ভার — আর একই কোডের ওপর একই টেস্ট ফেল করবে:
$ pytest -q
. [100%]
1 passed in 0.01s
$ APP_DEBUG=1 pytest -q
F [100%]
=================================== FAILURES ===================================
_________________________ test_debug_is_off_by_default _________________________
def test_debug_is_off_by_default():
> assert debug_enabled() is False
E assert True is False
E + where True = debug_enabled()
test_settings.py:5: AssertionError
=========================== short test summary info ============================
FAILED test_settings.py::test_debug_is_off_by_default - assert True is False
1 failed in 0.01sকোডে কিছুই বদলায়নি। বদলেছে তার চারপাশের জগৎ। এনভায়রনমেন্ট ভেরিয়েবল, এখনকার সময়, র্যান্ডম সংখ্যা, বর্তমান ডিরেক্টরি, নেটওয়ার্ক — যে কোড এগুলোর কোনোটি পড়ে, সে কোথায় আর কখন চলছে তার ওপর নির্ভর করে আলাদা উত্তর দেয়। আর যে টেস্ট এগুলো নিয়ন্ত্রণ করে না, সে কেবল আপনার কোডকে টেস্ট করছে না।
টেস্টকে বলতে পারতে হবে: এই টেস্ট যতক্ষণ চলছে, জগৎটা এরকম — তারপর সবকিছু আগের জায়গায় ফিরিয়ে দিতে হবে। বিল্ট-ইন monkeypatch fixture ঠিক এই কাজটিই করে।
এই অধ্যায় শেষে আপনি পারবেন
monkeypatch.setenvআরmonkeypatch.delenvদিয়ে একটি টেস্টের জন্য এনভায়রনমেন্ট ভেরিয়েবল বসাতে ও সরাতেmonkeypatch.setattrদিয়ে একটি ফাংশন বদলে দিতে — বস্তু আর নাম দিয়ে, অথবা"module.attr"স্ট্রিং দিয়েmonkeypatch.setitemদিয়ে একটি ডিকশনারির একটি কী, আরmonkeypatch.chdirদিয়ে কাজের ডিরেক্টরি বদলাতে- দেখাতে যে টেস্ট শেষ হলে প্রতিটি পরিবর্তন ফিরিয়ে নেওয়া হয়
- একটি নামকে যেখানে খোঁজা হয় সেখানে প্যাচ করতে, আর ব্যাখ্যা করতে কেন
random.randintপ্যাচ করলেfrom random import randintতা টের পায় না - একটি ঘড়ি বা র্যান্ডম ফাংশন আর্গুমেন্ট হিসেবে পাঠাতে, যাতে কোনো প্যাচই লাগে না
আগে যা জানা লাগবে: বিল্ট-ইন fixture।
টেস্ট লেখার আগে
এ ধরনের কোডে কাজের বেশিরভাগটা হয় প্রথম def test_ লেখার আগেই। চারটি প্রশ্ন, এই ক্রমে।
১. ফাংশনটি কী প্রতিশ্রুতি দেয় — আর আপনি যা পাঠাননি এমন কী সে পড়ে? ফাংশনটি পড়ুন আর প্রতিটি লাইন চিহ্নিত করুন যা তার আর্গুমেন্টের বাইরে থেকে কিছু নেয়: os.environ, datetime.now(), random, একটি আপেক্ষিক ফাইল পাথ, মডিউল-স্তরের একটি ডিকশনারি, একটি নেটওয়ার্ক কল। এর প্রতিটিই একটি লুকানো ইনপুট। ফাংশনের প্রতিশ্রুতি হলো "এই লুকানো ইনপুটগুলো পেলে আমি এটা ফেরত দিই", আর টেস্টকে প্রতিটি লুকানো ইনপুট স্থির করে দিতে হবে — নইলে সে টেস্ট করছে মেশিনে সেই মুহূর্তে যা আছে তাকে।
২. কী কী তৈরি থাকা দরকার? সাধারণ জিনিসগুলো: pytest ইনস্টল করা একটি ভার্চুয়াল এনভায়রনমেন্ট, আর টেস্ট থেকে মডিউলটি ইমপোর্ট করা যায় এমন ব্যবস্থা (টেস্ট ফাইল তার পাশেই, অথবা প্রজেক্টটি ইনস্টল করা)। তারপর এই বিষয়ে, প্রতিটি লুকানো ইনপুটের জন্য একটি করে সিদ্ধান্ত:
- এনভায়রনমেন্ট ভেরিয়েবল —
setenvদিয়ে বসান, অথবাraising=Falseদিয়েdelenvকরুন; - নেটওয়ার্ক, ডিস্ক বা র্যান্ডমনেসে পৌঁছায় এমন ফাংশন —
setattrদিয়ে বদলে দিন, যে মডিউল তাকে ডাকে সেখানে; - মডিউল-স্তরের ডিকশনারি —
setitemদিয়ে একটি কী বদলান; - আপেক্ষিক পাথ —
chdirদিয়ে একটিtmp_path-এ চলে যান; - এখন যে কোড লিখছেন তাতে ঘড়ি বা র্যান্ডম পছন্দ — তার বদলে সেটিকে আর্গুমেন্ট হিসেবে পাঠান।
৩. কোন কোন কেস? এই অধ্যায়ের শেষে যে কনফিগারেশন লোডারটি বানাবেন সেটি ধরুন। সে প্রথমে ডিফল্ট পড়ে, তারপর ঐচ্ছিক app.cfg, তারপর APP_* এনভায়রনমেন্ট ভেরিয়েবল। কোনো কোড লেখার আগে তার পরিকল্পনা:
| কেস | টেস্ট চলাকালীন জগৎ | প্রত্যাশিত | |---|---|---| | কিছুই সেট নেই | খালি ফোল্ডার, কোনো APP_* ভেরিয়েবল নেই | port 8000, debug False, region "bd" | | ফাইল ডিফল্টকে ছাপিয়ে যায় | app.cfg-এ আছে port = 9000 | port 9000 | | এনভায়রনমেন্ট ফাইলকে ছাপিয়ে যায় | app.cfg বলে 9000, APP_PORT=7000 | port 7000 | | একটি ডিফল্ট বদলায় | DEFAULTS["region"] হলো "ae" | region "ae" | | অবৈধ মান | APP_PORT=70000 | "out of range" উল্লেখসহ ValueError | | রিডার বদলে দেওয়া | read_file ফেরত দেয় {"port": "1234"} | port 1234 | | আপনার শেলে গোলমাল | pytest চালানোর আগে APP_PORT=5 এক্সপোর্ট করা | ওপরের প্রতিটি সারি তখনও খাটে |
শেষ সারিটিই মানুষ ভুলে যায়। এটি আলাদা কোনো টেস্ট নয় — এটি গোটা টেস্ট-সমষ্টির একটি বৈশিষ্ট্য, আর এটিই ঠিক করে দেয় যে প্রতিটি টেস্টকে আগে ভেরিয়েবলগুলো পরিষ্কার করতে হবে, কেবল যেগুলো সেগুলো সেট করে তাদের নয়।
৪. কী টেস্ট করবেন না। os.environ স্ট্রিং রাখে কি না, বা urllib একটি পেজ আনতে পারে কি না — এসব টেস্ট করবেন না; ওটা পাইথনের কাজ, আর সেটি আগেই টেস্ট করা। কোনো ইউনিট টেস্টকে আসল নেটওয়ার্কে যেতে দেবেন না: তা ধীর, আপনার কোডের সাথে সম্পর্কহীন কারণে ফেল করে, আর আবহাওয়া বদলায়। আর কখনো নকলটির ওপরই assert করবেন না। এই টেস্টটি পাস করে, কিন্তু কিছুই প্রমাণ করে না:
import weather
def test_pointless(monkeypatch):
monkeypatch.setattr(weather, "fetch_temperature", lambda city: 35)
assert weather.fetch_temperature("Dhaka") == 35. [100%]
1 passed in 0.01sএটি কেবল দেখে প্যাচটি জায়গামতো বসেছে কি না, আপনার কোনো কোড কাজ করে কি না তা নয়। নকলটির অস্তিত্ব এই জন্য যাতে তার ওপর দাঁড়িয়ে অন্য কোড — নিচের advice — টেস্ট করা যায়। assert-এ যদি কেবল আপনার বদলে দেওয়া জিনিসটিরই নাম থাকে, টেস্টটি মুছে দিন।
পরের অংশগুলো একেকটি টুল ধরে এই পরিকল্পনাটি বাস্তবায়ন করে।
setenv আর delenv — প্রতি টেস্টে আলাদা এনভায়রনমেন্ট
monkeypatch হলো tmp_path-এর মতোই একটি fixture: টেস্টের প্যারামিটারে নাম লিখে চেয়ে নিন। একটু বড় একটি settings.py-এর জন্য চারটি টেস্ট:
import os
def debug_enabled():
return os.environ.get("APP_DEBUG", "0") == "1"
def api_port():
return int(os.environ.get("APP_PORT", "8000"))from settings import api_port, debug_enabled
def test_debug_on(monkeypatch):
monkeypatch.setenv("APP_DEBUG", "1")
assert debug_enabled() is True
def test_debug_off_by_default(monkeypatch):
monkeypatch.delenv("APP_DEBUG", raising=False)
assert debug_enabled() is False
def test_port_from_env(monkeypatch):
monkeypatch.setenv("APP_PORT", "9000")
assert api_port() == 9000
def test_port_default(monkeypatch):
monkeypatch.delenv("APP_PORT", raising=False)
assert api_port() == 8000$ pytest -q
.... [100%]
4 passed in 0.01s
$ APP_DEBUG=1 APP_PORT=1234 pytest -q
.... [100%]
4 passed in 0.01sদ্বিতীয় রানটিই আসল। শেল এমন মান এক্সপোর্ট করেছিল যা এর দুটি টেস্ট ভেঙে দিত, তবু চারটিই পাস করে — কারণ প্রতিটি টেস্ট নিজেই বলে দেয় তার কোন এনভায়রনমেন্ট দরকার।
setenv(name, value) একটি ভেরিয়েবল বসায়। delenv(name) একটি সরায় — আর ডিফল্টভাবে, ভেরিয়েবলটি আগে থেকে না থাকলে KeyError তোলে। "সেট নেই" কেসের টেস্ট জানতে চায় না সেটি আগে সেট ছিল কি না, কেবল এখন যেন সেট না থাকে — তাই প্রায় সবসময় আপনার চাই raising=False।
মানগুলো স্ট্রিং। এনভায়রনমেন্ট কেবল লেখা রাখে, os.environ-ও তাই। "9000"-কে 9000 বানানো আপনার কোডের কাজ, আর প্রায়ই ঠিক এই রূপান্তরটিই টেস্ট করার মতো।
সবকিছু ফিরিয়ে নেওয়া হয় — প্রমাণ করুন
দাবিটি হলো টেস্ট শেষ হলে monkeypatch তার বদলানো সবকিছু আগের অবস্থায় ফেরায়। এই হলো প্রমাণ:
import os
def test_first(monkeypatch):
monkeypatch.setenv("APP_DEBUG", "1")
print("inside first :", os.environ.get("APP_DEBUG"))
def test_second():
print("inside second:", os.environ.get("APP_DEBUG"))pytest -q -s (-s দিলে print দেখা যায়):
inside first : 1
.inside second: None
.
2 passed in 0.01sদ্বিতীয় টেস্ট monkeypatch চায়নি, তবু কিছুই দেখে না — ভেরিয়েবলটি নেই। এবার তুলনা করুন, সরাসরি os.environ-এ লিখলে কী হয়:
import os
def test_first():
os.environ["APP_DEBUG"] = "1"
print("inside first :", os.environ.get("APP_DEBUG"))
def test_second():
print("inside second:", os.environ.get("APP_DEBUG"))inside first : 1
.inside second: 1
.
2 passed in 0.01sমানটি পরের টেস্টে চুইয়ে পড়েছে, আর একই রানের পরের প্রতিটি টেস্টেও পড়বে। এই ধরনের বাগের কারণেই একটি টেস্ট একা চালালে পাস করে আর বাকিদের সাথে চালালে ফেল করে — বা আরও খারাপ, উল্টোটা।
ফিরিয়ে নেওয়ার মধ্যে কোনো জাদু নেই। প্রতিটি পরিবর্তনের আগে monkeypatch পুরোনো মানটি টুকে রাখে — অথবা টুকে রাখে যে কোনো মান ছিল না — আর teardown-এর সময় টুকে রাখা প্রতিটি মান উল্টো ক্রমে ফিরিয়ে দেয়। টেস্ট পাস করুক, ফেল করুক বা এক্সেপশন তুলুক, teardown চলবেই, তাই মাঝপথে একটি ব্যর্থ assert এনভায়রনমেন্টকে নোংরা রেখে যেতে পারে না। হাতে লেখা os.environ[...] = ... আর টেস্টের শেষে হাতে লেখা পরিষ্কার করার লাইন — সেই লাইনটিকে ঠিক ওই ব্যর্থ assert-ই টপকে যেত।
setattr — একটি টেস্টের জন্য একটি ফাংশন বদলানো
এনভায়রনমেন্ট ভেরিয়েবল সহজ কেস। কঠিনটি হলো যে কোড নেটওয়ার্কের সাথে কথা বলে:
weather.py:
import json
import urllib.request
def fetch_temperature(city):
url = f"https://api.example.com/weather?city={city}"
with urllib.request.urlopen(url, timeout=5) as response:
return json.load(response)["temp_c"]
def advice(city):
temp = fetch_temperature(city)
if temp >= 30:
return "Hot: carry water"
if temp <= 10:
return "Cold: take a jacket"
return "Mild"আকর্ষণীয় যুক্তিটুকু advice-এ: সীমারেখাগুলো, বার্তাগুলো। এটিকে যেমন আছে তেমন টেস্ট করলে টেস্টটি নির্ভর করবে একটি সার্ভার, একটি নেটওয়ার্ক সংযোগ আর আজকের আবহাওয়ার ওপর। নেটওয়ার্কহীন একটি মেশিনে:
$ pytest -q --tb=line test_real.py
F [100%]
=================================== FAILURES ===================================
E socket.gaierror: [Errno -5] No address associated with hostname
During handling of the above exception, another exception occurred:
E urllib.error.URLError: <urlopen error [Errno -5] No address associated with hostname>
/usr/lib/python3.12/urllib/request.py:1347: urllib.error.URLError: <urlopen error [Errno -5] No address associated with hostname>
1 failed in 0.01smonkeypatch.setattr একটি টেস্টের সময়টুকুর জন্য fetch_temperature-কে আপনার নিয়ন্ত্রণের কিছু একটা দিয়ে বদলে দেয়:
import weather
def test_hot_day(monkeypatch):
monkeypatch.setattr(weather, "fetch_temperature", lambda city: 35)
assert weather.advice("Dhaka") == "Hot: carry water"
def test_cold_day(monkeypatch):
monkeypatch.setattr("weather.fetch_temperature", lambda city: 4)
assert weather.advice("Oslo") == "Cold: take a jacket".. [100%]
2 passed in 0.01sলক্ষ্য নির্দেশ করার দুটি উপায় আছে, আর দুটিই একই কাজ করে:
setattr(weather, "fetch_temperature", new)— প্রথমে বস্তুটি, তারপর অ্যাট্রিবিউটের নাম স্ট্রিং হিসেবে।setattr("weather.fetch_temperature", new)— একটিই ডট-দেওয়া স্ট্রিং; pytestweatherইমপোর্ট করে আর শেষ অংশটি আলাদা করে নেয়।
টেস্ট যখন আগেই মডিউলটি ইমপোর্ট করে, বস্তু-রূপটি ব্যবহার করুন; যখন ইমপোর্টের দরকার নেই, স্ট্রিং-রূপটি। যেভাবেই হোক, খেয়াল করুন এখন কী টেস্ট হচ্ছে: fetch_temperature নয়, যেটি আমরা বদলে দিয়েছি, বরং advice-এর সীমারেখা আর বার্তা — যে কোড আমরা লিখেছি। কোনো নেটওয়ার্ক নেই, অপেক্ষা নেই, প্রতিবার একই উত্তর — আর চাইলেই 35 বা 4 ডিগ্রি তাপমাত্রা, যা আসল আবহাওয়া কখনো চাহিদামতো দেবে না।
টেস্টে সরাসরি weather.fetch_temperature = lambda city: 35 না লিখে setattr কেন? os.environ-এর মতো একই কারণে: সাধারণ অ্যাসাইনমেন্ট কখনো ফিরিয়ে নেওয়া হয় না, আর রানের পরের প্রতিটি টেস্ট নকলটিই পাবে।
নিয়ম: যেখানে খোঁজা হয় সেখানে প্যাচ করুন
এবার সেই ভুল, যা সবাই একবার করে। একটি ছোট মডিউল যা ছক্কার গুটি গড়ায়:
dice.py:
from random import randint
def roll():
return randint(1, 6)
def is_jackpot():
return roll() == 6জ্যাকপট টেস্ট করতে randint-কে 6 ফেরত দিতে বাধ্য করুন। সে থাকে random-এ, তাই সেখানেই প্যাচ করি:
import random
import dice
def test_jackpot(monkeypatch):
monkeypatch.setattr(random, "randint", lambda a, b: 6)
assert dice.is_jackpot() is TrueF [100%]
=================================== FAILURES ===================================
_________________________________ test_jackpot _________________________________
monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x7fc1b2fdc9b0>
def test_jackpot(monkeypatch):
monkeypatch.setattr(random, "randint", lambda a, b: 6)
> assert dice.is_jackpot() is True
E assert False is True
E + where False = <function is_jackpot at 0x7fc1b2fcd800>()
E + where <function is_jackpot at 0x7fc1b2fcd800> = dice.is_jackpot
test_dice.py:8: AssertionError
=========================== short test summary info ============================
FAILED test_dice.py::test_jackpot - assert False is True
1 failed in 0.01sপ্যাচ বসানো হয়েছিল, আর dice তা কখনো দেখেনি। টেস্টটি আরও কয়েকবার চালান — মাঝে মাঝে পাস করবে, যখনই আসল randint কাকতালীয়ভাবে 6 ফেলে। যে টেস্ট ভুল অথচ মাঝে মাঝে সবুজ, সে সবসময় লাল থাকা টেস্টের চেয়েও খারাপ।
কারণটি হলো from random import randint আসলে কী করে। সে random.randint-এর সাথে কোনো সংযোগ তৈরি করে না। ইমপোর্টের মুহূর্তে সে রেফারেন্সটি একটি নতুন নামে কপি করে: dice.randint। এরপর একই ফাংশনের দিকে দুটি নাম তাক করা থাকে, আর setattr তার কেবল একটিকে সরায়:
import random
import dice
print(dice.randint is random.randint)
random.randint = lambda a, b: 6
print(dice.randint is random.randint)True
Falseroll() চলার সময় পাইথন randint খোঁজে dice মডিউলে — নামটি খোঁজা হয় সেখানেই। তাই বদলাতে হবে সেখানেই:
import dice
def test_jackpot(monkeypatch):
monkeypatch.setattr(dice, "randint", lambda a, b: 6)
assert dice.is_jackpot() is True
def test_no_jackpot(monkeypatch):
monkeypatch.setattr("dice.randint", lambda a, b: 3)
assert dice.is_jackpot() is False.. [100%]
2 passed in 0.01sপরপর পাঁচবার চালানো, পাঁচবারই পাস। অন্য সমাধানটি হলো dice.py কীভাবে ইমপোর্ট করে তা বদলানো। import random লিখে random.randint(1, 6) ডাকলে প্রতিটি কলে খোঁজাটা হয় random মডিউলে, আর তখন random.randint প্যাচ করলেই কাজ হয়:
import random
def roll():
return random.randint(1, 6)
def is_jackpot():
return roll() == 6$ pytest -q # the original test, patching random.randint
. [100%]
1 passed in 0.01sএক বাক্যে নিয়মটি: যে লাইন ফাংশনটি ডাকে সেটি খুঁজুন, দেখুন সে নামটি কোন মডিউল থেকে পায়, আর নামটি সেখানেই প্যাচ করুন।
setitem — ডিকশনারির একটি কী
সেটিংস প্রায়ই মডিউল-স্তরের একটি ডিকশনারিতে রাখা হয়। গোটা ডিকশনারি বদলানো অস্বস্তিকর; setitem একটি কী বদলায় আর পরে আগের অবস্থায় ফেরায়:
shop.py:
SETTINGS = {"currency": "BDT", "tax_rate": 0.15}
def price_with_tax(amount):
total = round(amount * (1 + SETTINGS["tax_rate"]), 2)
return f"{total} {SETTINGS['currency']}"import shop
def test_tax_free_day(monkeypatch):
monkeypatch.setitem(shop.SETTINGS, "tax_rate", 0.0)
assert shop.price_with_tax(100) == "100.0 BDT"
def test_other_currency(monkeypatch):
monkeypatch.setitem(shop.SETTINGS, "currency", "INR")
assert shop.price_with_tax(100) == "115.0 INR"
def test_settings_are_back():
assert shop.SETTINGS == {"currency": "BDT", "tax_rate": 0.15}pytest -v:
test_shop.py::test_tax_free_day PASSED [ 33%]
test_shop.py::test_other_currency PASSED [ 66%]
test_shop.py::test_settings_are_back PASSED [100%]
============================== 3 passed in 0.01s ===============================তৃতীয় টেস্টটি আবারও প্রমাণ: দুটি টেস্ট একেকটি কী বদলানোর পরও ডিকশনারিটি ঠিক শুরুর মতোই আছে। এর সঙ্গী monkeypatch.delitem(d, key) একটি টেস্টের জন্য একটি কী সরিয়ে দেয়।
chdir আর syspath_prepend
যে কোড "notes.txt"-এর মতো আপেক্ষিক পাথ খোলে, সে নির্ভর করে কোন ডিরেক্টরি থেকে চলছে তার ওপর। আগের অধ্যায়ের tmp_path-এর সাথে chdir মিলিয়ে নিন:
notes.py:
from pathlib import Path
def read_notes():
return Path("notes.txt").read_text().splitlines()from pathlib import Path
from notes import read_notes
def test_reads_from_current_directory(tmp_path, monkeypatch):
(tmp_path / "notes.txt").write_text("buy milk\ncall Rina\n")
monkeypatch.chdir(tmp_path)
assert Path.cwd() == tmp_path
assert read_notes() == ["buy milk", "call Rina"]
def test_import_from_a_new_folder(tmp_path, monkeypatch):
(tmp_path / "helper.py").write_text("ANSWER = 42\n")
monkeypatch.syspath_prepend(tmp_path)
import helper
assert helper.ANSWER == 42.. [100%]
2 passed in 0.01schdir একটি টেস্টের জন্য কাজের ডিরেক্টরি সরিয়ে নেয়, আর পরে ফিরিয়ে আনে। দ্বিতীয় টেস্টটি syspath_prepend-এর এক লাইনের ব্যবহার দেখায়: সে একটি ফোল্ডারকে sys.path-এর সামনে বসায়, যাতে সেখানে লেখা মডিউল ইমপোর্ট করা যায়, আর শেষে আবার সরিয়ে দেয় — কোনো ফোল্ডার থেকে plugin লোড করে এমন কোড টেস্ট করার সময় কাজে লাগে।
আরও ভালো: নির্ভরতাটি বাইরে থেকে পাঠান
প্যাচ কাজ করে, কিন্তু প্রতিটি প্যাচ একটি ছোট্ট স্বীকারোক্তি যে কোডটি হাত বাড়িয়ে জগৎ থেকে কিছু তুলে নিয়েছে। কিছু জিনিস তো স্বচ্ছন্দে প্যাচই করা যায় না। datetime.now তার একটি:
E TypeError: cannot set 'now' attribute of immutable type 'datetime.datetime'বিকল্পটি হলো একটি ডিজাইনের সিদ্ধান্ত: ফাংশন যার ওপর নির্ভর করে, সেটি তাকে গ্রহণ করতে দিন, আসলটিকে ডিফল্ট রেখে:
shop_hours.py:
from datetime import datetime
def is_open(now=datetime.now):
hour = now().hour
return 9 <= hour < 21প্রোডাকশন কোড is_open() ডাকে আর আসল ঘড়ি পায়। টেস্ট নিজের ঘড়ি পাঠায়:
from datetime import datetime
from shop_hours import is_open
def test_closed_just_before_nine():
assert is_open(now=lambda: datetime(2026, 1, 5, 8, 59)) is False
def test_open_at_nine():
assert is_open(now=lambda: datetime(2026, 1, 5, 9, 0)) is True
def test_closed_at_nine_pm():
assert is_open(now=lambda: datetime(2026, 1, 5, 21, 0)) is False... [100%]
3 passed in 0.01sকোনো fixture নেই, ফিরিয়ে নেওয়ার কিছু নেই, নাম কোথায় খোঁজা হয় সেই প্রশ্নও নেই। টেস্টটি একটি সাধারণ বাক্যের মতো পড়া যায় — "৮:৫৯-এ দোকান বন্ধ" — আর যে সীমানার মিনিটগুলো গুরুত্বপূর্ণ সেগুলো ঠিক ঠিক ধরা সহজ।
র্যান্ডমনেসের জন্যও একই কাঠামো খাটে: def pick_winner(customers, choose=random.choice), আর টেস্ট পাঠায় choose=lambda items: items[0]। একে বলে dependency injection, আর এর জন্য কোনো ফ্রেমওয়ার্ক লাগে না: একটি ডিফল্ট আর্গুমেন্টই যথেষ্ট। কোড লেখার সময় এটি ব্যবহার করুন। monkeypatch হাতে নিন তখন, যখন এমন কোড টেস্ট করছেন যা আপনি বদলাতে পারেন না বা বদলানো উচিত নয়, অথবা যখন নির্ভরতাটি কয়েক স্তর কলের গভীরে চাপা পড়ে আছে।
পূর্ণাঙ্গ উদাহরণ
এটিই সেই কনফিগারেশন লোডার যার পরিকল্পনা "টেস্ট লেখার আগে" অংশে করা হয়েছিল: তিনটি স্তর — বিল্ট-ইন ডিফল্ট, বর্তমান ডিরেক্টরিতে একটি ঐচ্ছিক app.cfg ফাইল, আর দুটোকেই ছাপিয়ে যাওয়া এনভায়রনমেন্ট ভেরিয়েবল। পরিকল্পনার প্রতিটি সারি একটি করে টেস্ট হয়।
app_config.py:
import os
from pathlib import Path
DEFAULTS = {"port": "8000", "debug": "0", "region": "bd"}
def read_file(name="app.cfg"):
path = Path(name)
if not path.exists():
return {}
values = {}
for line in path.read_text().splitlines():
if "=" in line:
key, value = line.split("=", 1)
values[key.strip()] = value.strip()
return values
def load_config():
# Lowest priority first: defaults, then the file, then the environment.
raw = dict(DEFAULTS)
raw.update(read_file())
for key in raw:
env_name = "APP_" + key.upper()
if env_name in os.environ:
raw[key] = os.environ[env_name]
port = int(raw["port"])
if not 1 <= port <= 65535:
raise ValueError(f"port out of range: {port}")
return {"port": port, "debug": raw["debug"] == "1", "region": raw["region"]}test_app_config.py:
import pytest
import app_config
@pytest.fixture
def clean_env(tmp_path, monkeypatch):
# An empty folder and no APP_ variables: the test starts from nothing.
monkeypatch.chdir(tmp_path)
for name in ("APP_PORT", "APP_DEBUG", "APP_REGION"):
monkeypatch.delenv(name, raising=False)
return tmp_path
def test_defaults(clean_env):
assert app_config.load_config() == {"port": 8000, "debug": False, "region": "bd"}
def test_file_beats_defaults(clean_env):
(clean_env / "app.cfg").write_text("port = 9000\nregion = in\n")
config = app_config.load_config()
assert config["port"] == 9000
assert config["region"] == "in"
def test_env_beats_file(clean_env, monkeypatch):
(clean_env / "app.cfg").write_text("port = 9000\n")
monkeypatch.setenv("APP_PORT", "7000")
assert app_config.load_config()["port"] == 7000
def test_changed_default(clean_env, monkeypatch):
monkeypatch.setitem(app_config.DEFAULTS, "region", "ae")
assert app_config.load_config()["region"] == "ae"
def test_bad_port(clean_env, monkeypatch):
monkeypatch.setenv("APP_PORT", "70000")
with pytest.raises(ValueError, match="out of range"):
app_config.load_config()
def test_reader_can_be_replaced(clean_env, monkeypatch):
monkeypatch.setattr(app_config, "read_file", lambda: {"port": "1234"})
assert app_config.load_config()["port"] == 1234pytest -v:
collected 6 items
test_app_config.py::test_defaults PASSED [ 16%]
test_app_config.py::test_file_beats_defaults PASSED [ 33%]
test_app_config.py::test_env_beats_file PASSED [ 50%]
test_app_config.py::test_changed_default PASSED [ 66%]
test_app_config.py::test_bad_port PASSED [ 83%]
test_app_config.py::test_reader_can_be_replaced PASSED [100%]
============================== 6 passed in 0.01s ===============================তিনটি জিনিস খেয়াল করার মতো।
প্রথমত, clean_env fixture নিজেই monkeypatch ব্যবহার করে। একটি fixture আর যে টেস্ট তাকে চায়, দুজনে একটিই monkeypatch বস্তু ভাগ করে নেয়, তাই টেস্ট তার ওপরে নিজের setenv যোগ করতে পারে, আর শেষে দুজনের সব পরিবর্তন একসাথে ফিরিয়ে নেওয়া হয়।
দ্বিতীয়ত, fixture ভেরিয়েবলগুলো নেই বলে ভরসা না করে সেগুলো সরিয়ে দেয়। এ কারণেই APP_PORT=5 APP_REGION=xx pytest -q দিয়ে চালালেও টেস্ট-সমষ্টি একই উত্তর দেয় — চালিয়ে দেখুন; এখনও বলবে 6 passed। এটিই পরিকল্পনার শেষ সারি, আলাদা কোনো টেস্ট দিয়ে নয়, ডিজাইন দিয়েই পূরণ করা।
তৃতীয়ত, শেষ টেস্টটি app_config.read_file প্যাচ করে — সেই মডিউল, যেখানে load_config নামটি খোঁজে। দুটি ফাংশনই একই মডিউলে থাকায়, গুরুত্বপূর্ণ জায়গা কেবল ওই একটিই।
যখন ভেঙে যায়
AttributeError: <module 'weather' from '.../weather.py'> has no attribute 'fetch_temprature' অ্যাট্রিবিউটের নামে টাইপো। setattr আগে থেকে না থাকা অ্যাট্রিবিউট তৈরি করতে রাজি হয় না, আর এটি আপনাকে এমন একটি নাম "প্যাচ" করা থেকে বাঁচায় যা কেউ কখনো পড়ে না। স্ট্রিং-রূপ কথাটি একটু অন্যভাবে বলে — AttributeError: 'module' object at weather has no attribute 'fetch_temprature' — কিন্তু মানে একই। কেবল যদি সত্যিই একটি নতুন অ্যাট্রিবিউট যোগ করতে চান, তখনই raising=False দিন।
প্যাচ বসেছে, কিন্তু আসল ফাংশনই চলছে কোনো ত্রুটিই নেই: টেস্ট আসল মান নিয়ে ফেল করে, অথবা ভাগ্যক্রমে পাস করে। আপনি নামটি প্যাচ করেছেন যেখানে সেটি সংজ্ঞায়িত (random.randint), যেখানে খোঁজা হয় সেখানে নয় (dice.randint)। টেস্ট-করা কোডের ইমপোর্ট লাইনটি দেখুন।
monkeypatch.delenv থেকে KeyError: 'APP_DEBUG' ভেরিয়েবলটি সেট ছিল না, আর delenv ডিফল্টভাবে এক্সেপশন তোলে। raising=False যোগ করুন। delitem আর delattr-এর বেলায়ও একই কথা।
PytestWarning: Value of environment variable APP_PORT type should be str, but got 9000 (type: int); converted to str implicitly আপনি লিখেছেন setenv("APP_PORT", 9000)। এনভায়রনমেন্ট ভেরিয়েবল হলো লেখা; লিখুন "9000"।
TypeError: cannot set 'now' attribute of immutable type 'datetime.datetime' datetime.datetime-এর মতো বিল্ট-ইন টাইপের মেথড বদলানো যায় না। তার বদলে is_open উদাহরণের মতো ঘড়িটিকে আর্গুমেন্ট হিসেবে পাঠান।
ScopeMismatch: You tried to access the function scoped fixture monkeypatch with a module scoped request object. scope="module" (বা "session") দেওয়া একটি fixture monkeypatch চেয়েছে, যা কেবল একটি টেস্টের জন্য বাঁচে। fixture-টিকে function scope-এ রাখুন, অথবা বড় fixture-এর ভেতরে pytest.MonkeyPatch.context() ব্যবহার করে ফিরিয়ে নেওয়ার দায়িত্ব নিজে নিন।
ধাপ ৪ / ৬ — অনুমান
যাচাই করুন
pytest -q -s দিয়ে চালানো হলো। দুটি print কী ছাপাবে (আগে থেকে CITY সেট নেই)?
import os
def test_one(monkeypatch):
monkeypatch.setenv("CITY", "Dhaka")
print(os.environ.get("CITY", "none"))
def test_two():
print(os.environ.get("CITY", "none"))- ADhaka, then Dhaka
- Bnone, then none
- CDhaka, then none
- DDhaka, then an error: KeyError: 'CITY'
এই টেস্ট বেশিরভাগ সময় ফেল করে, মাঝে মাঝে পাস করে। ভুলটা কোথায়?
# dice.py
from random import randint
def roll():
return randint(1, 6)
# test_dice.py
import random
import dice
def test_roll_six(monkeypatch):
monkeypatch.setattr(random, "randint", lambda a, b: 6)
assert dice.roll() == 6- A`lambda`-র দুটি প্যারামিটার থাকা উচিত নয়
- Bপ্যাচ করা হয়েছে `random`-এ, কিন্তু `roll()` নামটি খোঁজে `dice`-এ — `dice.randint` প্যাচ করতে হবে
- C`monkeypatch` বিল্ট-ইন মডিউল প্যাচ করতে পারে না
- D`setattr`-এর বদলে `setitem` লাগবে
...-এর জায়গায় কোন লাইনটি সবচেয়ে ভালো, যাতে পরের টেস্টগুলো আবার 0.15 দেখে?
# shop.py
SETTINGS = {"currency": "BDT", "tax_rate": 0.15}
# test_shop.py
import shop
def test_tax_free(monkeypatch):
... # set tax_rate to 0.0 for this test only- Ashop.SETTINGS["tax_rate"] = 0.0
- Bmonkeypatch.setattr(shop, "SETTINGS", {"tax_rate": 0.0})
- Cmonkeypatch.setenv("tax_rate", "0.0")
- Dmonkeypatch.setitem(shop.SETTINGS, "tax_rate", 0.0)
উত্তর দিতে অ্যাকাউন্ট লাগবে
উত্তর মিলিয়ে দেখতে সাইন ইন করুন
প্রশ্নগুলো উপরে আছে, আর মাথায় মাথায় উত্তর ভেবে নেওয়াই আসল কাজ। সঠিক উত্তর, ব্যাখ্যা আর তিন ধাপের ইঙ্গিত দেখতে সাইন ইন করুন।
নিজে করুন
greeter.py লিখুন, যাতে থাকবে একটি ফাংশন greeting(name) যা ফেরত দেয়:
- ১২:০০-র আগে
"Good morning, <name>", ১২:০০ থেকে ১৮:০০ পর্যন্ত"Good afternoon, <name>", আর তারপর"Good evening, <name>" - এনভায়রনমেন্ট ভেরিয়েবল
GREETER_LOUDযদি"1"হয়, তবে বড় হাতের অক্ষরে
আর মডিউল-স্তরের একটি ডিকশনারি TEMPLATES = {"morning": "Good morning", ...}, যেখান থেকে ফাংশনটি তার শব্দগুলো পড়ে।
তারপর test_greeter.py লিখুন, যা:
- জোরে আর আস্তে — দুই কেস টেস্ট করতে
setenvআরdelenv(..., raising=False)ব্যবহার করে, আর আপনার শেলেGREETER_LOUDসেট থাকুক বা না থাকুক, পাস করে - একটি টেস্টের জন্য
setitemদিয়ে সকালের শব্দটি"Shubho shokal"করে, আর দ্বিতীয় একটি টেস্ট প্রমাণ করে যেTEMPLATESআবার স্বাভাবিক - সময়ের সীমানা টেস্ট করে — ১১:৫৯, ১২:০০, ১৭:৫৯, ১৮:০০
ধাপ ৩ দুবার লিখুন। প্রথমবার greeter.py-তে from datetime import datetime রেখে monkeypatch.setattr(datetime, "now", ...) চেষ্টা করুন — ঠিক কোন ত্রুটি আসে, লিখে রাখুন। (গোটা greeter.datetime নামটিকে নিজের বানানো ছোট একটি নকল ক্লাস দিয়ে বদলে দিলে কাজ হয়; দেখুন তাতে কতটা কোড লাগে।) তারপর greeting-কে now=datetime.now গ্রহণ করতে দিন, আর একটি ঘড়ি পাঠিয়ে টেস্ট করুন।
দুটি সংস্করণের কোনটি রাখতে চাইবেন? অভিজ্ঞ টেস্টাররা বেশিরভাগই দ্বিতীয়টি বলেন, আর দুটোই চেষ্টা করার পর আপনি কারণটা জানবেন নিজের হাতে, এই পাতা থেকে নয়।
সমাধান
"টেস্ট লেখার আগে"-র মতো পরিকল্পনা দিয়ে শুরু করুন। greeting-এর লুকানো ইনপুট হলো ঘড়ি, GREETER_LOUD ভেরিয়েবল আর TEMPLATES ডিকশনারি। কেসগুলো: চারটি সীমানার মিনিট, আস্তে আর জোরে, বদলানো একটি টেমপ্লেট, আর টেমপ্লেটটি ফিরে এসেছে কি না তার যাচাই।
greeter.py:
import os
from datetime import datetime
TEMPLATES = {
"morning": "Good morning",
"afternoon": "Good afternoon",
"evening": "Good evening",
}
def greeting(name, now=datetime.now):
hour = now().hour
if hour < 12:
part = "morning"
elif hour < 18:
part = "afternoon"
else:
part = "evening"
text = f"{TEMPLATES[part]}, {name}"
if os.environ.get("GREETER_LOUD") == "1":
text = text.upper()
return texttest_greeter.py:
from datetime import datetime
import pytest
import greeter
def at(hour, minute=0):
# A fake clock: a function that always returns the same moment.
return lambda: datetime(2026, 1, 5, hour, minute)
@pytest.fixture
def quiet(monkeypatch):
monkeypatch.delenv("GREETER_LOUD", raising=False)
@pytest.mark.parametrize(
("hour", "minute", "expected"),
[
(11, 59, "Good morning, Rina"),
(12, 0, "Good afternoon, Rina"),
(17, 59, "Good afternoon, Rina"),
(18, 0, "Good evening, Rina"),
],
)
def test_time_boundaries(quiet, hour, minute, expected):
assert greeter.greeting("Rina", now=at(hour, minute)) == expected
def test_quiet_by_default(quiet):
assert greeter.greeting("Arif", now=at(9)) == "Good morning, Arif"
def test_loud(monkeypatch):
monkeypatch.setenv("GREETER_LOUD", "1")
assert greeter.greeting("Arif", now=at(9)) == "GOOD MORNING, ARIF"
def test_custom_morning_word(quiet, monkeypatch):
monkeypatch.setitem(greeter.TEMPLATES, "morning", "Shubho shokal")
assert greeter.greeting("Maya", now=at(8)) == "Shubho shokal, Maya"
def test_templates_are_back():
assert greeter.TEMPLATES["morning"] == "Good morning"pytest -v:
collected 8 items
test_greeter.py::test_time_boundaries[11-59-Good morning, Rina] PASSED [ 12%]
test_greeter.py::test_time_boundaries[12-0-Good afternoon, Rina] PASSED [ 25%]
test_greeter.py::test_time_boundaries[17-59-Good afternoon, Rina] PASSED [ 37%]
test_greeter.py::test_time_boundaries[18-0-Good evening, Rina] PASSED [ 50%]
test_greeter.py::test_quiet_by_default PASSED [ 62%]
test_greeter.py::test_loud PASSED [ 75%]
test_greeter.py::test_custom_morning_word PASSED [ 87%]
test_greeter.py::test_templates_are_back PASSED [100%]
============================== 8 passed in 0.01s ===============================আর শেলে ভেরিয়েবলটি এক্সপোর্ট করে, GREETER_LOUD=1 pytest -q:
........ [100%]
8 passed in 0.01sএর পেছনের সিদ্ধান্তগুলো:
- ঘড়ি একটি আর্গুমেন্ট, প্যাচ নয়। প্রথম চেষ্টা,
monkeypatch.setattr(datetime, "now", ...), থেমে যায়TypeError: cannot set 'now' attribute of immutable type 'datetime.datetime'দিয়ে। নকলdatetimeক্লাস বানানোর বদলেgreetingগ্রহণ করেnow=datetime.now। আসল কলকারীরা লেখেgreeting("Rina")আর কিছুই টের পায় না; টেস্ট পাঠায় একটি নির্দিষ্ট মুহূর্ত। at(hour, minute)ঘড়িটি বানায়। ছোট্ট একটি সহায়ক প্রতিটি টেস্টকে এক লাইনের পাঠযোগ্য রাখে। সে একটি ফাংশন ফেরত দেয়, কারণgreetingডাকেnow()— সরাসরি একটিdatetimeবস্তু পাঠালে ব্যর্থ হতোTypeError: 'datetime.datetime' object is not callableদিয়ে।- সীমানা জোড়ায় জোড়ায়। ১১:৫৯ আর ১২:০০, ১৭:৫৯ আর ১৮:০০: প্রতিটি কিনারার দুপাশে এক মিনিট। কেউ ভুল করে
hour <= 12লিখলে ১২:০০-র কেস তা ধরে ফেলে। quietএকটি fixture, অনুমান নয়। যে টেস্টের প্রত্যাশিত লেখা ছোট-বড় মেশানো, সে প্রত্যেকে এটি চায়, কারণ সেGREETER_LOUDনা থাকার ওপর নির্ভর করে। এ কারণেই ভেরিয়েবল এক্সপোর্ট করা দ্বিতীয় রানেও আটটিই পাস।test_loud-এর এটি লাগে না — সে নিজেই ভেরিয়েবলটি সেট করে।- আগে
setitem, তারপর প্রমাণ।test_custom_morning_wordএকটি কী বদলায়;test_templates_are_backকোনো fixture-ই চায় না আর নিশ্চিত করে যে কী-টি ফিরে এসেছে — ফিরিয়ে নেওয়াটা চোখে দেখা যায়। - যা টেস্ট করা হয়নি:
str.upper()কাজ করে কি না, বাdatetime.now()এখনকার সময় দেয় কি না। দুটোই পাইথনের দায়িত্ব।
ধাপ ৬ / ৬
কঠিন করা — অধ্যায়ের কুইজ
সহজ থেকে কঠিন — দশটি প্রশ্ন, শেষেরগুলো ইচ্ছে করেই কঠিন।
সাইন ইন করে কুইজ দিন