পাঠ ৪৭ · ৫৮-এর মধ্যে · মডিউল ১০
Home / Courses / Software Engineering Principles & Git / মকিং ও টেস্ট ডাবল

মকিং ও টেস্ট ডাবল

Mocking & test doubles
৮ মিনিট পড়া মধ্যম · Intermediate Python কোডসহ সম্পূর্ণ বাংলায়

এই পাঠে যা শিখবেন

  • Test double কী এবং এটি কেন ইউনিট টেস্টের আইসোলেশন সম্ভব করে
  • Dummy, Stub, Mock, Fake — চারটি প্রকারের মধ্যে প্রকৃত পার্থক্য
  • DIP-ইনজেক্টেড ডিপেন্ডেন্সি কীভাবে test double বসানোর "হুক" তৈরি করে
  • একটি real OrderService, একটি call-history রেকর্ড করা Mock, ও একটি বাস্তবসম্মত Fake — দুটো আলাদা, বাস্তব ব্যবহার

১ · Test double কী — এবং কেন দরকার

Test doubleTest Doubleটেস্টের সময় একটি real ডিপেন্ডেন্সির জায়গায় বসানো যেকোনো স্ট্যান্ড-ইন অবজেক্ট — অভিনয়ের "স্টান্ট ডাবল"-এর সাথে সাদৃশ্যপূর্ণ নামকরণ। একটি ছাতা-শব্দ (umbrella term) — টেস্টের সময় একটি real ডিপেন্ডেন্সির জায়গায় বসানো যেকোনো অবজেক্টকে বোঝায়, যা M10/L44-এর ইউনিট-টেস্ট-আইসোলেশন দাবির সরাসরি এনাবলার। L44 বলেছিল একটি ইউনিট টেস্টকে বাকি সিস্টেম থেকে "আলাদা" (isolated) হতে হবে — কিন্তু বাস্তব কোড প্রায় সবসময় অন্য কিছুর উপর নির্ভর করে (একটি ডেটাবেস, একটি পেমেন্ট API, একটি ফাইল সিস্টেম)। test double সেই real, ধীর/অনির্ভরযোগ্য/সেটআপ-কঠিন ডিপেন্ডেন্সির বদলে একটি সরল, নিয়ন্ত্রিত স্ট্যান্ড-ইন বসিয়ে দেয় — টেস্ট তখন M10/L46-এর FIRST মানদণ্ড (Fast, Independent, Repeatable, Self-validating, Timely) সহজেই পূরণ করতে পারে।

২ · চার ধরনের টেস্ট ডাবল

এই চারটি প্রকার একসাথে গুলিয়ে ফেলা একটি সাধারণ ভুল — প্রতিটির উদ্দেশ্য ও আচরণ আলাদা:

Dummy
শুধু একটি প্যারামিটার-প্রয়োজনীয়তা পূরণ করতে পাস করা হয় — কখনো আসলে কল/ব্যবহার হয় না।
Stub
একটি canned (আগে থেকে ঠিক করা) রেসপন্স দেয়, কোনো real লজিক ছাড়াই — যেমন সবসময় একটি ফিক্সড রেকর্ড রিটার্ন করা।
Mock
Stub-এর মতো রেসপন্স দেয়, কিন্তু অতিরিক্তভাবে কী কল হয়েছে তা রেকর্ড করে — টেস্ট সেই call history-র উপর ASSERT করে।
Fake
একটি সত্যিকারের কাজ-করা, সরলীকৃত ইমপ্লিমেন্টেশন — যেমন একটি ইন-মেমরি ফেক ডেটাবেস, যা আসলেই স্টোর/রিট্রিভ করে, শুধু persistence ছাড়া।

৩ · DIP ছাড়া test double বসানো অসম্ভব

M4/L18 SOLID — DIP-এর সাথে সরাসরি সংযোগ

M4/L18-এর Dependency Inversion Principle শিখিয়েছিল একটি ক্লাসকে তার ডিপেন্ডেন্সি নিজে তৈরি না করে, বরং constructor দিয়ে ইনজেক্ট করা উচিত। এটাই ঠিক সেই "হুক" যা test double বসানো সম্ভব করে — যদি OrderService নিজেই একটি concrete MySQLPaymentGateway হার্ডকোড করে তৈরি করত, তাহলে টেস্টের সময় সেটিকে Mock/Fake দিয়ে বদলানোর কোনো উপায় থাকত না। DIP-এর ফ্লেক্সিবিলিটি সুবিধা (যেকোনো ইমপ্লিমেন্টেশন ইনজেক্ট করা যায়) এবং টেস্টেবিলিটি সুবিধা (test double ইনজেক্ট করা যায়) — আসলে একই মেকানিজমের দুটো দিক।

OrderService abstract payment_gateway (DIP) RealPaymentGateway প্রোডাকশনে MockPaymentGateway call history verify FakePaymentGateway realistic simulate
DIP-এর কারণে OrderService শুধু abstract interface চেনে — প্রোডাকশনে real gateway, টেস্টে Mock বা Fake, সবই একই জায়গায় ইন্টারচেঞ্জেবল।

৪ · বাস্তবে Mock ও Fake — দুটো ভিন্ন উদ্দেশ্য

নিচের কোড সেলে একটি real OrderService ক্লাস আছে, যা constructor-এ একটি payment_gateway নেয় (DIP)। একটি MockPaymentGateway প্রতিটি কল রেকর্ড করে রাখে — টেস্ট সেই রেকর্ড থেকে যাচাই করে charge() ঠিক সঠিক অ্যামাউন্ট দিয়ে, ঠিক একবার কল হয়েছে কিনা (interaction verification)। একটি FakePaymentGateway একটি সাধারণ ইন-মেমরি নিয়ম (একটি সীমার উপরে অ্যামাউন্ট রিজেক্ট করা) দিয়ে বাস্তবসম্মত সাফল্য/ব্যর্থতা সিমুলেট করে (realistic behavior simulation) — দুটোই "test double", কিন্তু সম্পূর্ণ ভিন্ন উদ্দেশ্যে ব্যবহৃত।

Python
# OrderService -- payment_gateway constructor দিয়ে ইনজেক্ট করা হয় (M4/L18-এর DIP-এর সরাসরি প্রয়োগ)
class OrderService:
    def __init__(self, payment_gateway):
        self.payment_gateway = payment_gateway

    def place_order(self, order_id, amount):
        result = self.payment_gateway.charge(order_id, amount)
        if result["success"]:
            return f"অর্ডার {order_id} সফল -- {amount} টাকা চার্জ হয়েছে"
        return f"অর্ডার {order_id} ব্যর্থ -- কারণ: {result['reason']}"


# ---- MOCK: প্রতিটি কল রেকর্ড করে -- interaction VERIFY করার জন্য (canned response, real লজিক নেই) ----
class MockPaymentGateway:
    def __init__(self):
        self.calls = []  # (order_id, amount) প্রতিটি কল এখানে রেকর্ড হবে

    def charge(self, order_id, amount):
        self.calls.append((order_id, amount))
        return {"success": True}  # সবসময় canned সফল response


# ---- FAKE: বাস্তবসম্মত সাফল্য/ব্যর্থতা সিমুলেট করে, সরল ইন-মেমরি নিয়মে ----
class FakePaymentGateway:
    LIMIT = 5000

    def __init__(self):
        self.ledger = []

    def charge(self, order_id, amount):
        if amount > self.LIMIT:
            return {"success": False, "reason": f"সীমা {self.LIMIT} টাকার বেশি"}
        self.ledger.append((order_id, amount))
        return {"success": True}


# ---- টেস্ট ১: MOCK দিয়ে -- charge() সঠিক amount দিয়ে ঠিক একবার কল হয়েছে তা VERIFY করা ----
mock_gateway = MockPaymentGateway()
service_with_mock = OrderService(mock_gateway)
result1 = service_with_mock.place_order("ORD-1", 1200)

assert len(mock_gateway.calls) == 1, "charge() ঠিক একবার কল হওয়া উচিত"
assert mock_gateway.calls[0] == ("ORD-1", 1200), "সঠিক amount দিয়ে কল হওয়া উচিত"
print("টেস্ট ১ (Mock দিয়ে interaction verify):", result1)
print("  mock.calls =", mock_gateway.calls)
print("  চেক পাস: charge() ঠিক ১ বার, সঠিক amount সহ কল হয়েছে")

# ---- টেস্ট ২: FAKE দিয়ে -- বাস্তবসম্মত declined-payment scenario ----
fake_gateway = FakePaymentGateway()
service_with_fake = OrderService(fake_gateway)

result_ok = service_with_fake.place_order("ORD-2", 3000)
result_declined = service_with_fake.place_order("ORD-3", 8000)

assert "সফল" in result_ok
assert "ব্যর্থ" in result_declined
print("\nটেস্ট ২ (Fake দিয়ে realistic behavior):")
print(" ", result_ok)
print(" ", result_declined)
print("  চেক পাস: Fake বাস্তবসম্মতভাবে সীমার নিচে সফল, উপরে ব্যর্থ সিমুলেট করেছে")

    
লক্ষ্য করুন — টেস্ট ১-এ আমরা result1-এর ভ্যালুর উপর নয়, বরং mock_gateway.calls-এর উপর ASSERT করেছি। এটাই Mock-কে Stub থেকে আলাদা করে: Stub শুধু একটি প্রি-প্রোগ্রামড উত্তর দেয়, কিন্তু Mock অতিরিক্তভাবে "কী ঘটেছিল" তা মনে রাখে যাতে টেস্ট পরে সেই ইন্টারঅ্যাকশনটাই যাচাই করতে পারে। টেস্ট ২-এ আমরা উল্টো কাজ করেছি — Fake-এর বিহেভিয়ার (সীমা অতিক্রম করলে reject করা) যাচাই করেছি, কোনো call-history নয়।
মূল কথা · Key takeaway

test double একটি একক ধারণা নয়, বরং একটি পরিবার — Dummy, Stub, Mock, Fake প্রতিটি ভিন্ন কাজের জন্য ভিন্ন টুল। Mock ব্যবহার করুন যখন আপনি যাচাই করতে চান "সঠিকভাবে কল হয়েছে কিনা" (interaction), Fake ব্যবহার করুন যখন আপনার একটি বাস্তবসম্মত, কিন্তু হালকা ওজনের আচরণ দরকার (behavior)। আর এসবই সম্ভব হয় শুধু কারণ M4/L18-এর DIP ডিপেন্ডেন্সিকে ইনজেক্ট-করার-যোগ্য করে রেখেছে।

ভাবনার প্রশ্ন

প্রতিটি প্রশ্ন নিজে কিছুক্ষণ ভাবুন — তারপর "→ উত্তর" চাপুন।

প্র ০১ উপরের MockPaymentGateway-কে যদি Stub হিসেবে ব্যবহার করতাম (শুধু {"success": True} রিটার্ন করার জন্য, call history চেক না করে), তাহলে কি এখনো একে "Mock" বলা ঠিক হতো?

কঠোরভাবে বলতে গেলে না — টেকনিক্যালি সেটি তখন Stub হিসেবে ব্যবহৃত হচ্ছে, কারণ যা তাকে Mock করে তোলে তা হলো টেস্টে তার call history-র উপর ASSERT করা, শুধু ক্লাসের নামে "Mock" থাকা নয়। একই ক্লাস object টেকনিক্যালি Stub-এর মতো ব্যবহৃত হতে পারে যদি টেস্ট শুধু রিটার্ন ভ্যালু চেক করে, তার রেকর্ড করা calls লিস্ট কখনো না দেখে — পার্থক্যটা ক্লাসের গঠনে নয়, ব্যবহারের ধরনে।

প্র ০২ FakePaymentGateway-এর বদলে কেন real PaymentGateway ব্যবহার করে সরাসরি ইউনিট টেস্ট লেখা হয় না?

কারণ real payment gateway প্রায় নিশ্চিতভাবেই একটি নেটওয়ার্ক কল করবে (ধীর, অনির্ভরযোগ্য — M10/L46-এর FIRST-এর "Fast" ও "Repeatable" ভাঙে), হয়তো আসল টাকা চার্জ করবে (বিপজ্জনক, প্রতিবার টেস্ট রান করলে!), এবং এর ফলাফল বাহ্যিক সিস্টেমের অবস্থার উপর নির্ভর করবে (একই ইনপুটে একই আউটপুট নাও আসতে পারে — "Repeatable" ভঙ্গ)। Fake এই সব সমস্যা এড়িয়ে দ্রুত, নিয়ন্ত্রিত, পুনরাবৃত্তিযোগ্য টেস্ট দেয়।

প্র ০৩ যদি OrderService-এর constructor নিজেই self.payment_gateway = MySQLPaymentGateway() লিখে সরাসরি তৈরি করত (DIP লঙ্ঘন করে), তাহলে উপরের টেস্ট দুটো লেখা কি সম্ভব হতো?

না — MySQLPaymentGateway হার্ডকোড করা থাকলে OrderService-এর সাথে টাইট কাপলিং তৈরি হতো (M4/L14), এবং টেস্টের কোনো উপায় থাকত না তাকে MockPaymentGateway বা FakePaymentGateway দিয়ে বদলে দেওয়ার — টেস্ট করতে বাধ্য হয়ে একটি real MySQL ডেটাবেস লাগত। এটাই স্পষ্ট প্রমাণ করে কেন DIP-এর dependency injection টেস্টেবিলিটির জন্য প্রায় আবশ্যিক পূর্বশর্ত।

অনুশীলন

  1. পরীক্ষা করুন: উপরের কোডে service_with_mock.place_order("ORD-1", 1200)-এর নিচে আরেকটি কল service_with_mock.place_order("ORD-4", 500) যোগ করুন, তারপর mock_gateway.calls-এর length চেক করুন — কত হওয়া উচিত?

    দ্বিতীয় কলের পর mock_gateway.calls-এর length হবে ২ — [("ORD-1", 1200), ("ORD-4", 500)]। Mock প্রতিটি কল ক্রমানুসারে রেকর্ড করতে থাকে, তাই একই gateway instance-এ একাধিকবার place_order() কল করলে সবগুলো রেকর্ড জমা হয় — যদি আসল উদ্দেশ্য ছিল "ঠিক একবার" verify করা, তাহলে assert len(mock_gateway.calls) == 1-এর মতো একটি চেক এই দ্বিতীয় কলের পর FAIL করবে, যা genuine bug ধরার একটি বাস্তব উদাহরণ।

  2. চিন্তা করুন: FakePaymentGateway.LIMIT-কে 1000-এ পরিবর্তন করলে উপরের কোডের কোন লাইনের আউটপুট বদলে যাবে, এবং কেন?

    result_ok = service_with_fake.place_order("ORD-2", 3000)-এর ফলাফল বদলে যাবে — এখন 3000 > 1000 হওয়ায় এটিও রিজেক্ট হবে ("ব্যর্থ")। কিন্তু assert "সফল" in result_ok তখন FAIL করবে, ঠিক যেমন কোড রিভিউয়ে একটি বিজনেস-নিয়মের পরিবর্তন কীভাবে বিদ্যমান টেস্টকে ভাঙতে পারে তার একটি সরাসরি, ছোট উদাহরণ — M10/L48-এ এই ধরনের ভাঙা টেস্ট ধরার প্রক্রিয়াই বিস্তারিত আসবে।

আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ

পূর্ববর্তী পাঠ
ভালো ইউনিট টেস্ট লেখা ও টেস্ট কভারেজ