পাঠ ৪৯ · ৫৮-এর মধ্যে · মডিউল ১১
Home / Courses / Full-Stack Web Frameworks / ইন্টিগ্রেশন টেস্টিং

API-র ইন্টিগ্রেশন টেস্টিং

Integration testing APIs
১০ মিনিট পড়া মধ্যবর্তী · Intermediate Python কোডসহ সম্পূর্ণ বাংলায়

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

  • ইন্টিগ্রেশন টেস্ট ইউনিট টেস্ট থেকে কীভাবে আলাদা — একাধিক অংশের মিথস্ক্রিয়া যাচাই
  • একটি স্বনির্ভর CRUD "ডেটাবেস" + সরল রাউটার কম্বো (M6-স্টাইল) তৈরি করা
  • সম্পূর্ণ create → read → update → delete জীবনচক্র, প্রতিটি ধাপের real response assert করে যাচাই
  • একটি ইচ্ছাকৃত বাগ দিয়ে টেস্ট রানার সত্যিই FAIL ধরে কি না যাচাই করা, তারপর ঠিক সংস্করণে PASS দেখা

১ · ইন্টিগ্রেশন টেস্ট কীভাবে ইউনিট টেস্ট থেকে আলাদা

ইন্টিগ্রেশন টেস্টIntegration Testএকাধিক কম্পোনেন্ট একসাথে সংযুক্ত হয়ে সঠিকভাবে কাজ করছে কি না তা যাচাই করার টেস্ট। L48-এর ইউনিট টেস্ট একটি বিচ্ছিন্ন ফাংশন (cart_reducer) যাচাই করেছিল। ইন্টিগ্রেশন টেস্ট এক ধাপ উপরে — এটি যাচাই করে যখন একাধিক অংশ (যেমন একটি রাউটার আর তার পেছনের ডেটাবেস) একসাথে কাজ করে, তখন পুরো প্রবাহ সঠিকভাবে চলে কি না। এখানে বিচ্ছিন্নতার বদলে গুরুত্ব পায় — অংশগুলোর মধ্যে "সংযোগ" (integration) আসলেই কাজ করছে কি না।

২ · একটি স্বনির্ভর CRUD + রাউটার কম্বো

নিচের কোড সেলে M6-এর ধাঁচে (কিন্তু এই লেসনের নিজস্ব, সম্পূর্ণ স্বনির্ভর সংস্করণে) একটি ছোট্ট Api ক্লাস তৈরি করা হচ্ছে — এটি নিজেই একটি ইন-মেমরি dict-ভিত্তিক "ডেটাবেস" (self.table) রাখে এবং handle(method, path, body) মেথড দিয়ে HTTP-verb+path সমন্বয় ডিসপ্যাচ করে সঠিক status code ও body ফেরত দেয় — ঠিক যেমন একটি ছোট্ট REST API।

Python
# ---------- CORE PATTERN 9 (L47): ছোট্ট টেস্ট রানার, এই পাঠের জন্য পুনর্গঠিত ----------
_tests = []

def test(name):
    def decorator(fn):
        _tests.append((name, fn))
        return fn
    return decorator

def run_all(test_list=None):
    items = test_list if test_list is not None else _tests
    passed, failed = 0, 0
    for name, fn in items:
        try:
            fn()
            print(f"[PASS] {name}")
            passed += 1
        except AssertionError as e:
            print(f"[FAIL] {name} -- {e}")
            failed += 1
    print(f"মোট: {len(items)}, পাস: {passed}, ফেইল: {failed}\n")
    return passed, failed


# ---------- CRUD "ডেটাবেস" + রাউটার কম্বো (M6-স্টাইল, স্বনির্ভর, সঠিক সংস্করণ) ----------
class Api:
    def __init__(self):
        self.table = {}
        self.next_id = 1

    def handle(self, method, path, body=None):
        if method == "POST" and path == "/items":
            item_id = self.next_id
            self.table[item_id] = dict(body, id=item_id)
            self.next_id += 1
            return {"status": 201, "body": self.table[item_id]}

        parts = path.strip("/").split("/")
        if len(parts) == 2 and parts[0] == "items":
            item_id = int(parts[1])
            if method == "GET":
                if item_id in self.table:
                    return {"status": 200, "body": self.table[item_id]}
                return {"status": 404, "body": None}
            if method == "PUT":
                if item_id not in self.table:
                    return {"status": 404, "body": None}
                self.table[item_id].update(body)
                return {"status": 200, "body": self.table[item_id]}
            if method == "DELETE":
                if item_id not in self.table:
                    return {"status": 404, "body": None}
                del self.table[item_id]
                return {"status": 204, "body": None}

        return {"status": 404, "body": None}


# ---------- ইন্টিগ্রেশন টেস্ট: সম্পূর্ণ CRUD জীবনচক্র, একটির পর একটি ধাপ ----------
@test("CREATE -- POST /items নতুন আইটেম তৈরি করে, status 201")
def _():
    api = Api()
    resp = api.handle("POST", "/items", {"name": "Chair", "price": 100})
    assert resp["status"] == 201
    assert resp["body"]["name"] == "Chair"
    assert resp["body"]["id"] == 1

@test("READ -- তৈরি করা আইটেম GET দিয়ে ফেরত পড়া যায়")
def _():
    api = Api()
    api.handle("POST", "/items", {"name": "Chair", "price": 100})
    resp = api.handle("GET", "/items/1")
    assert resp["status"] == 200
    assert resp["body"]["price"] == 100

@test("UPDATE -- PUT-এর পর পরিবর্তন স্থায়ীভাবে সংরক্ষিত হয় (পরের GET-এও দেখা যায়)")
def _():
    api = Api()
    api.handle("POST", "/items", {"name": "Chair", "price": 100})
    put_resp = api.handle("PUT", "/items/1", {"price": 150})
    assert put_resp["status"] == 200
    get_resp = api.handle("GET", "/items/1")
    assert get_resp["body"]["price"] == 150

@test("DELETE -- মুছে ফেলার পর আইটেম আর পাওয়া যায় না (404)")
def _():
    api = Api()
    api.handle("POST", "/items", {"name": "Chair", "price": 100})
    del_resp = api.handle("DELETE", "/items/1")
    assert del_resp["status"] == 204
    get_resp = api.handle("GET", "/items/1")
    assert get_resp["status"] == 404

print("== সঠিক Api-এর বিরুদ্ধে সম্পূর্ণ CRUD ইন্টিগ্রেশন স্যুট ==")
run_all()

    
লক্ষ্য করুন UPDATE টেস্টে শুধু put_resp["status"] == 200 চেক করা হয়নি — এরপর আলাদাভাবে একটি নতুন GET কল করে get_resp["body"]["price"] যাচাই করা হয়েছে। এটাই একটি ইন্টিগ্রেশন টেস্টের আসল কাজ — PUT রেসপন্স শুধু "ঠিক দেখতে" হলেই যথেষ্ট নয়, ডেটা সত্যিই সংরক্ষিত হয়েছে কি না তা পরের ধাপে গিয়ে যাচাই করতে হয়। পরের সেকশনে দেখা যাবে ঠিক এই জায়গাতেই একটি সূক্ষ্ম বাগ লুকিয়ে থাকতে পারে।

৩ · ইচ্ছাকৃত বাগ — টেস্ট রানার কি সত্যিই FAIL ধরতে পারে?

এখন প্রশ্ন হলো — আমাদের টেস্ট রানার কি সত্যিই ভুল কোড ধরতে পারে, নাকি এটি শুধু সবসময় "পাস" বলে দেয়? এটি যাচাই করতে নিচে Api-এর একটি ইচ্ছাকৃতভাবে বাগযুক্ত কপি BuggyApi তৈরি করা হচ্ছে — এর PUT হ্যান্ডলার একটি লোকাল কপি আপডেট করে সেটিই রেসপন্সে ফেরত দেয়, কিন্তু আসল self.table-এ কখনো লেখে না। একই আপডেট-পার্সিস্টেন্স টেস্ট দুইবার চালানো হচ্ছে — একবার BuggyApi-এর বিরুদ্ধে (ফেইল হওয়া উচিত), একবার সঠিক Api-এর বিরুদ্ধে (পাস হওয়া উচিত)।

Python
# ---------- ইচ্ছাকৃত বাগ: PUT একটি লোকাল কপি রিটার্ন করে, কিন্তু self.table কখনো লেখে না ----------
class BuggyApi(Api):
    def handle(self, method, path, body=None):
        if method == "PUT":
            parts = path.strip("/").split("/")
            if len(parts) == 2 and parts[0] == "items":
                item_id = int(parts[1])
                if item_id not in self.table:
                    return {"status": 404, "body": None}
                updated = dict(self.table[item_id])
                updated.update(body)              # শুধু লোকাল কপি আপডেট হলো
                return {"status": 200, "body": updated}   # self.table কখনো লেখা হয়নি!
        return super().handle(method, path, body)   # বাকি সব মেথডের জন্য সঠিক আচরণ অপরিবর্তিত


def make_update_persists_test(api):
    def t():
        api.handle("POST", "/items", {"name": "Chair", "price": 100})
        put_resp = api.handle("PUT", "/items/1", {"price": 150})
        assert put_resp["status"] == 200
        get_resp = api.handle("GET", "/items/1")
        assert get_resp["body"]["price"] == 150, (
            f"প্রত্যাশিত price=150, কিন্তু GET-এ পাওয়া গেছে price={get_resp['body']['price']}"
        )
    return t


bug_demo_tests = [
    ("BuggyApi -- আপডেট সত্যিই persist হয় কি না (এখানে বাগ আছে)", make_update_persists_test(BuggyApi())),
    ("Api (ঠিক করা সংস্করণ) -- একই টেস্ট, একই লজিক দিয়ে", make_update_persists_test(Api())),
]

print("== একই টেস্ট লজিক, দুটো ভিন্ন বাস্তবায়নের বিরুদ্ধে ==")
run_all(bug_demo_tests)

    
দুটো টেস্টেই হুবহু একই make_update_persists_test লজিক ব্যবহার হয়েছে — শুধু পাশ করানো api instance আলাদা। BuggyApi-তে PUT-এর পর self.table[1] কখনো পরিবর্তিত হয় না (শুধু updated নামের একটি লোকাল ভ্যারিয়েবল পরিবর্তিত হয়), তাই পরের GET এখনও পুরনো price=100 ফেরত দেয় — assert get_resp["body"]["price"] == 150 ব্যর্থ হয়ে AssertionError তোলে, আর run_all() সেটিকে সত্যিকারের [FAIL] হিসেবে রিপোর্ট করে (মোট: ২, পাস: ১, ফেইল: ১)। সঠিক Api-তে self.table[item_id].update(body) আসল dict-টিকেই পরিবর্তন করে, তাই পরের GET সত্যিই price=150 ফেরত দেয় এবং টেস্ট পাস করে। এটিই প্রমাণ করে রানারটি শুধু সবসময় "পাস" বলে না — এটি সত্যিকারের আচরণের পার্থক্য ধরতে পারে।
মূল কথা · Key takeaway

ইন্টিগ্রেশন টেস্ট শুধু "রেসপন্স দেখতে ঠিক আছে কি না" যাচাই করে না — এটি যাচাই করে সিস্টেমের একটি অংশে করা পরিবর্তন আরেকটি অংশ থেকে পড়লে সত্যিই দেখা যায় কি না (এখানে: PUT-এর পরিবর্তন GET-এ প্রতিফলিত হওয়া)। BuggyApi-এর উদাহরণ দেখায় বাস্তব বাগ (persist না করা) প্রায়ই একটি একক মেথডের রেসপন্স দেখেই ধরা পড়ে না — একাধিক ধাপ চেইন করে টেস্ট করলেই ধরা পড়ে, যা ঠিক ইন্টিগ্রেশন টেস্টের কাজ। L50-এ এই ধারণা আরও এক ধাপ বিস্তৃত হয়ে সম্পূর্ণ ব্যবহারকারী-জার্নি টেস্টে পরিণত হবে।

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

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

প্র ০১ যদি আমরা শুধু put_resp["body"]["price"] == 150 চেক করতাম (আলাদা GET কল ছাড়াই), তাহলে কি BuggyApi-এর বাগটি ধরা পড়ত?

না, ধরা পড়ত না। BuggyApi.handle-এর PUT ব্র্যাঞ্চ updated নামের লোকাল কপিটাই রেসপন্সে ফেরত দেয়, যেখানে price ঠিকই ১৫০ — তাই put_resp["body"]["price"] == 150 এই assert-টি ভুলভাবে পাস করে যেত। বাগটি ধরা পড়ে শুধুমাত্র যখন একটি স্বতন্ত্র, পরবর্তী GET কল করে প্রকৃত সংরক্ষিত অবস্থা যাচাই করা হয় — এটাই দেখায় কেন ইন্টিগ্রেশন টেস্টে "একই রেসপন্সের ভেতরে যাচাই" এর বদলে "পরবর্তী ধাপে গিয়ে যাচাই" অনেক বেশি নির্ভরযোগ্য।

প্র ০২ BuggyApi কেন Api-কে ইনহেরিট করে শুধু PUT ওভাররাইড করেছে, পুরো ক্লাস নতুন করে লেখেনি?

এটি বাস্তব বাগের একটি বিশ্বস্ত সিমুলেশন — বাস্তব জীবনেও বেশিরভাগ বাগ পুরো সিস্টেম ভুল হওয়ার কারণে নয়, বরং একটি নির্দিষ্ট, ছোট অংশ (এখানে শুধু PUT হ্যান্ডলিং) ভুল লেখার কারণে হয়। ইনহেরিট করে শুধু একটি মেথড ওভাররাইড করায় প্রমাণ হয় বাকি সব (CREATE, READ, DELETE) অপরিবর্তিত এবং সঠিক রয়ে গেছে — শুধু PUT-এই সমস্যা, যা ঠিক লক্ষ্যভেদী টেস্টিং-এর প্রয়োজনীয়তা দেখায়।

প্র ০৩ দ্বিতীয় কোড সেলের run_all(bug_demo_tests) কল শেষে ঠিক কী প্রিন্ট হবে — "মোট", "পাস", "ফেইল" কত?

"মোট: ২, পাস: ১, ফেইল: ১"। কারণ bug_demo_tests তালিকায় দুটো টেস্ট আছে — প্রথমটি (BuggyApi) ব্যর্থ হয় (persist না হওয়ায় assert ভেঙে যায়), দ্বিতীয়টি (সঠিক Api) সফল হয় (persist ঠিকভাবে হওয়ায় assert সত্য হয়)। run_all() প্রতিটি টেস্টের ফলাফল আলাদাভাবে গণনা করে, তাই একটি ফেইল হলেও বাকিগুলোর ফলাফলে প্রভাব পড়ে না।

অনুশীলন

  1. চিন্তা করুন: BuggyApi-এর মতো আরেকটি বাগ কল্পনা করুন — যেখানে DELETE সঠিক status code (204) ফেরত দেয়, কিন্তু self.table থেকে আইটেমটি আসলে মুছে ফেলে না। কোন টেস্ট এই বাগটি ধরবে?

    এই পাঠের DELETE টেস্টটিই এটি ধরবে — কারণ সেটি শুধু del_resp["status"] == 204 চেক করে থেমে যায় না, এরপর আলাদাভাবে api.handle("GET", "/items/1") কল করে get_resp["status"] == 404 যাচাই করে। যদি DELETE আসলে টেবিল থেকে না মুছত, তাহলে এই পরের GET কল status 200 ফেরত দিত (আইটেম তখনও থাকত), আর assert ... == 404 ব্যর্থ হতো — ঠিক এই পাঠের PUT বাগের মতোই একই প্যাটার্নে ধরা পড়ত।

  2. পরীক্ষা করুন: দ্বিতীয় কোড সেলে BuggyApi-এর মতো আরেকটি ক্লাস BuggyDeleteApi লিখুন যার DELETE হ্যান্ডলার সবসময় {"status": 204, "body": None} রিটার্ন করে কিন্তু self.table থেকে del করে না, তারপর উপরের অনুশীলনে বর্ণিত টেস্টটি এর বিরুদ্ধে চালিয়ে দেখুন এটি সত্যিই FAIL রিপোর্ট করে কি না।

    হ্যাঁ, FAIL রিপোর্ট করবে। BuggyDeleteApi-তে del self.table[item_id] লাইনটি বাদ দেওয়া থাকলে DELETE-এর পরেও self.table[1] রয়ে যায়, তাই পরের GET কল status 200 ফেরত দেয় (আইটেমটি এখনও "আছে")। তখন assert get_resp["status"] == 404 ব্যর্থ হয়ে AssertionError তোলে এবং run_all() এটিকে [FAIL] হিসেবে দেখায় — ঠিক PUT বাগের মতোই একই যুক্তিতে।

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

  • কোর্সের সম্পূর্ণ সিলেবাস দেখুন ৫৮টি পাঠ আর্কিটেকচার প্যাটার্ন, ফ্রন্ট-এন্ড/ব্যাক-এন্ড ফ্রেমওয়ার্ক ফান্ডামেন্টাল, স্টেট ম্যানেজমেন্ট, REST API, ORM, অথেন্টিকেশন, রেন্ডারিং স্ট্র্যাটেজি ও ডিপ্লয়মেন্ট — বাকি পাঠগুলো শীঘ্রই যুক্ত হবে।
  • Python Programming কোর্স সহোদর কোর্স এই পাঠের ক্লাস ইনহেরিটেন্স ও super() ব্যবহারের ভাষাগত ভিত্তি সেই কোর্সে তৈরি হয়েছে।
  • সব Courses দেখুন ABCL TECH C, C++, Python, Java, JavaScript, DSA, DBMS, Discrete Mathematics, System Design, Cybersecurity, Cloud Computing & DevOps, Computer Networks, Operating Systems, Computer Architecture, Programming Languages & Compiler Design, Software Engineering & Git, Theory of Computation, Engineering Economics ও Full-Stack Web Frameworks — সব এক জায়গায়।
আগের পাঠ
কম্পোনেন্ট ও ফাংশনের ইউনিট টেস্টিং