অধ্যায় 11

Mock — বাইরের সার্ভিসকে কীভাবে ডাকা হলো তা যাচাই করা

পেমেন্ট বা ইমেল ক্লায়েন্টের জায়গায় Mock বসিয়ে দেখা তাকে কীভাবে ডাকা হলো। return_value, side_effect, autospec, patch, mocker — আর কখন mock নয়, fake।

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

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

এখানে একটি ছোট চেকআউট। এটি কার্টের মোট দাম বের করে, একটি পেমেন্ট ক্লায়েন্টের মাধ্যমে কার্ড থেকে টাকা কাটে, আর গ্রাহককে ইমেল পাঠায়।

payments.py:

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

python
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 — প্রতিটি কল লিখে রাখা একটি বস্তু

python
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)
text
<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 মেথডগুলো একই কথা এক লাইনে বলে, আর ব্যর্থ হলে নিজেই ব্যাখ্যা দেয়:

python
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")
text
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"] করে, তার দরকার একটি আসল উত্তর, তাই সেটি দিয়ে দিন:

python
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"))
text
{'id': 'ch_1', 'status': 'succeeded'}
{'id': 'ch_1', 'status': 'succeeded'}

আর্গুমেন্ট যা-ই হোক, উত্তর একই। এতে না চললে side_effect সেট করুন। এর তিনটি রূপ।

একটি exception — gateway.charge.side_effect = PaymentDeclined("card declined") দিলে প্রতিটি কলেই সেটি ছোড়া হয়। বাস্তবে যে অপ্রীতিকর পথগুলো ঘটানো কঠিন, সেগুলোতে এভাবেই পৌঁছানো যায়: প্রত্যাখ্যাত কার্ড, টাইমআউট, সার্ভার থেকে 500।

একটি তালিকা — প্রতি কলে একটি করে আইটেম, ক্রমানুসারে। তালিকার কোনো আইটেম exception হলে সেটি ছোড়া হয়; বাকি সব ফেরত দেওয়া হয়। "একবার ব্যর্থ, তারপর সফল" ধরনের অবস্থার জন্য ভালো:

python
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")
text
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 সবকিছুতে হ্যাঁ বলে

এই বাধ্য স্বভাবের একটি দাম আছে। একটি মেথডের নাম ভুল বানানে লিখুন, কেউ আপত্তি করবে না:

python
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)
text
no error — the typo was recorded as a new method
[call.chrage(2500, token='tok_visa')]

এই বানান-ভুলটি checkout.py-তে থাকলে খালি Mock দিয়ে লেখা টেস্ট পাস করে যেতে পারত, আর প্রথম আসল গ্রাহক পেতেন AttributeError। ভুল আর্গুমেন্টও একইভাবে ফসকে যায়। এখানে ভুল পথ আর ঠিক পথ পাশাপাশি — যে কোড টেস্ট হচ্ছে সে টোকেনটি দিতে ভুলে গেছে:

python
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()
text
$ 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 ঢুকিয়ে দেওয়া সহজ। কিন্তু প্রায়ই কোড তার নির্ভরতা নিজেই নিয়ে আসে:

python
# 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 হিসেবে:

python
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")
text
$ 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 করা — কপিটিকে অছোঁয়া রাখে, আর আসল ফাংশনটি চলে:

python
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()
text
$ 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 কেবল নজর রাখে।

python
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")]
text
$ pytest -q test_signup_mocker.py
..                                                                       [100%]
2 passed in 0.08s

spy একটি 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 দিয়ে গড়া টেস্ট যাচাই করে কোড তার কাজ কীভাবে করল। বাড়াবাড়ি করলে টেস্টটি আর যাচাই করে না সে কী করল। আগে ভুল পথ:

python
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()
text
$ 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-কে হারিয়ে দেয়:

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

python
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 == []
text
$ 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।