অধ্যায় 10

monkeypatch — এনভায়রনমেন্ট, সময় আর নেটওয়ার্ক নিয়ন্ত্রণে

এনভায়রনমেন্ট ভেরিয়েবল, ঘড়ি, র‍্যান্ডম সংখ্যা বা নেটওয়ার্কের ওপর নির্ভর করা কোড টেস্ট করা। setenv, delenv, setattr, setitem, chdir, প্রতিটি টেস্টের পর স্বয়ংক্রিয় undo, "যেখানে খোঁজা হয় সেখানে প্যাচ করুন" নিয়ম, আর প্যাচ ছাড়াই dependency injection।

50 মিনিটPython 3.12
  1. 1সমস্যা
  2. 2বোঝা
  3. 3উদাহরণ
  4. 4অনুমান
  5. 5নিজে করা
  6. 6কঠিন করা

যে সমস্যাটা আমরা সমাধান করছি

এই ফাংশনটি ঠিক করে একটি অ্যাপ্লিকেশন ডিবাগ মোডে চলবে কি না। সে একটি এনভায়রনমেন্ট ভেরিয়েবল পড়ে:

settings.py:

python
import os


def debug_enabled():
    return os.environ.get("APP_DEBUG", "0") == "1"

test_settings.py:

python
from settings import debug_enabled


def test_debug_is_off_by_default():
    assert debug_enabled() is False

নিজের টার্মিনালে pytest -q চালান — টেস্ট পাস করবে। এবার এমন একটি মেশিনে চালান যেখানে কেউ APP_DEBUG=1 এক্সপোর্ট করে রেখেছে — কোনো সহকর্মীর ল্যাপটপ, বা ডিবাগিংয়ের জন্য সাজানো একটি CI সার্ভার — আর একই কোডের ওপর একই টেস্ট ফেল করবে:

text
$ 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 করবেন না। এই টেস্টটি পাস করে, কিন্তু কিছুই প্রমাণ করে না:

python
import weather


def test_pointless(monkeypatch):
    monkeypatch.setattr(weather, "fetch_temperature", lambda city: 35)
    assert weather.fetch_temperature("Dhaka") == 35
text
.                                                                        [100%]
1 passed in 0.01s

এটি কেবল দেখে প্যাচটি জায়গামতো বসেছে কি না, আপনার কোনো কোড কাজ করে কি না তা নয়। নকলটির অস্তিত্ব এই জন্য যাতে তার ওপর দাঁড়িয়ে অন্য কোড — নিচের advice — টেস্ট করা যায়। assert-এ যদি কেবল আপনার বদলে দেওয়া জিনিসটিরই নাম থাকে, টেস্টটি মুছে দিন।

পরের অংশগুলো একেকটি টুল ধরে এই পরিকল্পনাটি বাস্তবায়ন করে।


setenv আর delenv — প্রতি টেস্টে আলাদা এনভায়রনমেন্ট

monkeypatch হলো tmp_path-এর মতোই একটি fixture: টেস্টের প্যারামিটারে নাম লিখে চেয়ে নিন। একটু বড় একটি settings.py-এর জন্য চারটি টেস্ট:

python
import os


def debug_enabled():
    return os.environ.get("APP_DEBUG", "0") == "1"


def api_port():
    return int(os.environ.get("APP_PORT", "8000"))
python
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
text
$ 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 তার বদলানো সবকিছু আগের অবস্থায় ফেরায়। এই হলো প্রমাণ:

python
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 দেখা যায়):

text
inside first : 1
.inside second: None
.
2 passed in 0.01s

দ্বিতীয় টেস্ট monkeypatch চায়নি, তবু কিছুই দেখে না — ভেরিয়েবলটি নেই। এবার তুলনা করুন, সরাসরি os.environ-এ লিখলে কী হয়:

python
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"))
text
inside first : 1
.inside second: 1
.
2 passed in 0.01s

মানটি পরের টেস্টে চুইয়ে পড়েছে, আর একই রানের পরের প্রতিটি টেস্টেও পড়বে। এই ধরনের বাগের কারণেই একটি টেস্ট একা চালালে পাস করে আর বাকিদের সাথে চালালে ফেল করে — বা আরও খারাপ, উল্টোটা।

ফিরিয়ে নেওয়ার মধ্যে কোনো জাদু নেই। প্রতিটি পরিবর্তনের আগে monkeypatch পুরোনো মানটি টুকে রাখে — অথবা টুকে রাখে যে কোনো মান ছিল না — আর teardown-এর সময় টুকে রাখা প্রতিটি মান উল্টো ক্রমে ফিরিয়ে দেয়। টেস্ট পাস করুক, ফেল করুক বা এক্সেপশন তুলুক, teardown চলবেই, তাই মাঝপথে একটি ব্যর্থ assert এনভায়রনমেন্টকে নোংরা রেখে যেতে পারে না। হাতে লেখা os.environ[...] = ... আর টেস্টের শেষে হাতে লেখা পরিষ্কার করার লাইন — সেই লাইনটিকে ঠিক ওই ব্যর্থ assert-ই টপকে যেত।

setattr — একটি টেস্টের জন্য একটি ফাংশন বদলানো

এনভায়রনমেন্ট ভেরিয়েবল সহজ কেস। কঠিনটি হলো যে কোড নেটওয়ার্কের সাথে কথা বলে:

weather.py:

python
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-এ: সীমারেখাগুলো, বার্তাগুলো। এটিকে যেমন আছে তেমন টেস্ট করলে টেস্টটি নির্ভর করবে একটি সার্ভার, একটি নেটওয়ার্ক সংযোগ আর আজকের আবহাওয়ার ওপর। নেটওয়ার্কহীন একটি মেশিনে:

text
$ 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.01s

monkeypatch.setattr একটি টেস্টের সময়টুকুর জন্য fetch_temperature-কে আপনার নিয়ন্ত্রণের কিছু একটা দিয়ে বদলে দেয়:

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

লক্ষ্য নির্দেশ করার দুটি উপায় আছে, আর দুটিই একই কাজ করে:

  • setattr(weather, "fetch_temperature", new) — প্রথমে বস্তুটি, তারপর অ্যাট্রিবিউটের নাম স্ট্রিং হিসেবে।
  • setattr("weather.fetch_temperature", new) — একটিই ডট-দেওয়া স্ট্রিং; pytest weather ইমপোর্ট করে আর শেষ অংশটি আলাদা করে নেয়।

টেস্ট যখন আগেই মডিউলটি ইমপোর্ট করে, বস্তু-রূপটি ব্যবহার করুন; যখন ইমপোর্টের দরকার নেই, স্ট্রিং-রূপটি। যেভাবেই হোক, খেয়াল করুন এখন কী টেস্ট হচ্ছে: fetch_temperature নয়, যেটি আমরা বদলে দিয়েছি, বরং advice-এর সীমারেখা আর বার্তা — যে কোড আমরা লিখেছি। কোনো নেটওয়ার্ক নেই, অপেক্ষা নেই, প্রতিবার একই উত্তর — আর চাইলেই 35 বা 4 ডিগ্রি তাপমাত্রা, যা আসল আবহাওয়া কখনো চাহিদামতো দেবে না।

টেস্টে সরাসরি weather.fetch_temperature = lambda city: 35 না লিখে setattr কেন? os.environ-এর মতো একই কারণে: সাধারণ অ্যাসাইনমেন্ট কখনো ফিরিয়ে নেওয়া হয় না, আর রানের পরের প্রতিটি টেস্ট নকলটিই পাবে।

নিয়ম: যেখানে খোঁজা হয় সেখানে প্যাচ করুন

এবার সেই ভুল, যা সবাই একবার করে। একটি ছোট মডিউল যা ছক্কার গুটি গড়ায়:

dice.py:

python
from random import randint


def roll():
    return randint(1, 6)


def is_jackpot():
    return roll() == 6

জ্যাকপট টেস্ট করতে randint-কে 6 ফেরত দিতে বাধ্য করুন। সে থাকে random-এ, তাই সেখানেই প্যাচ করি:

python
import random

import dice


def test_jackpot(monkeypatch):
    monkeypatch.setattr(random, "randint", lambda a, b: 6)
    assert dice.is_jackpot() is True
text
F                                                                        [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 তার কেবল একটিকে সরায়:

python
import random

import dice

print(dice.randint is random.randint)
random.randint = lambda a, b: 6
print(dice.randint is random.randint)
text
True
False

roll() চলার সময় পাইথন randint খোঁজে dice মডিউলে — নামটি খোঁজা হয় সেখানেই। তাই বদলাতে হবে সেখানেই:

python
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
text
..                                                                       [100%]
2 passed in 0.01s

পরপর পাঁচবার চালানো, পাঁচবারই পাস। অন্য সমাধানটি হলো dice.py কীভাবে ইমপোর্ট করে তা বদলানো। import random লিখে random.randint(1, 6) ডাকলে প্রতিটি কলে খোঁজাটা হয় random মডিউলে, আর তখন random.randint প্যাচ করলেই কাজ হয়:

python
import random


def roll():
    return random.randint(1, 6)


def is_jackpot():
    return roll() == 6
text
$ pytest -q        # the original test, patching random.randint
.                                                                        [100%]
1 passed in 0.01s

এক বাক্যে নিয়মটি: যে লাইন ফাংশনটি ডাকে সেটি খুঁজুন, দেখুন সে নামটি কোন মডিউল থেকে পায়, আর নামটি সেখানেই প্যাচ করুন।

setitem — ডিকশনারির একটি কী

সেটিংস প্রায়ই মডিউল-স্তরের একটি ডিকশনারিতে রাখা হয়। গোটা ডিকশনারি বদলানো অস্বস্তিকর; setitem একটি কী বদলায় আর পরে আগের অবস্থায় ফেরায়:

shop.py:

python
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']}"
python
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:

text
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:

python
from pathlib import Path


def read_notes():
    return Path("notes.txt").read_text().splitlines()
python
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
text
..                                                                       [100%]
2 passed in 0.01s

chdir একটি টেস্টের জন্য কাজের ডিরেক্টরি সরিয়ে নেয়, আর পরে ফিরিয়ে আনে। দ্বিতীয় টেস্টটি syspath_prepend-এর এক লাইনের ব্যবহার দেখায়: সে একটি ফোল্ডারকে sys.path-এর সামনে বসায়, যাতে সেখানে লেখা মডিউল ইমপোর্ট করা যায়, আর শেষে আবার সরিয়ে দেয় — কোনো ফোল্ডার থেকে plugin লোড করে এমন কোড টেস্ট করার সময় কাজে লাগে।

আরও ভালো: নির্ভরতাটি বাইরে থেকে পাঠান

প্যাচ কাজ করে, কিন্তু প্রতিটি প্যাচ একটি ছোট্ট স্বীকারোক্তি যে কোডটি হাত বাড়িয়ে জগৎ থেকে কিছু তুলে নিয়েছে। কিছু জিনিস তো স্বচ্ছন্দে প্যাচই করা যায় না। datetime.now তার একটি:

text
E       TypeError: cannot set 'now' attribute of immutable type 'datetime.datetime'

বিকল্পটি হলো একটি ডিজাইনের সিদ্ধান্ত: ফাংশন যার ওপর নির্ভর করে, সেটি তাকে গ্রহণ করতে দিন, আসলটিকে ডিফল্ট রেখে:

shop_hours.py:

python
from datetime import datetime


def is_open(now=datetime.now):
    hour = now().hour
    return 9 <= hour < 21

প্রোডাকশন কোড is_open() ডাকে আর আসল ঘড়ি পায়। টেস্ট নিজের ঘড়ি পাঠায়:

python
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
text
...                                                                      [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:

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

python
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"] == 1234

pytest -v:

text
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() ব্যবহার করে ফিরিয়ে নেওয়ার দায়িত্ব নিজে নিন।