اختبار العقد

اختبار العقد (Contract Testing) يفحص نقطة تكامل باختبار كل طرف منها وحده مقابل عقد مشترك: الطلبات التي يرسلها المستهلك (Consumer) والاستجابات التي ينتظرها من المزوّد (Provider). المستهلك هو من يستدعي، كتطبيق جوال أو خدمة أخرى، والمزوّد هو من يجيب، أي الواجهة البرمجية (API). ولا يحتاج أيّ من الاختبارين إلى تشغيل الطرف الآخر، فيتحقق كل فريق من جزئه في خط الأنابيب (Pipeline) الخاص به.

مثال ذلك أن تطبيق الجوال يقرأ id وstatus وtotal من GET /orders/{orderId} في واجهة الطلبات (Orders API). فيُختبر التطبيق مقابل بديل محاكاة (Mock) يجيب كما ينص العقد، وتُختبر واجهة الطلبات بإعادة إرسال الطلب والتأكد أن استجابتها الحقيقية ما زالت مطابقة للعقد. فإذا أعاد مطوّر تسمية total فشل فحص المزوّد في خط الأنابيب الخاص به، قبل أن يلتقي الطرفان في بيئة مشتركة بوقت طويل. والعقد إما أن يقوده المستهلك، وإما أن يُبنى على المواصفة في مستند OpenAPI.

لماذا يهم

تُصدَر الخدمات والتطبيقات كلٌّ على حدة. وقد ينجح تغيير في المزوّد في كل اختباراته ثم يُعطّل مستهلكاً، لأن تلك الاختبارات لا تعرف ما يقرؤه المستهلك. ومن دون اختبار العقد لا يظهر العطل إلا حين يعمل الطرفان معاً: بعد أيام في بيئة اختبارات E2E، أو في الإنتاج حيث تواصل الإصدارات القديمة من تطبيق الجوال استدعاء الواجهة.

وبالنسبة إلى المختبِر، يجيب اختبار العقد عن سؤال إصدار محدد: هل سيعمل إصدار المزوّد هذا مع كل إصدار مستهلك سيلتقي به؟ وإن لم يعمل، سمّى المستهلك والتفاعل والحقل.

المستهلكون والمزوّدون والعقد

يصف مارتن فاولر (Martin Fowler) اختبار العقد بأنه فحص يتأكد أن بديل الاختبار (Test Double)، أي الـStub أو الـMock الذي تستخدمه اختبارات أخرى مكان خدمة ما، ما زال يتصرف كالخدمة الحقيقية. ويطبّق اختبار العقد الفكرة على الطرفين: يُختبر كلٌّ منهما مقابل العقد المكتوب نفسه.

في واجهة HTTP يحدّد كل تفاعل في العقد ما يلي:

  • الطلب: الطريقة (Method) والمسار ومعاملات الاستعلام والترويسات المهمة والجسم.
  • الاستجابة المتوقعة: رمز الحالة والترويسات وحقول الجسم مع أنواعها أو أنماطها.
  • حالة المزوّد (Provider State): البيانات التي يجب أن توجد ليكون للتفاعل معنى، مثل «الطلب ORD-1042 موجود».

اختبار العقد المدفوع بالمستهلك

في اختبار العقد المدفوع بالمستهلك (Consumer-Driven Contract Testing) يكتب المستهلكون العقد من خلال اختباراتهم، فيعرف المزوّد بالضبط أيّ الحقول يستخدمها كل مستهلك، ويغيّر ما سواها بحرية. والكلفة أن على كل فريق مستهلك أن يكتب هذه الاختبارات ويصونها، وعلى المزوّد أن يشغّلها؛ لذا يناسب الخدماتِ داخل مؤسسة واحدة، لا واجهةً عامة مستهلكوها مجهولون.

وتعمل Pact، وهي أداة مفتوحة المصدر لهذا الأسلوب، في خمس خطوات (ملف العقد الذي تنتجه معروض أدناه):

  • اختبار المستهلك: تستدعي شيفرة العميل مزوّداً محاكى (Mock Provider) يجيب كما يصرّح الاختبار، فيثبت الاختبار أن المستهلك يتعامل مع تلك الاستجابة تعاملاً صحيحاً.
  • ملف العقد: يكتب التشغيل كل تفاعل في ملف Pact: الطلبات التي يرسلها المستهلك وحقول الاستجابة التي يتوقعها اختباره، وينبغي ألا تكون إلا الحقول التي يقرؤها فعلاً.
  • رفع العقد (Publish): يخزّن وسيطٌ (Broker) العقدَ مع رقم إصدار المستهلك.
  • تحقق المزوّد (Provider Verification): يهيّئ خط أنابيب المزوّد كل حالة مزوّد، ويعيد إرسال كل طلب إلى المزوّد الحقيقي، ويقارن الاستجابة بالمتوقعة، بالنوع أو بالنمط حين لا تهمّ القيمة بعينها؛ والحقول الإضافية مسموحة افتراضياً.
  • فحص النشر (Deployment Check): قبل أن يُنشر أيّ من الطرفين، يؤكد الوسيط أن التحقق نجح بين هذا الإصدار والإصدارات الموجودة في البيئة المستهدفة (الأمر can-i-deploy في Pact).
اختبار العقد المدفوع بالمستهلك لواجهة الطلبات: اختبار تطبيق الجوال مقابل بديل محاكاة يكتب العقد، والوسيط يتيحه، وخط أنابيب واجهة الطلبات يعيد تشغيله قبل فحص النشر.
  1. خط أنابيب المستهلك: mobile-app

    يُختبر مقابل بديل محاكاة، لا مقابل واجهة الطلبات الحقيقية

    1. 1اختبار المستهلكشيفرة العميل تستدعي مزوّداً محاكى يجيب كما صرّح الاختبار
    2. 2كتابة العقدالطلبات المرسلة والحقول المقروءة فقطGET /orders/ORD-1042 → 200 {id, status, total}
    3. 3رفع العقد إلى الوسيطيُخزَّن مع إصدار المستهلك
  2. خط أنابيب المزوّد: orders-api

    يُتحقق منه مقابل العقد، دون التطبيق

    1. 4تهيئة حالة المزوّدبيانات الاختبار التي يحتاجها التفاعلorder ORD-1042 exists and is shipped
    2. 5إعادة إرسال الطلب ومطابقة الاستجابةمقابل المزوّد الحقيقي، بالنوع أو بالنمط

      فرع: إعادة تسمية total ← غياب $.total: فشل التحققفشل

    3. 6فحص النشرcan-i-deployلا تُنشر إلا إصدارات نجح التحقق بينهابوابة

الأسماء من Pact: ملف العقد يُسمّى Pact والفحص can-i-deploy؛ وتسمّي أدوات أخرى الخطوات نفسها بأسماء مختلفة.

{
  "consumer": { "name": "mobile-app" },
  "provider": { "name": "orders-api" },
  "interactions": [
    {
      "description": "a request for an existing order",
      "providerStates": [{ "name": "order ORD-1042 exists and is shipped" }],
      "request": { "method": "GET", "path": "/orders/ORD-1042" },
      "response": {
        "status": 200,
        "headers": { "Content-Type": "application/json" },
        "body": { "id": "ORD-1042", "status": "shipped", "total": 149.5 },
        "matchingRules": {
          "body": {
            "$.id": { "matchers": [{ "match": "regex", "regex": "^ORD-[0-9]+$" }] },
            "$.total": { "matchers": [{ "match": "type" }] }
          }
        }
      }
    }
  ],
  "metadata": { "pactSpecification": { "version": "3.0.0" } }
}

جرّبها: مقارنة عقود الاستجابات

اختبار العقد المبني على المواصفة باستخدام OpenAPI

هنا يكون مستند OpenAPI الخاص بالمزوّد هو العقد، وتسمّي بعض الفرق ذلك اختبار العقد المدفوع بالمزوّد. فالفحص الذي تعلّمته في درس التحقق من مخطط API يجري على كل استجابة في اختبارات المزوّد، بما فيها الأخطاء، ويبني المستهلكون بدائل المحاكاة من المستند نفسه.

ونقطته العمياء: إذا تغيّرت الشيفرة والمستند معاً، بقي المزوّد مطابقاً لمستنده. لذلك تُقارَن كل نسخة جديدة من المستند أيضاً بنسخة الإنتاج، وتُعلَّم التغييرات الكاسرة (Breaking Changes):

  • كاسر: حذف عملية أو حقل استجابة أو إعادة تسميته، وتغيير نوع، وجعل حقل اختياري في الطلب إلزامياً، ورفض قيم في الطلب كانت مقبولة من قبل.
  • آمن عادةً: إضافة عملية أو حقل اختياري في الطلب أو حقل في الاستجابة، إذا كان المستهلكون يتجاهلون الحقول المجهولة.
  • يحتاج إلى قرار: قيمة enum جديدة أو null في استجابة؛ فالعميل الذي لا يتعامل إلا مع القيم المعروفة قد يتعطّل.
  • الحدّ: يبيّن المستند ما يقدّمه المزوّد، لا ما يستخدمه كل مستهلك منه، فقد لا يمسّ تغييرٌ «كاسر» أحداً.

جرّبها: مدقّق عقود API جرّبها: مدقّق OpenAPI

مثال تطبيقي: إعادة تسمية حقل في واجهة الطلبات

لواجهة الطلبات مستهلكان لهما عقدان: تطبيق الجوال يقرأ id وstatus وtotal، وخدمة التوصيل تقرأ id وstatus وdeliveryCity. ويعيد إصدارٌ جديد تسمية total إلى grandTotal، ويحوّل createdAt من تاريخ نصي إلى رقم، ويضيف حقلاً اختيارياً giftMessage. وقد عُدِّل مستند OpenAPI أيضاً، فينجح الفحص مقابله.

  • مقارنة المستندين تُعلّم تغييرين كاسرين: حذف total وتغيير نوع createdAt.
  • يفشل تحقق المزوّد لعقد تطبيق الجوال عند $.total، وينجح عقد خدمة التوصيل لأنها لا تستخدم أياً من الحقلين.
  • لا يقرأ أيّ عقد createdAt، فتغيير نوعه آمن للمستهلكَين المعروفَين، لا لمستهلكين خارج الوسيط.
  • يوقف فحص النشر الإصدار. فيعيد الفريق total وgrandTotal معاً، ويعلّم total بأنه deprecated، ويحذفه حين لا يحتاجه أيّ إصدار مدعوم من التطبيق.
  • أما تشغيل E2E فما كان ليُظهر إعادة التسمية إلا بعد نشر الخدمتين، في صورة شاشة طلب بلا مبلغ.

جرّبها: مقارنة JSON جرّبها: عارض OpenAPI

ما يكشفه اختبار العقد مقارنةً باختبار E2E

يقود الاختبار من البداية إلى النهاية (E2E) رحلةً عبر كل الخدمات الحقيقية، فلا يكشف الواجهة غير المتوافقة إلا بعد نشر كل الخدمات معاً، وهو بطيء، ويجب تتبّع إخفاقه عبر واجهات كثيرة. أما اختبار العقد فيفحص واجهة واحدة في ثوانٍ، داخل خط أنابيب كل طرف، ويسمّي المستهلك والتفاعل والحقل.

  • يكشف قبل النشر: الحقول المحذوفة أو المعاد تسميتها أو المتغيّر نوعها، وتغيّر المسارات والطرق ورموز الحالة والترويسات، والطلبات التي لم يعد المزوّد يقبلها.
  • لا يكشف القيم الخاطئة وقواعد العمل والمعنى: فالمجموع المحسوب خطأً، أو المبلغ المرسل بالريال حيث يُنتظر بالهللة (درس اختبار التكامل)، يبقى من النوع الصحيح.
  • لا يكشف الإعداد والنشر: عنوان URL أساسي خاطئ، أو بيانات اعتماد ناقصة، أو شهادة منتهية الصلاحية.
  • لا يكشف ما ليس في أي عقد، مثل قيمة enum أو خطأ لا تنتجه أيّ حالة مزوّد.

أخطاء شائعة

  • كتابة العقد من الاستجابة الكاملة للمزوّد بدلاً مما يقرؤه المستهلك، فيُفشل كل تغيير غير ضارّ في المزوّد عملية التحقق.
  • مطابقة القيم بدقة حيث لا يهمّ إلا النوع (المعرّفات المولّدة والطوابع الزمنية)، أو مطابقة النوع وحده حيث تهمّ القيمة (رموز الحالة وقيم enum).
  • كتابة عقود لاستجابات النجاح وحدها؛ فاستجابات 404 و401 وأخطاء التحقق والقوائم الفارغة هي أكثر ما يُسيء العملاء التعامل معه.
  • تشغيل تحقق المزوّد دون ربط النشر بنتيجته، فتصل إصدارات غير متوافقة إلى الإنتاج.

من منظور ضمان الجودة

يبدأ العمل على العقود أثناء تنقيح المتطلبات (Refinement): اسأل عن كل تغيير في الواجهة من يستهلك نقطة النهاية، وأيّ الحقول يقرأ، وهل التغيير كاسر. وخريطة لمستهلكي الواجهات التي تختبرها تجيب عن ذلك وتُظهر العقود الغائبة. وراجع العقود كما تراجع حالات الاختبار: هل تغطي الأخطاء والقوائم الفارغة والحقول الاختيارية والقابلة لـnull؟ وهل تستخدم حالات المزوّد بيانات واقعية؟

وفي خط الأنابيب تأكّد أن العقود تُرفع إلى الوسيط، وأن التحقق يعمل مع كل تغيير في المزوّد، وأن فحص النشر قادر على إيقاف الإصدار. وعندما يفشل التحقق، سجّل الإصدارين والتفاعل ومسار الحقل والقيمتين المتوقعة والفعلية، ثم اسأل هل كان التغيير مقصوداً: إن كان كذلك فخطّط لإطلاق منسَّق، وإلا فأبلغ عن انحدار في المزوّد.

تمرين عملي

احكم على أربعة تغييرات في واجهة مقابل عقدَي مستهلكَين

المطلوب

يخطط فريق customers-api لأربعة تغييرات في GET /customers/{id}، وهي واجهة يستهلكها loyalty-app وsms-service بموجب عقدين (انظر المادة). حدّد ما يُبلغ عنه كل أسلوب، وما ينبغي للفريق فعله.

المادة
customers-api   GET /customers/{id}

الاستجابة اليوم (200):
  { "id": "C-77", "name": "Sara Al-Harbi", "phone": "+966500000001",
    "tier": "gold", "createdAt": "2026-03-01T09:30:00Z" }

عقود المستهلكين (الحقول التي يقرؤها كل منهما):
  loyalty-app   id (string), name (string), tier ("standard" | "gold")
  sms-service   id (string), phone (string)
  حالة المزوّد في العقدين: العميل C-77 موجود (الفئة "gold")

التغييرات المخطط لها في الإصدار القادم:
  1  إضافة حقل اختياري "email" (string)
  2  إعادة تسمية "phone" إلى "mobile"
  3  السماح بقيمة فئة جديدة "platinum"
  4  تغيير "createdAt" من نص بصيغة ISO 8601 إلى رقم (ثوانٍ بتوقيت Unix)
الخطوات
  1. صنّف كل تغيير كما تصنّفه مقارنة مستند OpenAPI القديم بالجديد: كاسر، أو آمن عادةً، أو يحتاج إلى قرار.
  2. حدّد لكل تغيير أيّ مستهلك يفشل تحقق عقده، إن وُجد.
  3. علّم المواضع التي يختلف فيها الجوابان، واشرح السبب.
  4. اقترح إطلاقاً آمناً لكل تغيير يُعطّل مستهلكاً.
إظهار الإجابة النموذجية
الإجابة النموذجية

التغيير 1 (email): آمن عادةً؛ وينجح التحقق للعقدين، إذ لا يذكره أيّ منهما، والحقول الإضافية في الاستجابة مسموحة افتراضياً. ويتفق الأسلوبان هنا.

التغيير 2 (phone إلى mobile): كاسر؛ يفشل sms-service عند $.phone وينجح loyalty-app. أعِد الحقلين معاً، وعلّم phone بأنه متقادم (Deprecated)، ودَع sms-service تنتقل وترفع عقداً جديداً، ثم احذف phone حين لا يحتاجه أيّ إصدار منشور من sms-service.

التغيير 3 (platinum): قيمة enum جديدة في الاستجابة تحتاج إلى قرار، وتنجح كل عمليات التحقق لأن حالة المزوّد تعيد عميلاً من الفئة gold. لكن loyalty-app لا يقبل إلا standard وgold، فقد يتعطّل مع أول عميل بلاتيني، ولا يُظهر ذلك أيّ من الفحصين. اسأل فريق التطبيق، وانشر إصداراً من loyalty-app يتعامل مع platinum (أو مع أي فئة مجهولة)، وأضِف إلى عقده حالة مزوّد بعميل بلاتيني، ولا تسمح للواجهة بإعادة القيمة الجديدة إلا بعد ذلك. ولن يُظهر تشغيل E2E المشكلة إلا إذا احتوت بياناته على عميل بلاتيني.

التغيير 4 (createdAt رقماً): كاسر في المستند، لكن كل عمليات التحقق تنجح لأن أيّ عقد لا يقرؤه: آمن لهذين المستهلكَين، وكاسر لأي مستهلك خارج الوسيط. وإن كان من المحتمل وجود مستهلكين كهؤلاء، فأضِف الرقم في حقل جديد واترك createdAt كما هو.

أسئلة شائعة

هل أحتاج إلى Pact لأمارس اختبار العقد؟

لا. Pact أداة مفتوحة المصدر مخصّصة للعقود المدفوعة بالمستهلك، أما اختبار العقد المبني على المواصفة فلا يحتاج إلا إلى مستند OpenAPI ومدقّق. والمبدأ أهم من الأداة: اختبر كل طرف مقابل عقد مشترك له إصدارات، واجعل النتيجة شرطاً للنشر.

من يكتب العقود المدفوعة بالمستهلك: المطورون أم المختبِرون؟

يكتب مطورو المستهلك عادةً اختبارات المستهلك داخل شيفرته، لأنها اختبارات لشيفرة العميل. ويملك فريق المزوّد التحقق وحالات المزوّد. ويراجع المختبِر أيّ التفاعلات مغطاة (الأخطاء والقوائم الفارغة والحقول الاختيارية)، ويتأكد أن حالات المزوّد تستخدم بيانات واقعية، وأن فحص النشر موجود في خط الأنابيب.

هل تحلّ اختبارات العقد محلّ اختبارات E2E؟

جزئياً. فهي تحلّ محلّ اختبارات E2E التي لا غرض لها إلا إثبات أن خدمتين ما زالتا تتواصلان. واحتفظ بعدد قليل من رحلات E2E للمسارات الحرجة، وللإعداد والنشر، ولقواعد العمل الممتدة عبر الخدمات، وهي أمور لا تراها العقود.

هل يصلح اختبار العقد مع واجهة طرف ثالث؟

لا تصلح الصيغة المدفوعة بالمستهلك، لأنها تتطلب أن يشغّل المزوّد عقدك، وطرف ثالث كبوابة الدفع لن يفعل ذلك. استخدم الصيغة المبنية على المواصفة: افحص استجابات بيئته التجريبية (Sandbox) مقابل مستند OpenAPI المنشور إن وُجد، وشغّل بانتظام بعض الاختبارات على تلك البيئة لتتأكد أن بدائل المحاكاة لديك ما زالت تطابق الخدمة الحقيقية، وهذا هو المعنى الأصلي لاختبار العقد عند فاولر.

المراجع

  • Contract Test (Martin Fowler)martinfowler.com · 2011اختبارات العقود: فحوص دورية تتأكد من أن الاستدعاءات الموجّهة إلى بديل الاختبار تعيد النتائج نفسها التي تعيدها الخدمة الخارجية الحقيقية.
  • Introduction — Pact DocsPact Foundationاختبار العقود الموجّه بالمستهلك باستخدام Pact: يُختبر كل تطبيق بمعزل عن غيره مقابل عقد يتولّد أثناء تشغيل اختبارات المستهلك.
  • OpenAPI SpecificationOpenAPI Initiativeالمعيار لوصف واجهات REST: المسارات والعمليات والمعاملات والمخططات والاستجابات وأنظمة الأمان.
  • The Practical Test Pyramid (Ham Vocke)martinfowler.com · 2018شرح واسع الاستشهاد لكيفية الموازنة بين اختبارات الوحدة والتكامل والعقود والواجهة في محفظة الاختبارات الآلية.

تدرّب بأدوات متقن

التحقق من مطابقة زوج طلب/استجابة API لعقد OpenAPI الخاص به: المسار والطريقة والمعاملات والجسم ومخطط الاستجابة.

التحقق

مقارنة استجابة API فعلية بعقد متوقَّع (مثال أو مخطط) والإبلاغ عن الفروق الكاسرة وغير الكاسرة.

التحقق

التحقق من مستندات OpenAPI 3.x وSwagger 2.0 للكشف عن الأخطاء البنيوية ومشكلات أفضل الممارسات.

الويب

عرض مواصفة OpenAPI 3.x / Swagger 2.0 (بصيغة JSON أو YAML) كتوثيق قابل للتصفح لنقاط النهاية.

الويب

مقارنة مستندي JSON دلالياً وإبراز القيم المضافة والمحذوفة والمتغيرة.

JSON والبيانات

للتعمّق أكثر

تقدّمك في هذا الموضوع

جزء من مسار تعلم ضمان الجودة