Mock — বাইরের সার্ভিসকে কীভাবে ডাকা হলো তা যাচাই করা
পেমেন্ট বা ইমেল ক্লায়েন্টের জায়গায় Mock বসিয়ে দেখা তাকে কীভাবে ডাকা হলো। return_value, side_effect, autospec, patch, mocker — আর কখন mock নয়, fake।
- 1সমস্যা
- 2বোঝা
- 3উদাহরণ
- 4অনুমান
- 5নিজে করা
- 6কঠিন করা
যে সমস্যাটা আমরা সমাধান করছি
এখানে একটি ছোট চেকআউট। এটি কার্টের মোট দাম বের করে, একটি পেমেন্ট ক্লায়েন্টের মাধ্যমে কার্ড থেকে টাকা কাটে, আর গ্রাহককে ইমেল পাঠায়।
payments.py:
class PaymentDeclined(Exception):
pass
class PaymentGateway:
"""Talks to the real payment provider over HTTPS."""
def __init__(self, api_key):
self.api_key = api_key
def charge(self, amount_cents, token):
raise RuntimeError("real network call — never in a test")
def refund(self, charge_id):
raise RuntimeError("real network call — never in a test")checkout.py:
from payments import PaymentDeclined
def cart_total(cart):
return sum(item["price_cents"] * item["qty"] for item in cart)
def place_order(cart, email, token, gateway, mailer):
total = cart_total(cart)
if total == 0:
raise ValueError("cart is empty")
try:
receipt = gateway.charge(total, token=token)
except PaymentDeclined:
mailer.send(email, "Your payment was declined")
return {"status": "declined"}
mailer.send(email, f"Order confirmed: {receipt['id']}")
return {"status": "paid", "charge_id": receipt["id"], "total_cents": total}বাস্তবে charge একটি আসল কার্ড থেকে আসল টাকা কাটে, তাই কোনো টেস্ট যেন কখনো সেখানে না পৌঁছায়। গত অধ্যায়ে monkeypatch দিয়ে কোনো জিনিস বদলে দেওয়া শিখেছেন — কিন্তু এখানে বদলে দেওয়া কাজের কেবল অর্ধেক। place_order-এর ফেরত মান বলছে "paid", অথচ যে বাগগুলো আসলে গুরুত্বপূর্ণ সেগুলো লুকিয়ে আছে কলগুলোতে: কার্ড থেকে 2500 কাটা হলো, নাকি 25? কোন টোকেন দিয়ে? একবার, নাকি দুবার? ইমেল কি ঠিক ঠিকানায় গেল? এসবের উত্তর পেতে হলে বদলি জিনিসটিকে মনে রাখতে হবে তাকে কীভাবে ডাকা হয়েছিল।
এর জন্য প্রতিটি নির্ভরতার জন্য আলাদা করে একটি ছোট রেকর্ড-রাখা ক্লাস লিখতে পারতেন। unittest.mock সেটি আগেই আপনার জন্য লিখে রেখেছে।
এই অধ্যায় শেষে আপনি পারবেন
- একটি ক্লায়েন্টের জায়গায়
Mockবসাতে, আরassert_called_once_with,call_args,call_countওmock_callsদিয়ে দেখতে তাকে কীভাবে ডাকা হয়েছিল return_valueআরside_effectদিয়ে একটি mock থেকে মান ফেরত দেওয়াতে বা exception ছোড়াতে- বোঝাতে কেন খালি
Mockবিপজ্জনক, আরspec=ওcreate_autospecদিয়ে সেই ফাঁক বন্ধ করতে patchদিয়ে একটি নাম বদলাতে — decorator হিসেবে আর context manager হিসেবে, ঠিক জায়গায়- pytest-mock-এর
mocker.patchআরmocker.spyব্যবহার করতে - stub, mock আর fake-এর পার্থক্য বলতে, আর কখন fake দিয়ে লেখা টেস্ট বেশি ভালো তা বুঝতে
আগে যা জানা লাগবে: monkeypatch — এক টেস্টের জন্য জিনিস বদলে দেওয়া।
টেস্ট লেখার আগে
কোনো Mock লেখার আগে লিখে ফেলুন place_order আসলে কী প্রতিশ্রুতি দেয়। এর একটি ইনপুট আপনার হাতে (কার্ট, ইমেল আর টোকেন), আর দুটি পার্শ্বপ্রতিক্রিয়া (side effect) যা ফেরত মানে দেখা যায় না — একটি চার্জ আর একটি ইমেল। এমন কোডের টেস্টকে দুটো অর্ধেকই যাচাই করতে হয়।
চুক্তি। কার্ট খালি না হলে place_order ঠিক মোট দামটি, একবার, দেওয়া টোকেন দিয়ে চার্জ করে; একটি নিশ্চিতকরণ ইমেল পাঠায়; আর "paid" ফেরত দেয়। কার্ড প্রত্যাখ্যাত হলে কিছুই চার্জ করে না, দুঃখপ্রকাশের ইমেল পাঠায় আর "declined" ফেরত দেয়। খালি কার্ট হলে কিছু বাইরে যাওয়ার আগেই ValueError ওঠে। নেটওয়ার্কের ব্যর্থতা চেপে রাখা হয় না।
প্রস্তুতি। আপনার ভার্চুয়াল এনভায়রনমেন্টে pytest, যেখান থেকে pytest চালাবেন সেখান থেকে payments.py আর checkout.py ইমপোর্ট করা যায় এমন অবস্থা, আর mocker fixture-এর জন্য pytest-mock প্লাগইন (pip install pytest-mock)। unittest.mock পাইথনের সাথেই আসে। কোনো API key নয়, কোনো নেটওয়ার্ক নয় — কোনো টেস্টের এর কোনোটি লাগলে সেটি আর unit test নয়।
পরিকল্পনা।
| ক্ষেত্র | ইনপুট | প্রত্যাশিত | |---|---|---| | স্বাভাবিক পথ | 2 × 1000 + 1 × 500, কার্ড গৃহীত | tok_visa দিয়ে 2500-এর একটি চার্জ; নিশ্চিতকরণ ইমেল; "paid" | | কার্ড প্রত্যাখ্যাত | একই কার্ট, gateway PaymentDeclined ছোড়ে | কোনো চার্জ লেখা হয়নি; দুঃখপ্রকাশের ইমেল; "declined" | | খালি কার্ট | [] | ValueError("cart is empty"); gateway আর mailer অছোঁয়া | | নেটওয়ার্ক ব্যর্থতা | gateway TimeoutError ছোড়ে | TimeoutError বাইরে আসে; কোনো ইমেল নয় | | চুক্তি | যেকোনো অর্ডার | charge আসল signature (amount_cents, token) মেনে ডাকা হয় |
কী টেস্ট করবেন না। পেমেন্ট প্রোভাইডার নিজেকে — সেটি তার লেখকদের কাজ, আর আপনার টেস্ট সেখানে পৌঁছাতেও পারে না। পাইথনের sum। আর place_order-এর ভেতরের ধাপগুলো: সে cart_total একবার ডাকে নাকি হিসাবটা নিজেই করে, সেটা তার নিজের ব্যাপার। পরিকল্পনায় আছে কেবল ফলাফল আর সীমানা পেরোনো একটিমাত্র কল — আর কিছু নয়। কথাটা মনে রাখুন; উপকারী mock আর ক্ষতিকর mock-এর পার্থক্য এখানেই, আর অধ্যায়ের শেষে আমরা এতে ফিরে আসব।
Mock — প্রতিটি কল লিখে রাখা একটি বস্তু
from unittest.mock import Mock
gateway = Mock()
result = gateway.charge(2500, token="tok_visa")
gateway.refund("ch_1")
print(result)
print(gateway.charge.called, gateway.charge.call_count)
print(gateway.charge.call_args)
print(gateway.mock_calls)<Mock name='mock.charge()' id='132252166523936'>
True 1
call(2500, token='tok_visa')
[call.charge(2500, token='tok_visa'), call.refund('ch_1')]gateway-এর কোনো charge মেথড নেই; কিছুই সংজ্ঞায়িত করা হয়নি। আপনি যে attribute ছোঁবেন, Mock সেটি তৈরি করে নেয়, আর প্রতিটি attribute নিজেও একটি Mock যাকে ডাকা যায়। প্রতিটি কল লেখা থাকে: called বলে কল হয়েছে কি না, call_count কতবার, call_args ঠিক কী দিয়ে। মূল বস্তুর mock_calls-এ থাকে প্রতিটি মেথডের প্রতিটি কল, ক্রমানুসারে — ক্রম গুরুত্বপূর্ণ হলে কাজে লাগে।
এই attribute-গুলো একটি সাধারণ assert-এ পড়লেও চলে, কিন্তু assertion মেথডগুলো একই কথা এক লাইনে বলে, আর ব্যর্থ হলে নিজেই ব্যাখ্যা দেয়:
from unittest.mock import Mock
gateway = Mock()
gateway.charge(2500, token="tok_visa")
gateway.charge.assert_called_once_with(2500, token="tok_visa")
print("first check passed")
gateway.charge.assert_called_once_with(2500, token="tok_amex")first check passed
Traceback (most recent call last):
...
AssertionError: expected call not found.
Expected: charge(2500, token='tok_amex')
Actual: charge(2500, token='tok_visa')assert_called_once_with দুটি জিনিস যাচাই করে: ঠিক একটি কল হয়েছে, আর সেটি ঠিক এই আর্গুমেন্টগুলো দিয়ে। এর আত্মীয়রা হলো assert_called_once() (একবার, যেকোনো আর্গুমেন্ট), assert_called_with(...) (কেবল শেষ কলটি) আর assert_not_called()। (এই অধ্যায়ে যেখানে কেবল শেষ লাইনটি জরুরি, সেখানে traceback ছেঁটে ... করা হয়েছে।)
"একবার" কেন? কারণ একটি কার্ড থেকে দুবার টাকা কাটা সত্যিকারের বাগ, আর শুধু assert_called_with দ্বিতীয় কলটি টের পেত না।
return_value আর side_effect — mock কী ফেরত দেবে
ডিফল্টভাবে একটি কল আরেকটি Mock ফেরত দেয়। যে কোড receipt["id"] করে, তার দরকার একটি আসল উত্তর, তাই সেটি দিয়ে দিন:
from unittest.mock import Mock
gateway = Mock()
gateway.charge.return_value = {"id": "ch_1", "status": "succeeded"}
print(gateway.charge(2500, token="tok_visa"))
print(gateway.charge(999, token="tok_other")){'id': 'ch_1', 'status': 'succeeded'}
{'id': 'ch_1', 'status': 'succeeded'}আর্গুমেন্ট যা-ই হোক, উত্তর একই। এতে না চললে side_effect সেট করুন। এর তিনটি রূপ।
একটি exception — gateway.charge.side_effect = PaymentDeclined("card declined") দিলে প্রতিটি কলেই সেটি ছোড়া হয়। বাস্তবে যে অপ্রীতিকর পথগুলো ঘটানো কঠিন, সেগুলোতে এভাবেই পৌঁছানো যায়: প্রত্যাখ্যাত কার্ড, টাইমআউট, সার্ভার থেকে 500।
একটি তালিকা — প্রতি কলে একটি করে আইটেম, ক্রমানুসারে। তালিকার কোনো আইটেম exception হলে সেটি ছোড়া হয়; বাকি সব ফেরত দেওয়া হয়। "একবার ব্যর্থ, তারপর সফল" ধরনের অবস্থার জন্য ভালো:
from unittest.mock import Mock
gateway = Mock()
gateway.charge.side_effect = [
TimeoutError("gateway timed out"),
{"id": "ch_2", "status": "succeeded"},
]
try:
gateway.charge(2500, token="tok_visa")
except TimeoutError as exc:
print("first call:", exc)
print("second call:", gateway.charge(2500, token="tok_visa"))
gateway.charge(2500, token="tok_visa")first call: gateway timed out
second call: {'id': 'ch_2', 'status': 'succeeded'}
Traceback (most recent call last):
...
StopIterationতৃতীয় কলটি তালিকা খালি পেয়েছে। mock থেকে StopIteration মানে কোডটি আপনার পরিকল্পনার চেয়ে বেশিবার তাকে ডেকেছে — যা জানাটাও মূল্যবান।
একটি ফাংশন — mock যে আর্গুমেন্ট পায়, সেগুলো দিয়েই এটি ডাকা হয়, আর এটি যা ফেরত দেয় সেটাই উত্তর। gateway.charge.side_effect = lambda amount_cents, token: {"status": "declined"} if amount_cents > 100_000 else {"id": "ch_1"} ইনপুটের ওপর নির্ভর করে উত্তর দেয় — আর কলগুলো তবুও লেখা ও গোনা হয়।
MagicMock — কোড যখন len, with বা for ব্যবহার করে
সাধারণ Mock পাইথনের বিশেষ মেথডগুলো সমর্থন করে না: len(Mock()) থেকে TypeError: object of type 'Mock' has no len() আসে, আর with Mock():-ও ব্যর্থ হয়। MagicMock এগুলো যুক্তিসঙ্গত ডিফল্টসহ সমর্থন করে — len(MagicMock()) হলো 0, এটি with ব্লকে চলে, আর m.__len__.return_value = 3 দিলে উত্তর বদলে যায়।
বদলি জিনিসটি যখন context manager, container বা iterator হিসেবে ব্যবহৃত হয় — ধরুন with ব্লকের ভেতরে একটি ডেটাবেস কানেকশন — তখন MagicMock নিন। নিচের patch আর mocker.patch ডিফল্টভাবে MagicMock-ই তৈরি করে।
বিপদ: Mock সবকিছুতে হ্যাঁ বলে
এই বাধ্য স্বভাবের একটি দাম আছে। একটি মেথডের নাম ভুল বানানে লিখুন, কেউ আপত্তি করবে না:
from unittest.mock import Mock
gateway = Mock()
gateway.chrage(2500, token="tok_visa") # typo in the method name
print("no error — the typo was recorded as a new method")
print(gateway.mock_calls)no error — the typo was recorded as a new method
[call.chrage(2500, token='tok_visa')]এই বানান-ভুলটি checkout.py-তে থাকলে খালি Mock দিয়ে লেখা টেস্ট পাস করে যেতে পারত, আর প্রথম আসল গ্রাহক পেতেন AttributeError। ভুল আর্গুমেন্টও একইভাবে ফসকে যায়। এখানে ভুল পথ আর ঠিক পথ পাশাপাশি — যে কোড টেস্ট হচ্ছে সে টোকেনটি দিতে ভুলে গেছে:
from unittest.mock import Mock, create_autospec
from payments import PaymentGateway
def charge_order(gateway, total, token):
return gateway.charge(total) # bug: the token is never passed
def test_with_a_plain_mock():
gateway = Mock()
charge_order(gateway, 2500, "tok_visa")
gateway.charge.assert_called_once()
def test_with_an_autospec():
gateway = create_autospec(PaymentGateway, instance=True)
charge_order(gateway, 2500, "tok_visa")
gateway.charge.assert_called_once()$ pytest -q --tb=line test_typo.py
.F [100%]
=================================== FAILURES ===================================
E TypeError: missing a required argument: 'token'
/usr/lib/python3.12/inspect.py:3157: TypeError: missing a required argument: 'token'
=========================== short test summary info ============================
FAILED test_typo.py::test_with_an_autospec - TypeError: missing a required ar...
1 failed, 1 passed in 0.07sসাধারণ Mock একটি ভাঙা কলকে যেতে দিয়েছে — একটি সবুজ টেস্ট যা কিছুই রক্ষা করছে না। create_autospec আসল PaymentGateway থেকে একটি mock বানায়: তাতে কেবল ওই ক্লাসের attribute-গুলোই থাকে, আর প্রতিটি মেথড তার আর্গুমেন্ট আসল signature-এর সাথে মিলিয়ে দেখে। instance=True মানে "ক্লাসটির একটি instance", ক্লাসটি নিজে নয়।
কড়াকড়ির তিনটি স্তর:
Mock()— যেকোনো attribute, যেকোনো আর্গুমেন্ট।Mock(spec=PaymentGateway)— কেবল আসল attribute-এর নাম (gateway.chrageথেকে আসেAttributeError: Mock object has no attribute 'chrage'. Did you mean: 'charge'?), কিন্তু আর্গুমেন্ট যাচাই হয় না।create_autospec(PaymentGateway, instance=True), অথবাpatch-এautospec=True— আসল নাম আর আসল signature।
বানান-ভুলের বাইরেও এটা কেন জরুরি? কারণ আসল ক্লায়েন্ট বদলায়। লাইব্রেরি আপগ্রেডে কোনো মেথডের নাম বদলালে বা নতুন বাধ্যতামূলক আর্গুমেন্ট যোগ হলে autospec mock সেদিনই ব্যর্থ হয়; খালি Mock এমন কোডের সাথে একমত হতেই থাকে যা আর কাজ করে না। আসল কোনো ক্লাসের বদলি হলে সবচেয়ে কড়া স্তরটিই ব্যবহার করুন।
এক ধরনের বানান-ভুল পাইথন নিজেই আটকায়: 3.12 থেকে assert_called_once_wiht-এর মতো ভুল বানানের assertion চুপচাপ পাস না করে AttributeError: 'assert_called_once_wiht' is not a valid assertion. ছোড়ে। কিন্তু এই পাহারা কেবল mock-এর নিজের assertion-এর নামগুলো চেনে। আপনার gateway-তে যে charge আছে, chrage নেই — সেটা সে জানে না; তা জানে কেবল একটি spec।
patch — কোড যে নাম নিজে খুঁজে নেয়, সেটি বদলানো
place_order তার gateway আর্গুমেন্ট হিসেবে পায়, তাই mock ঢুকিয়ে দেওয়া সহজ। কিন্তু প্রায়ই কোড তার নির্ভরতা নিজেই নিয়ে আসে:
# emailer.py
def send_email(to, subject):
raise RuntimeError("real SMTP call — never in a test")
# signup.py
from emailer import send_email
def register(email):
send_email(email, "Welcome aboard")
return {"email": email, "active": True}unittest.mock.patch একটি টেস্ট যতক্ষণ চলে ততক্ষণের জন্য একটি নামকে MagicMock দিয়ে বদলে দেয়, তারপর আসলটি ফিরিয়ে রাখে। এটি decorator হিসেবে চলে — mock আসে আর্গুমেন্ট হয়ে — অথবা context manager হিসেবে:
from unittest.mock import patch
from signup import register
@patch("signup.send_email")
def test_register_as_decorator(send_email):
user = register("asha@example.com")
assert user["active"] is True
send_email.assert_called_once_with("asha@example.com", "Welcome aboard")
def test_register_as_context_manager():
with patch("signup.send_email") as send_email:
register("ravi@example.com")
send_email.assert_called_once_with("ravi@example.com", "Welcome aboard")$ pytest -q test_signup.py
.. [100%]
2 passed in 0.08sলক্ষ্য হলো "signup.send_email", "emailer.send_email" নয়। এটাই গত অধ্যায়ের নিয়ম: নাম যেখানে খোঁজা হয় সেখানে patch করুন, যেখানে সংজ্ঞায়িত সেখানে নয়। from emailer import send_email নামটিকে signup-এ কপি করেছে, তাই register সেখানেই খোঁজে। ভুল পথ — মূল মডিউলটি patch করা — কপিটিকে অছোঁয়া রাখে, আর আসল ফাংশনটি চলে:
from unittest.mock import patch
from signup import register
def test_patched_in_the_wrong_place():
with patch("emailer.send_email") as send_email:
register("asha@example.com")
send_email.assert_called_once()$ pytest -q --tb=line test_signup_wrong.py
F [100%]
=================================== FAILURES ===================================
E RuntimeError: real SMTP call — never in a test
/home/you/shop/emailer.py:2: RuntimeError: real SMTP call — never in a test
=========================== short test summary info ============================
FAILED test_signup_wrong.py::test_patched_in_the_wrong_place - RuntimeError: ...
1 failed in 0.08sত্রুটিটি আসছে emailer.py থেকে — আসলটি থেকে। এখানে বদলিটি জোরে চেঁচিয়ে ওঠে; একটি আসল ইমেল ক্লায়েন্ট হলে চুপচাপ ইমেলটি পাঠিয়েই দিত।
patch-ও autospec=True নেয়, আগের কারণেই: patch("signup.send_email", autospec=True) এমন একটি mock বানায় যা কেবল (to, subject) গ্রহণ করে।
mocker — pytest-mock-এর fixture
pytest-mock প্লাগইন এসব কিছু mocker নামের একটি fixture-এ মুড়ে দেয়। mocker.patch ঠিক patch-এর মতো আর্গুমেন্ট নেয়, decorator বা with লাগে না, আর টেস্ট শেষ হলে নিজে থেকেই ফিরিয়ে নেওয়া হয় — ঠিক monkeypatch-এর মতো। mocker.spy এদের মধ্যে আলাদা: সে কিছুই বদলায় না। আসল মেথডটি চলে; spy কেবল নজর রাখে।
from checkout import place_order
from fakes import FakeGateway, FakeMailer
from signup import register
def test_register_with_mocker(mocker):
send_email = mocker.patch("signup.send_email", autospec=True)
register("asha@example.com")
send_email.assert_called_once_with("asha@example.com", "Welcome aboard")
def test_spy_watches_a_real_object(mocker):
gateway = FakeGateway()
spy = mocker.spy(gateway, "charge")
place_order([{"price_cents": 700, "qty": 1}], "a@example.com", "tok", gateway, FakeMailer())
spy.assert_called_once_with(700, token="tok")
assert spy.spy_return == {"id": "ch_1"}
assert gateway.charges == [("ch_1", 700, "tok")]$ pytest -q test_signup_mocker.py
.. [100%]
2 passed in 0.08sspy একটি mock-এর মতোই কলটি লিখে রেখেছে, আসল ফলটি রেখেছে spy_return-এ, আর আসল charge তবুও তার কাজ করেছে — gateway.charges ভরেছে। (FakeGateway পরের অংশে সংজ্ঞায়িত।) mocker-এ mocker.Mock, mocker.MagicMock আর mocker.create_autospec-ও আছে, ফলে একটি টেস্ট এক fixture থেকেই সব পেয়ে যায়।
Stub, mock, fake — আর কখন mock করবেন না
তিনটি শব্দ গুলিয়ে যায়, আর পার্থক্যটিই ঠিক করে আপনার টেস্ট কী ধরতে পারবে:
- stub কেবল উত্তর দেয়।
gateway.charge.return_value = {"id": "ch_1"}, আর তাকে কীভাবে ডাকা হলো তা আপনি কখনো যাচাই করেন না। সে টেস্টাধীন কোডকে খাওয়ায়। - mock হলো এমন stub, যাকে পরে আপনি জেরা করেন:
assert_called_once_with(...)। টেস্টটি কলটিকে নিয়েই। - fake হলো একটি আসল, কাজ-করা, ছোট বাস্তবায়ন — একটি in-memory gateway যা ব্যাংকের সাথে কথা না বলে একটি তালিকা রাখে।
mock দিয়ে গড়া টেস্ট যাচাই করে কোড তার কাজ কীভাবে করল। বাড়াবাড়ি করলে টেস্টটি আর যাচাই করে না সে কী করল। আগে ভুল পথ:
import checkout
from checkout import place_order
def test_place_order_over_mocked(mocker):
mocker.patch("checkout.cart_total", return_value=2500)
gateway = mocker.Mock()
gateway.charge.return_value = {"id": "ch_1"}
mailer = mocker.Mock()
place_order([{"price_cents": 1, "qty": 1}], "a@example.com", "tok", gateway, mailer)
checkout.cart_total.assert_called_once()
gateway.charge.assert_called_once_with(2500, token="tok")
mailer.send.assert_called_once()$ pytest -q test_overmock.py
. [100%]
1 passed in 0.07sপাস করছে। কার্টের দাম এক সেন্ট, অথচ টেস্ট 2500-এর চার্জ "নিশ্চিত" করছে — কারণ হিসাবটা টেস্ট নিজেই বদলে দিয়েছে। cart_total ভেঙে দিন, এই টেস্ট তবুও পাস করবে। আচরণ না বদলে শুধু cart_total-এর নাম বদলান, টেস্ট ব্যর্থ হবে। এটাই over-mocking-এর গন্ধ: এমন টেস্ট যা বাস্তবায়নকে লাইন ধরে ধরে নকল করে, নিজের mock-গুলোকেই টেস্ট করে, আর প্রতিটি refactor-এ ভেঙে যায়।
ঠিক পথটি অধ্যায়ের শুরুর পরিকল্পনাই মানে: কেবল আপনার সিস্টেমের সীমানায় mock করুন, আর কোথাও নয়। পেমেন্ট প্রোভাইডার আর SMTP সার্ভার হলো সীমানা। cart_total আপনার নিজের কোড — তাকে চলতে দিন। আর ছোট interface-ওয়ালা সহযোগীর ক্ষেত্রে সাধারণত fake mock-কে হারিয়ে দেয়:
from payments import PaymentDeclined
class FakeGateway:
"""An in-memory stand-in for PaymentGateway: same methods, no network."""
def __init__(self, declines=False):
self.declines = declines
self.charges = []
def charge(self, amount_cents, token):
if self.declines:
raise PaymentDeclined("card declined")
charge_id = f"ch_{len(self.charges) + 1}"
self.charges.append((charge_id, amount_cents, token))
return {"id": charge_id}
def refund(self, charge_id):
self.charges = [c for c in self.charges if c[0] != charge_id]
class FakeMailer:
def __init__(self):
self.sent = []
def send(self, to, subject):
self.sent.append((to, subject))fake দিয়ে লেখা টেস্ট অবস্থা (state) যাচাই করে — তালিকায় শেষে কী জমল — কলের ক্রম নয়। এটি আচরণের বর্ণনার মতো পড়া যায়, আর আচরণ ঠিক থাকলে যেকোনো refactor-এ টিকে থাকে। fake একবার লেখেন, fakes.py-তে বা একটি conftest.py fixture-এ, আর প্রতিটি টেস্ট সেটি আবার ব্যবহার করে। এর একমাত্র দুর্বলতা: fake আসল ক্লায়েন্ট থেকে সরে যেতে পারে — সে কারণেই সম্পূর্ণ উদাহরণে চুক্তির জন্য একটি autospec টেস্ট রাখা হয়েছে।
একটা সম্পূর্ণ উদাহরণ
test_checkout.py টেস্ট-পরিকল্পনাটি সারি ধরে ধরে বাস্তবায়ন করে, প্রতিটি সরঞ্জাম তার নিজের জায়গায় — আচরণের জন্য fake, চুক্তির জন্য autospec mock, ব্যর্থতার জন্য side_effect:
from unittest.mock import create_autospec
import pytest
from checkout import place_order
from fakes import FakeGateway, FakeMailer
from payments import PaymentGateway
CART = [{"price_cents": 1000, "qty": 2}, {"price_cents": 500, "qty": 1}]
EMAIL = "asha@example.com"
# Behaviour, checked with fakes: what happened, not which calls were made.
def test_paid_order_charges_once_and_sends_a_receipt():
gateway, mailer = FakeGateway(), FakeMailer()
result = place_order(CART, EMAIL, "tok_visa", gateway, mailer)
assert result == {"status": "paid", "charge_id": "ch_1", "total_cents": 2500}
assert gateway.charges == [("ch_1", 2500, "tok_visa")]
assert mailer.sent == [(EMAIL, "Order confirmed: ch_1")]
def test_declined_card_sends_an_apology_and_charges_nothing():
gateway, mailer = FakeGateway(declines=True), FakeMailer()
result = place_order(CART, EMAIL, "tok_visa", gateway, mailer)
assert result == {"status": "declined"}
assert gateway.charges == []
assert mailer.sent == [(EMAIL, "Your payment was declined")]
def test_empty_cart_never_reaches_the_gateway():
gateway, mailer = FakeGateway(), FakeMailer()
with pytest.raises(ValueError, match="cart is empty"):
place_order([], EMAIL, "tok_visa", gateway, mailer)
assert gateway.charges == []
assert mailer.sent == []
# The contract with the real client, checked with an autospec mock.
def test_gateway_is_called_with_the_real_signature():
gateway = create_autospec(PaymentGateway, instance=True)
gateway.charge.return_value = {"id": "ch_9"}
place_order(CART, EMAIL, "tok_visa", gateway, FakeMailer())
gateway.charge.assert_called_once_with(2500, token="tok_visa")
gateway.refund.assert_not_called()
def test_a_timeout_propagates_and_sends_nothing():
gateway = create_autospec(PaymentGateway, instance=True)
gateway.charge.side_effect = TimeoutError("gateway timed out")
mailer = FakeMailer()
with pytest.raises(TimeoutError):
place_order(CART, EMAIL, "tok_visa", gateway, mailer)
assert mailer.sent == []$ pytest -v test_checkout.py
============================= test session starts ==============================
collected 5 items
test_checkout.py::test_paid_order_charges_once_and_sends_a_receipt PASSED [ 20%]
test_checkout.py::test_declined_card_sends_an_apology_and_charges_nothing PASSED [ 40%]
test_checkout.py::test_empty_cart_never_reaches_the_gateway PASSED [ 60%]
test_checkout.py::test_gateway_is_called_with_the_real_signature PASSED [ 80%]
test_checkout.py::test_a_timeout_propagates_and_sends_nothing PASSED [100%]
============================== 5 passed in 0.08s ===============================দুটি জিনিস লক্ষ করার মতো।
প্রথমত, আচরণের তিনটি টেস্টে কোনো assert_called... নেই। তারা ফলাফল যাচাই করে — ফেরত মান, লেখা চার্জ, পাঠানো ইমেল — তাই place_order গোড়া থেকে নতুন করে লিখলেও তারা টিকে থাকবে।
দ্বিতীয়ত, যে একটিমাত্র টেস্ট কল যাচাই করে সেটি PaymentGateway-এর সাথে চুক্তি, আর সেটি create_autospec ব্যবহার করে। কেউ charge-এর নাম বদলালে বা নতুন বাধ্যতামূলক আর্গুমেন্ট যোগ করলে এই টেস্ট চুপচাপ একমত না হয়ে ব্যর্থ হবে। পরিকল্পনায় পাঁচটি সারি, পাঁচটি টেস্ট, place_order-এর ভেতরটা টেস্ট করে এমন কিছু নেই।
কিছু ভাঙা অবস্থা ও তার সমাধান
AssertionError: expected call not found. mock ডাকা হয়েছে, কিন্তু এই আর্গুমেন্টগুলো দিয়ে নয়। বার্তায় Expected: আর Actual: একটির নিচে আরেকটি ছাপা হয় — অক্ষর ধরে ধরে মিলিয়ে দেখুন। positional আর্গুমেন্ট বনাম keyword আর্গুমেন্ট (charge(2500, "tok") বনাম charge(2500, token="tok")) আলাদা কল হিসেবে গণ্য হয়।
AssertionError: Expected 'charge' to be called once. Called 2 times. কোড একাধিকবার ডেকেছে, আর Calls: লাইনে প্রতিটি কলের তালিকা আছে। প্রায়ই কারণ একটি retry লুপ, অথবা টেস্টে দুটি কাজের জন্য একই mock ব্যবহার।
AttributeError: Mock object has no attribute 'chrage'. Did you mean: 'charge'? spec তার কাজ করছে: কোড এমন নাম ব্যবহার করছে যা আসল ক্লাসে নেই। কোড ঠিক করুন, টেস্ট নয়।
TypeError: missing a required argument: 'token' autospec mock এমন একটি কল ধরেছে যা আসল signature-এর সাথে মেলে না। আসল ক্লায়েন্টের সামনে কোডটি একইভাবে ব্যর্থ হতো।
AttributeError: <module 'signup' from '...'> does not have the attribute 'send_mail' patch-এর লক্ষ্য এমন কিছুর নাম দিচ্ছে যা নেই — বানান ভুল, বা ভুল মডিউল। patch নতুন attribute বানাতে রাজি হয় না, আর সেটা ভালোই।
patch "কিছুই করেনি", আর আসল সার্ভিস ডাকা হয়েছে নাম যেখানে খোঁজা হয় সেখানে নয়, যেখানে সংজ্ঞায়িত সেখানে patch করেছেন। মডিউলে from emailer import send_email থাকলে patch করুন "signup.send_email"।
mock কল থেকে StopIteration side_effect ছিল একটি তালিকা, আর কোড তালিকার আইটেমের চেয়ে বেশি কল করেছে।
ফলে ডেটার বদলে আছে <MagicMock name='mock.charge().__getitem__()' ...> আপনি return_value সেট করেননি, আর MagicMock খুশিমনে receipt["id"]-এর উত্তরে আরেকটি mock দিয়েছে। সাধারণ Mock হলে একই কোড TypeError: 'Mock' object is not subscriptable দিয়ে ব্যর্থ হতো। আসল ক্লায়েন্ট যে আকারের উত্তর দেয়, return_value-তে সেটাই দিন।
fixture 'mocker' not found pytest যে এনভায়রনমেন্ট থেকে চলছে সেখানে pytest-mock ইনস্টল করা নেই। pip install pytest-mock।
ধাপ ৪ / ৬ — অনুমান
যাচাই করুন
side_effect-এ তিনটি আইটেমের একটি তালিকা দেওয়া হয়েছে, যার মাঝেরটি একটি exception। ঠিক কী ছাপা হবে?
from unittest.mock import Mock
client = Mock()
client.fetch.side_effect = [1, ValueError("boom"), 3]
print(client.fetch())
try:
client.fetch()
except ValueError as exc:
print("error:", exc)
print(client.fetch(), client.fetch.call_count)- A1 error: boom 3 2
- B1 ValueError('boom') 3 3
- C1 error: boom 3 3
- D[1, ValueError('boom'), 3] error: boom 3 1
টেস্টটি RuntimeError: real SMTP call দিয়ে ব্যর্থ হয় — অর্থাৎ আসল send চলেছে। কোন পরিবর্তনে এটি ঠিক হবে?
# mailer.py
def send(to, subject):
raise RuntimeError("real SMTP call")
# orders.py
from mailer import send
def confirm(order_id, email):
send(email, f"Order {order_id} confirmed")
# test_orders.py
from orders import confirm
def test_confirm_sends_one_email(mocker):
send = mocker.patch("mailer.send")
confirm(7, "asha@example.com")
send.assert_called_once_with("asha@example.com", "Order 7 confirmed")- A`mocker.patch("mailer.send", autospec=True)` লিখুন
- B`mocker.patch("orders.send")` লিখুন — `orders` যেখানে নামটি খোঁজে
- C`mocker.patch`-এর বদলে `mocker.spy(mailer, "send")` ব্যবহার করুন
- D`orders.py`-তে `send` ডাকার আগে `import mailer` যোগ করুন
pay টোকেন দিতে ভুলে গেছে। কোন বদলি দিয়ে লেখা টেস্ট এই বাগটি ধরবে?
class PaymentGateway:
def charge(self, amount_cents, token):
...
def pay(gateway, total):
return gateway.charge(total) # the token is forgotten- AMock()
- BMagicMock()
- CMock(spec=PaymentGateway)
- Dcreate_autospec(PaymentGateway, instance=True)
উত্তর দিতে অ্যাকাউন্ট লাগবে
উত্তর মিলিয়ে দেখতে সাইন ইন করুন
প্রশ্নগুলো উপরে আছে, আর মাথায় মাথায় উত্তর ভেবে নেওয়াই আসল কাজ। সঠিক উত্তর, ব্যাখ্যা আর তিন ধাপের ইঙ্গিত দেখতে সাইন ইন করুন।
নিজে করুন
weather.py লিখুন, তাতে থাকবে WeatherClient নামের একটি ক্লাস, যার মেথড get_forecast(self, city) RuntimeError ছোড়ে — এটি আসল HTTP ক্লায়েন্টের প্রতিনিধি — আর umbrella_advice(city, client) নামের একটি ফাংশন। ফাংশনটি client.get_forecast(city) ডাকে, যা {"rain_chance": 70}-এর মতো একটি dict ফেরত দেয়, আর সম্ভাবনা 50 বা বেশি হলে "take an umbrella", নইলে "leave it at home" ফেরত দেয়। ক্লায়েন্ট TimeoutError ছুড়লে ফেরত দেয় "forecast unavailable"।
শুরু করুন একটি টেস্ট-পরিকল্পনা দিয়ে (ক্ষেত্র | ইনপুট | প্রত্যাশিত), তারপর test_weather.py লিখুন:
create_autospec(WeatherClient, instance=True)আরreturn_valueদিয়ে দুটি পরামর্শই টেস্ট করুন — ঠিক 50-এর সীমানাসহ — আরassert_called_once_withদিয়ে যাচাই করুন যে শহরের নামটি ঠিকঠাক পৌঁছেছে।side_effect = TimeoutError(...)দিয়ে"forecast unavailable"পথটি টেস্ট করুন।side_effect-এ একটি তালিকা দিয়ে একই mock থেকে দুটি আলাদা উত্তর নিন।- শহর → বৃষ্টির সম্ভাবনার একটি dict রাখে এমন একটি
FakeWeatherClientলিখুন, আর সেটি দিয়ে টেস্ট করুন।
তারপর একটি পরীক্ষা: ধরে নিন লাইব্রেরি আপগ্রেড হয়েছে, আর আসল মেথডটিকে বদলে করুন get_forecast(self, city, units)। একটি টেস্ট চালান যা Mock() ব্যবহার করে, আর একটি যা create_autospec ব্যবহার করে। কোনটি টের পায়?
সমাধান
weather.py:
class WeatherClient:
"""Stands for the real HTTP client."""
def get_forecast(self, city):
raise RuntimeError("real HTTP call — never in a test")
def umbrella_advice(city, client):
try:
forecast = client.get_forecast(city)
except TimeoutError:
return "forecast unavailable"
if forecast["rain_chance"] >= 50:
return "take an umbrella"
return "leave it at home"পরিকল্পনা:
| ক্ষেত্র | ইনপুট | প্রত্যাশিত | |---|---|---| | বৃষ্টির দিন | সম্ভাবনা 70 | "take an umbrella" | | সীমানা | সম্ভাবনা 50 | "take an umbrella" | | ঠিক নিচে | সম্ভাবনা 49 | "leave it at home" | | নেটওয়ার্ক ব্যর্থতা | ক্লায়েন্ট TimeoutError ছোড়ে | "forecast unavailable" | | চুক্তি | যেকোনো শহর | get_forecast সেই শহর দিয়ে একবার ডাকা হয় |
test_weather.py:
from unittest.mock import create_autospec
import pytest
from weather import WeatherClient, umbrella_advice
@pytest.fixture
def client():
return create_autospec(WeatherClient, instance=True)
@pytest.mark.parametrize(
"chance, advice",
[(70, "take an umbrella"), (50, "take an umbrella"), (49, "leave it at home")],
)
def test_advice_follows_the_rain_chance(client, chance, advice):
client.get_forecast.return_value = {"rain_chance": chance}
assert umbrella_advice("Dhaka", client) == advice
client.get_forecast.assert_called_once_with("Dhaka")
def test_timeout_gives_a_polite_answer(client):
client.get_forecast.side_effect = TimeoutError("no answer in 5s")
assert umbrella_advice("Dhaka", client) == "forecast unavailable"
def test_one_mock_two_answers(client):
client.get_forecast.side_effect = [{"rain_chance": 90}, {"rain_chance": 10}]
assert umbrella_advice("Dhaka", client) == "take an umbrella"
assert umbrella_advice("Dhaka", client) == "leave it at home"
assert client.get_forecast.call_count == 2
class FakeWeatherClient:
def __init__(self, chances):
self.chances = chances
def get_forecast(self, city):
return {"rain_chance": self.chances[city]}
def test_with_a_fake():
client = FakeWeatherClient({"Dhaka": 80, "Cairo": 5})
assert umbrella_advice("Dhaka", client) == "take an umbrella"
assert umbrella_advice("Cairo", client) == "leave it at home"$ pytest -v test_weather.py
============================= test session starts ==============================
collected 6 items
test_weather.py::test_advice_follows_the_rain_chance[70-take an umbrella] PASSED [ 16%]
test_weather.py::test_advice_follows_the_rain_chance[50-take an umbrella] PASSED [ 33%]
test_weather.py::test_advice_follows_the_rain_chance[49-leave it at home] PASSED [ 50%]
test_weather.py::test_timeout_gives_a_polite_answer PASSED [ 66%]
test_weather.py::test_one_mock_two_answers PASSED [ 83%]
test_weather.py::test_with_a_fake PASSED [100%]
============================== 6 passed in 0.08s ===============================পরীক্ষাটি, WeatherClient-এ get_forecast(self, city, units) আর এই test_drift.py দিয়ে:
from unittest.mock import Mock, create_autospec
from weather import WeatherClient, umbrella_advice
def test_plain_mock():
client = Mock()
client.get_forecast.return_value = {"rain_chance": 70}
assert umbrella_advice("Dhaka", client) == "take an umbrella"
def test_autospec():
client = create_autospec(WeatherClient, instance=True)
client.get_forecast.return_value = {"rain_chance": 70}
assert umbrella_advice("Dhaka", client) == "take an umbrella"$ pytest -q --tb=line test_drift.py
.F [100%]
=================================== FAILURES ===================================
E TypeError: missing a required argument: 'units'
/usr/lib/python3.12/inspect.py:3157: TypeError: missing a required argument: 'units'
=========================== short test summary info ============================
FAILED test_drift.py::test_autospec - TypeError: missing a required argument:...
1 failed, 1 passed in 0.07sপ্রতিটি সিদ্ধান্তের কারণ:
- একটি fixture autospec mock বানায়। প্রতিটি টেস্ট নতুন একটি পায়, তাই কলের গণনা এক টেস্ট থেকে আরেকটিতে চুইয়ে যায় না, আর কড়া সংস্করণটিই ডিফল্ট — মনে রাখার মতো আলাদা কিছু নয়।
parametrize70, 50 আর 49 ঢেকে দেয়।>=লাইনটিতেই ভুল হওয়ার সম্ভাবনা সবচেয়ে বেশি; 50 আর 49 তার দুই পাশে। কেবল একটি "বৃষ্টির" ক্ষেত্র থাকলে>দিয়েও পাস করত।assert_called_once_with("Dhaka")parametrize করা টেস্টেই আছে, কারণ শহরের নাম পৌঁছে দেওয়া ক্লায়েন্টের সাথে চুক্তির অংশ — সীমানা পেরোনো একমাত্র কল।- টাইমআউট টেস্ট কেবল ফেরত মান যাচাই করে। ব্যবহারকারী কী দেখেন সেটাই জরুরি; ক্লায়েন্টকে কতবার জিজ্ঞেস করা হলো তা প্রতিশ্রুতির অংশ নয়।
- fake-এর প্রতি টেস্টে কোনো প্রস্তুতি লাগে না: আপনি জগৎটা বর্ণনা করেন (
{"Dhaka": 80, "Cairo": 5}) আর তাকে প্রশ্ন করেন। নিজের মালিকানার ক্লায়েন্টের জন্য সাধারণত এটাই বেশি পড়ার উপযোগী টেস্ট। - পরীক্ষাটি খালি
Mock-এর দাম দেখায়: আপগ্রেডের পর আসল ক্লায়েন্ট প্রতিটি কলে ব্যর্থ হতো, অথচ সাধারণ-mock টেস্ট সবুজই থাকে। autospec টেস্ট সেদিনই ব্যর্থ হয়, অনুপস্থিত আর্গুমেন্টের নাম বলে দিয়ে।
ধাপ ৬ / ৬
কঠিন করা — অধ্যায়ের কুইজ
সহজ থেকে কঠিন — দশটি প্রশ্ন, শেষেরগুলো ইচ্ছে করেই কঠিন।
সাইন ইন করে কুইজ দিন