الأدلة التقنية

اختبار العقود Contract Testing بين الخدمات مع Pact

كيف تمنع خدمة من كسر خدمة أخرى تعتمد عليها قبل النشر، بعقد يكتبه المستهلك ويتحقق منه المزود ويحفظه Pact Broker.

اختبار العقود Contract Testing بين الخدمات مع Pact

فريق الكتالوج قرر أن اسم الحقل name في رد المنتج غير واضح، فغيره إلى title، وعدل اختباراته فنجحت كلها، وتولد ملف OpenAPI الجديد تلقائياً من الكود فأصبح يقول title أيضاً، ثم نشر الخدمة. وبعد دقائق أصبحت صفحة الدفع في تطبيق آخر يملكه فريق آخر تعرض «undefined» بدلاً من اسم المنتج. لم يخطئ أي فريق في اختباراته، فكل فريق اختبر كوده مقابل ما يعرفه، والذي لم يختبره أحد هو الاتفاق بين الخدمتين نفسه.

وقد عرفنا اختبار العقد Contract Test في مقال أنواع الاختبارات في فقرة واحدة، وذكرنا في دليل تصميم ال API أن إعادة تسمية حقل في الرد تغيير كاسر Breaking Change، وأن ملف OpenAPI يصف ما كتبه الكود. وهذا المقال يكمل من هناك بالتطبيق: كيف تكتب العقد من جهة المستهلك بأداة Pact، وكيف يتحقق منه المزود، وكيف يمنع Pact Broker على سيرفرك النسخة التي تكسر غيرك من الوصول إلى الإنتاج.

وسوف نناقش في هذا المقال ما يلي:

  • متى تحتاج اختبار العقود، ومتى يكفيك اختبار التكامل.
  • كيف يعمل Pact: اختبار المستهلك Consumer وملف العقد Pact File والتحقق عند المزود Provider Verification، وحالات المزود Provider States.
  • تشغيل Pact Broker على سيرفرك، ثم النسخ والفروع والبيئات وسؤال can-i-deploy، والعقود المعلقة Pending وWIP، وال Webhooks باختصار.
  • الفرق بين Pact والفحص المبني على مخطط OpenAPI، ومتى يناسب كل منهما.
  • تجربة عملية بخدمتين بلغتين مختلفتين، Node.js وPython، يعيد فيها المزود تسمية حقل فيرفض can-i-deploy نشره.

متى تحتاج اختبار العقود؟

اختبار العقود حل لمشكلة محددة، وهي أن خدمتين أو أكثر تنشران بشكل مستقل، ويملكها فرق مختلفة، ويعتمد بعضها على API بعض. فإذا كانت هذه حالتك فأمامك ثلاث طرق للتأكد من أن التغيير في خدمة لا يكسر الأخرى، ولكل طريقة مشكلتها:

  • بيئة مشتركة تشغل فيها كل الخدمات واختبارات E2E عليها: وهي الطريقة البسيطة التي تبدأ بها أغلب الفرق، لكنها بطيئة وهشة، وكل خدمة جديدة تزيدها بطئاً، والأسوأ أنها تختبر النسخ الموجودة في البيئة الآن، وليس النسخة التي ستنشرها أنت مع النسخ الموجودة في الإنتاج.
  • بديل وهمي Mock يكتبه المستهلك لخدمة المزود: سريع، لكنه يعكس ما يظنه المستهلك عن المزود، ولا يوجد ما يخبره عندما يتغير المزود، وهذه بالضبط القصة في أول المقال. وقد شرحنا البدائل Test Doubles في مقال أنواع الاختبارات.
  • العقد: المستهلك يسجل في اختباره الطلبات التي يرسلها والحقول التي يقرؤها، والمزود يشغل هذا التسجيل على كوده الحقيقي في خط CI الخاص به، وبهذا يصبح البديل الوهمي عند المستهلك مضموناً لأن المزود يثبت أنه يطابقه.

وقد يتساءل البعض: هل أحتاجه إذا كان عندي تطبيق واحد؟ والإجابة لا، فإذا كان الكود كله في مستودع واحد ويبنى وينشر مرة واحدة فاختبار التكامل يكفيك، والسبب أن المترجم والاختبارات يريانك الكسر قبل النشر. وكذلك إذا كان فريق واحد يملك الخدمتين وينشرهما معاً دائماً. أما عندما تنفصل الخدمات وتنشر كل واحدة في وقتها، وهو ما ناقشناه في Monolith أم Microservices؟، فاختبار العقود هو ما يجعل النشر المستقل آمناً فعلاً.

كيف يعمل Pact؟

Pact أداة مفتوحة المصدر لاختبار العقود بأسلوب يسمى العقود التي يقودها المستهلك Consumer-Driven Contracts، ولها مكتبات لأغلب اللغات تشترك في نواة مكتوبة بلغة Rust، لذلك يمكن أن يكون المستهلك بلغة والمزود بلغة أخرى كما في تجربتنا. والصورة التالية تبين الخطوات كاملة بالأرقام التي سوف تراها في التجربة:

ثلاث مناطق: فريق المستهلك checkout-web يشغل اختباره على Pact mock server فيتولد ملف pact، ثم ينشره إلى Pact Broker مع PostgreSQL، وفريق المزود catalog-api يجلب العقود ويجهز حالة المزود product 10 exists ويعيد الطلبات على الخدمة الحقيقية وينشر النتيجة، وفي ال Broker مصفوفة تبين أن web 1.4.0 نجح مع api 2.0.0 وفشل مع api 2.1.0، ثم سؤال can-i-deploy قبل النشر
سبع خطوات، وكل فريق يشغل خطواته في خط CI الخاص به دون أن يشغل خدمة الفريق الآخر
  1. اختبار المستهلك: تكتب في اختبار المستهلك الطلب الذي يرسله كودك والرد الذي يتوقعه، ثم يشغل Pact سيرفراً وهمياً Mock Server يرد بهذا الرد، ويشغل كود الكلاينت الحقيقي عليه. فإذا أرسل كودك طلباً مختلفاً عما كتبته فشل الاختبار.
  2. ملف العقد: عند نجاح الاختبار يكتب Pact كل التفاعلات Interactions في ملف JSON واحد لكل زوج من مستهلك ومزود، وهذا هو العقد.
  3. النشر إلى ال Broker: يرفع خط CI الخاص بالمستهلك الملف مع رقم نسخة المستهلك واسم الفرع.
  4. جلب العقود عند المزود: اختبار المزود يسأل ال Broker عن العقود التي يجب أن يحترمها، مثل عقد الفرع الرئيسي للمستهلك وعقد النسخة الموجودة في الإنتاج.
  5. التحقق Verification: لكل تفاعل يجهز المزود الحالة المطلوبة، ثم يرسل ال Verifier الطلب المسجل إلى الخدمة الحقيقية ويقارن ردها بما في العقد.
  6. نشر النتيجة: نجاح أو فشل، مرتبطاً برقم نسخة المزود.
  7. قرار النشر: قبل نشر أي من الخدمتين يسأل خط النشر ال Broker سؤال can-i-deploy.

ولاحظ أن العقد لا يحتوي كل حقول الرد، وإنما الحقول التي يقرؤها المستهلك فقط، وهذا مقصود. ففي التجربة يرجع المزود خمسة حقول، والعقد فيه حقلان فقط هما name وprice، لذلك يستطيع المزود أن يحذف in_stock أو يضيف حقلاً جديداً دون أن يفشل أي عقد، ولا يستطيع أن يغير name. وتوضح وثائق Pact أيضاً أن العقد ليس اختباراً وظيفياً للمزود، ففي صفحة Contract tests vs functional tests تنصح بأن يختبر العقد شكل الطلب والرد ومعناهما المشترك، وأن تبقى قواعد العمل في المزود، مثل حساب الخصم أو رفض طلب بلا رصيد، في اختبارات المزود نفسه، والسبب أن العقد الذي يحاول اختبار كل قواعد المزود يتحول إلى نسخة ثانية من اختباراته، ويكسر عند كل تغيير داخلي لا يهم المستهلك.

ولنفس السبب يستخدم العقد قواعد مطابقة Matchers بدلاً من القيم الحرفية، فالمستهلك في التجربة يكتب like('Arabic keyboard')، أي أنه يحتاج نصاً في هذا الحقل وليس هذا النص بالذات، وdecimal(49.5) أي رقماً عشرياً، وتفاصيل قواعد المطابقة في صفحة Matching. ولو كتب القيم حرفياً لأصبح العقد مرتبطاً ببيانات الاختبار عند المزود، وفشل كلما تغير اسم منتج في بيانات الاختبار.

حالات المزود Provider States

الطلب GET /products/10 لا يكون له معنى إلا إذا كان المنتج 10 موجوداً، والطلب GET /products/99 الذي يتوقع 404 لا ينجح إلا إذا لم يكن موجوداً. لذلك يكتب المستهلك قبل كل تفاعل جملة تصف الحالة المطلوبة، مثل given('product 10 exists')، ويكتب المزود لكل جملة دالة تجهز هذه الحالة في بياناته قبل إعادة الطلب، وهذا ما تسميه الوثائق Provider States. والجملة نص يتفق عليه الفريقان، وتطابق حرفياً، لذلك عاملها كجزء من العقد: لا تقم بتغيير صياغتها من جهة واحدة، والسبب أن المزود الذي لا يجد دالة لجملة جديدة لن يجهز البيانات، فيفشل التحقق لسبب لا علاقة له بالكود.

وفي Python يمرر ال Verifier لدالة الحالة معاملين باسمين محددين هما action وparameters، وaction قيمته setup قبل الطلب وteardown بعده. وسوف ترى في قسم الأخطاء الشائعة ما حدث في التجربة عندما سمينا المعامل الثاني params.

Pact Broker على سيرفرك

يمكن أن ينقل ملف العقد بين الفريقين بأي طريقة، مثل نسخه إلى مستودع المزود، لكن هذا يعمل مع مستهلك واحد ونسخة واحدة فقط. أما عندما يكون للمزود ثلاثة مستهلكين، ولكل مستهلك فرع رئيسي ونسخة في الإنتاج ونسخة في بيئة الاختبار، فأنت تحتاج مكاناً يعرف كل العقود وكل نتائج التحقق وأي نسخة موجودة في أي بيئة، وهذا هو Pact Broker. وهو تطبيق Ruby مفتوح المصدر برخصة MIT كما في مستودعه على GitHub، ويحفظ بياناته في PostgreSQL، وله Docker Image رسمي من مستودع pact-broker-docker برخصة MIT أيضاً، وأحدث وسم Tag للصورة وقت كتابة المقال هو 3.0.0-pactbroker2.121.2، أي نسخة 2.121.2 من ال Broker.

وفي ال Broker أربعة مفاهيم تحتاجها لتفهم مخرجات التجربة:

  • المشارك Pacticipant ونسخته Version: كل خدمة مشارك، وكل بناء لها نسخة، والأفضل أن يكون رقم النسخة هو ال Commit في Git، والسبب أن ال Broker يحتاج أن يميز كل بناء عن غيره، وفي التجربة استخدمنا أرقاماً مثل 1.4.0 و2.1.0 لأنها أسهل في القراءة.
  • الفرع Branch: ترسله مع كل نشر للعقد ومع كل نتيجة تحقق، ومنه يعرف ال Broker ما هو «الفرع الرئيسي» للمستهلك.
  • البيئة Environment وتسجيل النشر: بعد أن تنشر نسخة إلى الإنتاج يشغل خط النشر record-deployment فيعرف ال Broker أن هذه النسخة هي الموجودة الآن في الإنتاج، كما تشرح صفحة Recording deployments and releases. وال Broker في التجربة أنشأ بيئتين جاهزتين هما production وtest.
  • المصفوفة Matrix: جدول كل نسخ المستهلك مع كل نسخ المزود ونتيجة التحقق بينها، ومنه يجيب ال Broker على سؤال can-i-deploy.

وسؤال can-i-deploy هو ما يجعل كل ما سبق يمنع شيئاً فعلاً، فهو يأخذ النسخة التي تريد نشرها والبيئة، ويبحث في المصفوفة عن نتيجة تحقق ناجحة بينها وبين كل نسخة من الخدمات المرتبطة بها الموجودة في تلك البيئة الآن، ويرجع رمز خروج Exit Code صفراً إذا وجدها و1 إذا لم يجدها، لذلك يوضع خطوة في خط النشر قبل أمر النشر نفسه.

العقود المعلقة Pending والعقود قيد العمل WIP

هناك مشكلة تظهر بسرعة مع العقود التي يقودها المستهلك: المستهلك ينشر عقداً فيه تفاعل جديد لم يبنه المزود بعد، فيفشل خط CI عند المزود في أول تشغيل، ويتوقف نشر المزود بسبب ميزة لم يطلبها أحد منه بعد. وحل Pact لذلك هو العقود المعلقة Pending Pacts: العقد الذي لم ينجح التحقق منه أبداً من الفرع الرئيسي للمزود يعتبر معلقاً، وفشله تنشر نتيجته إلى ال Broker لكنه لا يفشل بناء المزود، وبعد أول نجاح يصبح العقد ملزماً، وأي فشل بعدها يفشل البناء. وسوف ترى هذه الرسالة بنفسها في أول تحقق في التجربة. ولاحظ أن نتيجة الفشل تبقى منشورة، لذلك يبقى المستهلك هو من لا يستطيع النشر، لأن can-i-deploy عنده سوف يقول إن المزود لا يدعم ما يحتاجه بعد.

أما العقود قيد العمل WIP Pacts فهي عقود من فروع المستهلك الأخرى، مثل فرع ميزة جديدة، يتحقق منها المزود إضافة إلى ما اختاره صراحة، حتى يعرف المستهلك مبكراً هل يدعم المزود ميزته، وتحتاج تحديد تاريخ تبدأ منه عبر includeWipPactsSince حتى لا يتحقق المزود فجأة من كل العقود القديمة، وتحتاج نسخة 2.60.0 أو أحدث من ال Broker بحسب الوثائق. وفي pact-python الإصدار 3 اسم الخيار include_wip_since.

ال Webhooks: من ينبه المزود؟

إذا نشر المستهلك عقداً جديداً يوم الخميس ولم يتغير كود المزود حتى الأحد، فلن يتحقق أحد من العقد حتى الأحد، ويبقى المستهلك ينتظر. لذلك يرسل ال Broker طلب HTTP إلى نظام CI عند أحداث معينة، وأهمها الحدث contract_requiring_verification_published الذي يطلق بناء تحقق عند المزود لنسخة العقد الجديدة فقط، وقد حل بحسب صفحة Webhooks محل الحدث الأقدم contract_content_changed منذ النسخة 2.82.0، وفي الوثائق قوالب جاهزة لأشهر أنظمة CI. ولم نشغل Webhook في التجربة، لأنها تحتاج نظام CI يستقبله، فإذا كنت تستخدم Gitea Actions فالفكرة نفسها: Webhook يطلق Workflow في مستودع المزود.

تشغيل ال Broker بأمان

ال Broker يعرف كل خدماتك وكل نقاط ال API التي يستخدمها كل منها، لذلك عامله كأي خدمة داخلية حساسة:

  • فعل المصادقة، ففي التجربة استخدمنا المصادقة الأساسية Basic Auth عبر متغيرات البيئة، والطلب بدونها يرجع 401، ومع وجود أكثر من فريق استخدم حساباً للقراءة وحساباً للكتابة لخطوط CI.
  • ضعه خلف Reverse Proxy مع TLS، ولا تقم بنشر منفذه مباشرة على الإنترنت.
  • كل بياناته في قاعدة PostgreSQL، لذلك نسخها الاحتياطي هو نسخ قاعدة البيانات، وقد شرحنا ذلك في النسخ الاحتياطي بقاعدة 3-2-1.
  • متغير PACT_BROKER_BASE_URLS يحدد العناوين التي يبني منها الروابط في صفحاته، وفي التجربة كتبنا فيه عنوانين، الاسم الداخلي في شبكة Docker والعنوان المحلي، والسبب أن الصفحة التي فتحناها من المتصفح بالعنوان المحلي ظهرت أولاً بلا أي تنسيق، لأن روابط ملفات CSS كانت مبنية على الاسم الداخلي الذي لا يعرفه المتصفح.

Pact أم الفحص المبني على OpenAPI؟

إذا كان عندك ملف OpenAPI فهناك نوعان من الأدوات قد تظن أنها تغنيك عن Pact. النوع الأول يختبر المزود مقابل وثيقته، مثل Schemathesis (رخصة MIT) الذي يولد طلبات من الوثيقة ويتأكد أن الردود تطابق المخطط Schema. والنوع الثاني يقارن نسختين من الوثيقة ويبحث عن التغييرات الكاسرة، مثل oasdiff (رخصة Apache-2.0). وقد شغلنا الأداتين على نفس التغيير في التجربة، والصورة التالية تلخص ما وجدته كل أداة:

جدول من ثلاثة أعمدة schemathesis وoasdiff breaking وPact verification وصفين: إعادة تسمية name إلى title نجح فيها schemathesis ب 64 حالة دون إنذار وأعطى oasdiff خطأ وفشل Pact بسبب غياب name، وحذف in_stock الذي لا يقرؤه أي مستهلك لم نشغل عليه schemathesis وأعطى oasdiff خطأ ونجح Pact
كل أداة تجيب على سؤال مختلف، والنتيجة من التجربة نفسها

والسبب واضح بعد أن تراه: Schemathesis قارن المزود بوثيقته، والوثيقة تولدت من الكود الجديد فأصبحت تقول title، فالمزود يطابق وثيقته تماماً، والكسر في مكان آخر لا تراه الوثيقة. أما oasdiff فرأى أن الحقل name حذف من الرد وأعطى خطأ، وهذا صحيح، لكنه أعطى الخطأ نفسه عندما حذفنا in_stock الذي لا يقرؤه أي مستهلك، لأنه لا يعرف من يستخدم ماذا، فإما أن تمنع كل حذف وتبقى الحقول القديمة إلى الأبد، أو تتجاهل الإنذار وتعود إلى المشكلة الأولى. وPact وحده فرق بين الحالتين، لأن المستهلك هو من كتب ما يقرؤه.

السؤالالفحص المبني على OpenAPIPact
ماذا يقارن؟المزود مع وثيقته، أو نسختين من الوثيقةالمزود مع ما يستخدمه كل مستهلك فعلاً
من يكتب الاختبار؟فريق المزود وحدهالمستهلك يكتب العقد، والمزود يشغل التحقق
إعادة تسمية حقل يقرؤه مستهلكلا يراها فحص المزود مع وثيقته، ويراها فحص الفرق بين نسختينيفشل التحقق، ويرفض can-i-deploy النشر
حذف حقل لا يقرؤه أحدفحص الفرق يعتبره كاسراًينجح، لأنه ليس في أي عقد
الكلاينت يرسل طلباً خاطئاًلا يعرف شيئاً عن كود الكلاينتيفشل اختبار المستهلك نفسه، لأن الكود الحقيقي يعمل على السيرفر الوهمي
واجهة عامة لمستهلكين لا تعرفهمالحل المناسب، فلا يوجد من يكتب لك عقداًغير مناسب، لأن كل مستهلك يجب أن يكتب اختباره وينشره
التكلفةقليلة، تعمل من الوثيقة الموجودةعمل على الفريقين، وخدمة Broker تديرها

والخلاصة العملية: إذا كان ال API عاماً أو يستهلكه عملاء لا تملك كودهم فالوثيقة وفحص الفرق بين نسخها مثل oasdiff في خط CI هو الأنسب، مع سياسة إصدارات واضحة كما في دليل تصميم ال API. وإذا كانت الخدمات داخلية وتملكها فرق تستطيع أن تكتب اختبارات المستهلك فPact أدق، ويسمح لك أن تغير ما لا يستخدمه أحد دون خوف. ولا يوجد ما يمنع الجمع بينهما، فوثيقة OpenAPI مفيدة للتوثيق في كل الأحوال.

ويجدر الإشارة إلى أن الخدمة التجارية PactFlow تقدم ما تسميه Bi-Directional Contract Testing، الذي يقارن عقد المستهلك بوثيقة OpenAPI التي يرفعها المزود بدلاً من تشغيل التحقق على كوده، وهذه الميزة ليست في ال Broker مفتوح المصدر. وبحسب صفحة الأسعار في 12 أكتوبر 2026 فالخطة المجانية Starter تشمل هذه الميزة مع تكاملين Integrations فقط، وخطة Team تبدأ من 115.42 دولاراً شهرياً بالدفع السنوي (1,385 دولاراً في السنة) لعشرة مستخدمين. ولم نختبرها لأنها تحتاج حساباً عندهم، وكل ما في هذا المقال يعمل بالأدوات مفتوحة المصدر على سيرفرك.

تجربة عملية: خدمتان بلغتين، وإعادة تسمية حقل يمنعها can-i-deploy

في هذه التجربة سوف نشغل Pact Broker مع PostgreSQL، ونكتب مستهلكاً اسمه checkout-web بلغة JavaScript على Node.js، ومزوداً اسمه catalog-api بلغة Python مع FastAPI، ثم نمر بالدورة كاملة: نشر العقد، والتحقق، وتسجيل النشر في الإنتاج، ثم إعادة تسمية الحقل ومحاولة نشرها، ثم الطريقة الصحيحة لإعادة التسمية. وكل الأوامر تعمل من مجلد التجربة، وكل ما تحتاجه هو Docker.

ملفات التجربة

lab/
├── compose.yaml                 # PostgreSQL + Pact Broker
├── broker.sh                    # pact-broker CLI in Docker, with the lab credentials
├── checkout-web/                # consumer (Node.js 24, @pact-foundation/pact 17.1.4)
│   ├── package.json
│   ├── src/catalog-client.js
│   └── test/catalog.pact.test.js
├── catalog-api/                 # provider (Python 3.13, FastAPI, pact-python 3.4.1)
│   ├── app.py                   # 2.0.0
│   ├── app-v2.1.py              # renames name to title
│   ├── app-v2.2.py              # title, and name kept for old consumers
│   ├── app-v2.0.1.py            # drops in_stock
│   ├── verify.py                # provider verification against the broker
│   ├── requirements.txt
│   └── Dockerfile, Dockerfile.v2.1, Dockerfile.v2.2, Dockerfile.v2.0.1
└── schema-check/serve.py        # serves one provider version for schemathesis

تشغيل ال Broker

ملف compose.yaml فيه خدمتان، والمنفذ مربوط على 127.0.0.1 فقط:

# Pact Broker for the contract-testing lab: PostgreSQL + the broker, reachable only from this machine.
name: lab-qa3-pact
services:
  db:
    image: postgres:18.6
    container_name: lab-qa3-pact-db
    environment:
      POSTGRES_USER: broker
      POSTGRES_PASSWORD: broker-lab-pass
      POSTGRES_DB: broker
    volumes:
      - lab-qa3-pact-db:/var/lib/postgresql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U broker -d broker"]
      interval: 3s
      retries: 20
  broker:
    image: pactfoundation/pact-broker:3.0.0-pactbroker2.121.2
    container_name: lab-qa3-pact-broker
    depends_on:
      db:
        condition: service_healthy
    environment:
      PACT_BROKER_DATABASE_URL: postgres://broker:broker-lab-pass@db/broker
      PACT_BROKER_BASIC_AUTH_USERNAME: ci
      PACT_BROKER_BASIC_AUTH_PASSWORD: ci-lab-pass
      PACT_BROKER_BASE_URLS: "http://lab-qa3-pact-broker:9292 http://127.0.0.1:19292"
      PACT_BROKER_LOG_LEVEL: WARN
    ports:
      - "127.0.0.1:19292:9292"
volumes:
  lab-qa3-pact-db:
    name: lab-qa3-pact-db
networks:
  default:
    name: lab-qa3-pact

وملف broker.sh يشغل أداة سطر الأوامر الرسمية من Docker Image اسمها pactfoundation/pact-cli داخل شبكة التجربة، حتى لا تكرر العنوان وبيانات الدخول في كل أمر:

#!/bin/sh
# Runs a pact-broker CLI command against the lab broker: ./broker.sh can-i-deploy --pacticipant ...
exec docker run --rm --name lab-qa3-pact-cli --network lab-qa3-pact \
  -e PACT_BROKER_BASE_URL=http://lab-qa3-pact-broker:9292 \
  -e PACT_BROKER_USERNAME=ci -e PACT_BROKER_PASSWORD=ci-lab-pass \
  pactfoundation/pact-cli:1.5.0.5 pact-broker "$@"

الآن نقوم بالتشغيل ثم نتأكد أن ال Broker يرفض الطلب بدون مصادقة، ونعرض البيئات الموجودة:

docker compose up -d
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:19292/
curl -s -I -u ci:ci-lab-pass http://127.0.0.1:19292/ | grep -i x-pact-broker-version
./broker.sh list-environments

والمخرج سوف يكون كما يلي:

401
x-pact-broker-version: 2.121.2
UUID                                 | NAME       | DISPLAY NAME | PRODUCTION
-------------------------------------|------------|--------------|-----------
399ccb68-d1ff-495c-aaa0-a2dda657c5fe | production | Production   | true
04aa87a5-1bc7-412c-bb09-961d10ff9d80 | test       | Test         | false

المستهلك: الكلاينت واختبار العقد

هذا هو الكود الوحيد في checkout-web الذي يتكلم مع خدمة الكتالوج، ولاحظ أنه يقرأ حقلين فقط:

// The only code in checkout-web that talks to catalog-api.
// It reads two fields from the product: name and price. Nothing else.
export class CatalogClient {
  constructor(baseUrl) {
    this.baseUrl = baseUrl;
  }

  async getProduct(id) {
    const res = await fetch(`${this.baseUrl}/products/${id}`, {
      headers: { Accept: 'application/json' },
    });
    if (res.status === 404) return null;
    if (!res.ok) throw new Error(`catalog-api returned ${res.status}`);
    const body = await res.json();
    return { name: body.name, price: body.price };
  }

  // What the checkout page prints for one line of the cart.
  async cartLine(id, qty) {
    const p = await this.getProduct(id);
    if (!p) return null;
    return `${p.name} x${qty} = ${(p.price * qty).toFixed(2)}`;
  }
}

واختبار العقد يستخدم node:test المدمج في Node.js مع مكتبة @pact-foundation/pact بالنسخة 17.1.4:

// Consumer test: describes what checkout-web needs from catalog-api, runs the client
// against a Pact mock server, and writes the contract to ../pacts/checkout-web-catalog-api.json
import { test } from 'node:test';
import assert from 'node:assert/strict';
import path from 'node:path';
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
import { CatalogClient } from '../src/catalog-client.js';

const { like, decimal } = MatchersV3;

const pact = new PactV3({
  consumer: 'checkout-web',
  provider: 'catalog-api',
  dir: path.resolve(process.cwd(), 'pacts'),
  logLevel: 'warn',
});

test('a product that exists gives a cart line', async () => {
  pact
    .given('product 10 exists')
    .uponReceiving('a request for product 10')
    .withRequest({ method: 'GET', path: '/products/10', headers: { Accept: 'application/json' } })
    .willRespondWith({
      status: 200,
      headers: { 'Content-Type': 'application/json' },
      body: { name: like('Arabic keyboard'), price: decimal(49.5) },
    });

  await pact.executeTest(async (mock) => {
    const line = await new CatalogClient(mock.url).cartLine(10, 2);
    assert.equal(line, 'Arabic keyboard x2 = 99.00');
  });
});

test('a product that does not exist gives null', async () => {
  pact
    .given('product 99 does not exist')
    .uponReceiving('a request for product 99')
    .withRequest({ method: 'GET', path: '/products/99', headers: { Accept: 'application/json' } })
    .willRespondWith({ status: 404 });

  await pact.executeTest(async (mock) => {
    assert.equal(await new CatalogClient(mock.url).cartLine(99, 1), null);
  });
});

في الإعداد أعلاه لاحظ التالي:

  • الاختبار يستدعي cartLine من الكود الحقيقي، وليس دالة مكتوبة داخل الاختبار، لذلك إذا غير أحد مسار الطلب في الكلاينت إلى /product/10 فسوف يفشل هذا الاختبار لأن السيرفر الوهمي لا يعرف هذا المسار.
  • الرد المتوقع فيه name وprice فقط، مع أن المزود يرجع خمسة حقول.
  • جملتا given هما ما سوف يبحث عنه المزود في دوال الحالات، حرفاً بحرف.

نقوم بتشغيل الاختبار في Container من Node.js 24:

cd checkout-web
docker run --rm --name lab-qa3-pact-consumer -v "$(pwd):/app" -w /app \
  node:24.21.0-bookworm-slim sh -c "npm install --no-audit --no-fund && npm run test:pact"

والمخرج سوف يكون كما يلي:

> [email protected] test:pact
> node --test test/*.test.js

✔ a product that exists gives a cart line (2181.513707ms)
2026-10-12T16:25:40.298532Z  WARN ThreadId(01) pact_models::pact: Note: Existing pact is an older specification version (V3), and will be upgraded
✔ a product that does not exist gives null (281.230698ms)
ℹ tests 2
ℹ suites 0
ℹ pass 2
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 37510.774657

ويمكنك تجاهل سطر WARN، فالملف الناتج pacts/checkout-web-catalog-api.json بصيغة V3 كما يظهر في آخره. وهذا جزء من الملف، ولاحظ قواعد المطابقة وحالة المزود:

{
  "consumer": { "name": "checkout-web" },
  "interactions": [
    {
      "description": "a request for product 10",
      "providerStates": [ { "name": "product 10 exists" } ],
      "request": {
        "headers": { "Accept": "application/json" },
        "method": "GET",
        "path": "/products/10"
      },
      "response": {
        "body": { "name": "Arabic keyboard", "price": 49.5 },
        "headers": { "Content-Type": "application/json" },
        "matchingRules": {
          "body": {
            "$.name": { "combine": "AND", "matchers": [ { "match": "type" } ] },
            "$.price": { "combine": "AND", "matchers": [ { "match": "decimal" } ] }
          }
        },
        "status": 200
      }
    }
  ],
  "metadata": {
    "pact-js": { "version": "17.1.4" },
    "pactRust": { "ffi": "0.5.8", "models": "1.3.16" },
    "pactSpecification": { "version": "3.0.0" }
  },
  "provider": { "name": "catalog-api" }
}

نشر العقد، وأول سؤال can-i-deploy

خط CI الخاص بالمستهلك ينشر العقد مع رقم النسخة والفرع:

docker run --rm --name lab-qa3-pact-cli --network lab-qa3-pact -v "$(pwd)/pacts:/pacts" \
  pactfoundation/pact-cli:1.5.0.5 pact-broker publish /pacts \
  --consumer-app-version 1.4.0 --branch main \
  --broker-base-url http://lab-qa3-pact-broker:9292 --broker-username ci --broker-password ci-lab-pass
Created checkout-web version 1.4.0 with branch main
Pact successfully published for checkout-web version 1.4.0 and provider catalog-api.
  View the published pact at http://lab-qa3-pact-broker:9292/pacts/provider/catalog-api/consumer/checkout-web/version/1.4.0
  Events detected: contract_published, contract_content_changed (first time untagged pact published)
  Next steps:
    * Add Pact verification tests to the catalog-api build. See https://docs.pact.io/go/provider_verification
    * Configure separate catalog-api pact verification build and webhook to trigger it when the pact content changes. See https://docs.pact.io/go/webhooks

وقبل أن يتحقق المزود من العقد، لنسأل هل يمكن نشر المستهلك إلى الإنتاج:

./broker.sh can-i-deploy --pacticipant checkout-web --version 1.4.0 --to-environment production; echo "exit code: $?"
Computer says no ¯\_(ツ)_/¯

CONSUMER     | C.VERSION | PROVIDER    | P.VERSION | SUCCESS? | RESULT#
-------------|-----------|-------------|-----------|----------|--------
checkout-web | 1.4.0     | catalog-api | ???       | ???      |

There is no verified pact between version 1.4.0 of checkout-web and a version of catalog-api currently in production (no version is currently recorded as deployed/released in this environment)
exit code: 1

والجواب «لا» منطقي، فلا توجد نسخة من المزود في الإنتاج، ولا يوجد أي تحقق بعد، وال Broker لا يفترض شيئاً لا يعرفه.

المزود: الخدمة والتحقق مع حالات المزود

خدمة catalog-api في نسختها 2.0.0 مسار واحد يرجع المنتج:

# catalog-api: the provider. One endpoint that checkout-web uses.
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

app = FastAPI(title="catalog-api", version="1.0.0")


class Product(BaseModel):
    id: int
    name: str
    price: float
    currency: str
    in_stock: int


# In the real service this is a database table; the lab keeps it in memory.
PRODUCTS: dict[int, Product] = {}


@app.get("/products/{product_id}", response_model=Product)
def get_product(product_id: int) -> Product:
    if product_id not in PRODUCTS:
        raise HTTPException(status_code=404, detail="product not found")
    return PRODUCTS[product_id]

وملف التحقق verify.py يشغل الخدمة نفسها في Thread، ويعرف دالة لكل حالة، ويجلب العقود من ال Broker وينشر النتيجة إليه، بمكتبة pact-python 3.4.1:

# Provider verification: start catalog-api, fetch the pacts it must honour from the broker,
# replay every interaction against it and publish the result back to the broker.
import os
import sys
import threading
import time

import uvicorn
from pact import Verifier

import app as catalog

PORT = 8000


def product_exists(action, parameters):
    # Provider state "product 10 exists": put exactly this product in the store.
    if action == "setup":
        # The label field is "name" up to 2.0.x and "title" from 2.1.0, as in the provider's own code.
        label = "title" if "title" in catalog.Product.model_fields else "name"
        catalog.PRODUCTS[10] = catalog.Product(
            **{"id": 10, label: "Arabic keyboard", "price": 49.5, "currency": "SAR", "in_stock": 7}
        )
    else:
        catalog.PRODUCTS.pop(10, None)


def product_missing(action, parameters):
    # Provider state "product 99 does not exist": make sure it is not there.
    catalog.PRODUCTS.pop(99, None)


def main() -> int:
    threading.Thread(
        target=uvicorn.run,
        args=(catalog.app,),
        kwargs={"host": "localhost", "port": PORT, "log_level": "warning"},
        daemon=True,
    ).start()
    time.sleep(1)

    verifier = (
        Verifier("catalog-api")
        .add_transport(url=f"http://localhost:{PORT}")
        .state_handler(
            {"product 10 exists": product_exists, "product 99 does not exist": product_missing},
            teardown=True,
        )
        .broker_source(os.environ["PACT_BROKER_BASE_URL"], selector=True)
        .consumer_version(main_branch=True)
        .consumer_version(deployed_or_released=True)
        .include_pending()
        .build()
        .set_publish_options(
            version=os.environ["PROVIDER_VERSION"], branch=os.environ.get("PROVIDER_BRANCH", "main")
        )
    )
    try:
        verifier.verify()
    except Exception as exc:  # the verifier raises when an interaction fails
        print(f"verification failed: {exc.__class__.__name__}", file=sys.stderr)
        return 1
    return 0


if __name__ == "__main__":
    sys.exit(main())

في الإعداد أعلاه لاحظ التالي:

  • السطران consumer_version(main_branch=True) وconsumer_version(deployed_or_released=True) هما محددات نسخ المستهلك Consumer Version Selectors، أي أن المزود يتحقق من عقد الفرع الرئيسي لكل مستهلك ومن عقد كل نسخة موجودة في أي بيئة، وهذا هو الإعداد الذي توصي به الوثائق، والسبب أن النسخة الموجودة في الإنتاج هي التي سوف تتعامل مع نسختك الجديدة فعلاً.
  • include_pending() يفعل العقود المعلقة التي شرحناها أعلاه.
  • بيانات الدخول إلى ال Broker لا تظهر في الكود، فالمكتبة تقرأ PACT_BROKER_USERNAME وPACT_BROKER_PASSWORD من متغيرات البيئة.
  • الخادم يعمل على localhost وليس 127.0.0.1، والسبب أن add_transport رفض في أول تشغيل العنوان الرقمي برسالة ValueError: Host mismatch: 127.0.0.1 != localhost، لأن ال Verifier يستخدم localhost افتراضياً.

نقوم ببناء Docker Image للمزود ثم نشغل التحقق للنسخة 2.0.0:

cd ../catalog-api
docker build -q -t lab-qa3-pact-provider:1 .
docker run --rm --name lab-qa3-pact-provider --network lab-qa3-pact \
  -e PACT_BROKER_BASE_URL=http://lab-qa3-pact-broker:9292 \
  -e PACT_BROKER_USERNAME=ci -e PACT_BROKER_PASSWORD=ci-lab-pass \
  -e PROVIDER_VERSION=2.0.0 lab-qa3-pact-provider:1 python verify.py; echo "exit code: $?"

والمخرج سوف يكون كما يلي، بعد حذف أسطر سجل طلبات الحالات POST /_pact/state:

The pact at http://lab-qa3-pact-broker:9292/pacts/provider/catalog-api/consumer/checkout-web/pact-version/28fcccfcdced2bd1ee999795f348f64f77ec29dc is being verified because the pact content belongs to the consumer version matching the following criterion:
    * latest version from the main branch 'main' (1.4.0)
This pact is in pending state for this version of catalog-api because a successful verification result for a version of catalog-api from branch 'main' has not yet been published. If this verification fails, it will not cause the overall build to fail. Read more at https://docs.pact.io/go/pending

Verifying a pact between checkout-web and catalog-api

  a request for product 10 (320ms loading, 144ms verification)
     Given product 10 exists
    returns a response which
      has status code 200 (OK)
      includes headers
        "Content-Type" with value "application/json" (OK)
      has a matching body (OK)

  a request for product 99 (320ms loading, 43ms verification)
     Given product 99 does not exist
    returns a response which
      has status code 404 (OK)
      has a matching body (OK)

This pact is no longer in pending state for any version of catalog-api from branch 'main', as a successful verification result has been published. If a verification for a version of catalog-api fails in the future, it will fail the build. Read more at https://docs.pact.io/go/pending
exit code: 0

ولاحظ الرسالتين في الأول والآخر، فالعقد كان معلقاً Pending لأن المزود لم يتحقق منه أبداً، فلو فشل لما فشل بناء المزود، وبعد هذا النجاح أصبح ملزماً، وأي فشل بعد الآن سوف يوقف البناء، وهذا ما سوف نراه بعد قليل.

النشر إلى الإنتاج وتسجيله

الآن نسأل عن المزود ثم نسجل نشره، ثم نسأل عن المستهلك ونسجل نشره، وهذا هو الترتيب الذي يعمل به خطا النشر في الواقع:

./broker.sh can-i-deploy --pacticipant catalog-api --version 2.0.0 --to-environment production; echo "exit code: $?"
./broker.sh record-deployment --pacticipant catalog-api --version 2.0.0 --environment production
./broker.sh can-i-deploy --pacticipant checkout-web --version 1.4.0 --to-environment production; echo "exit code: $?"
./broker.sh record-deployment --pacticipant checkout-web --version 1.4.0 --environment production
Computer says yes \o/

There are no missing dependencies
exit code: 0
Recorded deployment of catalog-api version 2.0.0 to production environment in the Pact Broker.
Computer says yes \o/

CONSUMER     | C.VERSION | PROVIDER    | P.VERSION | SUCCESS? | RESULT#
-------------|-----------|-------------|-----------|----------|--------
checkout-web | 1.4.0     | catalog-api | 2.0.0     | true     | 1

VERIFICATION RESULTS
--------------------
1. http://lab-qa3-pact-broker:9292/pacts/provider/catalog-api/consumer/checkout-web/pact-version/28fcccfcdced2bd1ee999795f348f64f77ec29dc/metadata/Y3ZuPTEuNC4w/verification-results/200 (success)

All required verification results are published and successful
exit code: 0
Recorded deployment of checkout-web version 1.4.0 to production environment in the Pact Broker.

ولاحظ الفرق بين الجوابين: المزود ليس له أي خدمة يعتمد عليها، فالجواب «There are no missing dependencies»، أما المستهلك فوجد ال Broker تحققاً ناجحاً بينه وبين النسخة 2.0.0 الموجودة الآن في الإنتاج.

نكسر العقد: المزود يعيد تسمية الحقل

الآن نعيد القصة التي بدأنا بها المقال، ففريق الكتالوج يغير name إلى title في النسخة 2.1.0، ولا يغير شيئاً آخر:

--- app.py
+++ app-v2.1.py
@@ -2,12 +2,12 @@
 from fastapi import FastAPI, HTTPException
 from pydantic import BaseModel

-app = FastAPI(title="catalog-api", version="1.0.0")
+app = FastAPI(title="catalog-api", version="2.1.0")


 class Product(BaseModel):
     id: int
-    name: str
+    title: str
     price: float
     currency: str
     in_stock: int
docker build -q -f Dockerfile.v2.1 -t lab-qa3-pact-provider:2.1 .
docker run --rm --name lab-qa3-pact-provider --network lab-qa3-pact \
  -e PACT_BROKER_BASE_URL=http://lab-qa3-pact-broker:9292 \
  -e PACT_BROKER_USERNAME=ci -e PACT_BROKER_PASSWORD=ci-lab-pass \
  -e PROVIDER_VERSION=2.1.0 lab-qa3-pact-provider:2.1 python verify.py; echo "exit code: $?"
The pact at http://lab-qa3-pact-broker:9292/pacts/provider/catalog-api/consumer/checkout-web/pact-version/28fcccfcdced2bd1ee999795f348f64f77ec29dc is being verified because the pact content belongs to the consumer versions matching the following criteria:
    * consumer version(s) currently deployed to production (1.4.0)
    * latest version from the main branch 'main' (1.4.0)
This pact has previously been successfully verified by a version of catalog-api from branch 'main'. If this verification fails, it will fail the build. Read more at https://docs.pact.io/go/pending

Verifying a pact between checkout-web and catalog-api

  a request for product 10 (281ms loading, 135ms verification)
     Given product 10 exists
    returns a response which
      has status code 200 (OK)
      includes headers
        "Content-Type" with value "application/json" (OK)
      has a matching body (FAILED)

  a request for product 99 (281ms loading, 154ms verification)
     Given product 99 does not exist
    returns a response which
      has status code 404 (OK)
      has a matching body (OK)


Failures:

1) Verifying a pact between checkout-web and catalog-api Given product 10 exists - a request for product 10
    1.1) has a matching body
           $ -> Actual map is missing the following keys: name

There were 1 pact failures

verification failed: RuntimeError
exit code: 1

ولاحظ أن ال Verifier ذكر هذه المرة سببين لاختيار العقد، فالنسخة 1.4.0 هي الفرع الرئيسي وهي أيضاً الموجودة في الإنتاج، والعقد لم يعد معلقاً فالفشل أوقف البناء برمز خروج 1، ورسالة الخطأ تحدد المشكلة بالضبط: الحقل name غير موجود. ولكن لنفرض أن الفريق تجاهل البناء الأحمر، أو أن التحقق يعمل في Job منفصل لا يمنع النشر، فماذا يقول ال Broker؟

./broker.sh can-i-deploy --pacticipant catalog-api --version 2.1.0 --to-environment production; echo "exit code: $?"
Computer says no ¯\_(ツ)_/¯

CONSUMER     | C.VERSION | PROVIDER    | P.VERSION | SUCCESS? | RESULT#
-------------|-----------|-------------|-----------|----------|--------
checkout-web | 1.4.0     | catalog-api | 2.1.0     | false    | 1

VERIFICATION RESULTS
--------------------
1. http://lab-qa3-pact-broker:9292/pacts/provider/catalog-api/consumer/checkout-web/pact-version/28fcccfcdced2bd1ee999795f348f64f77ec29dc/metadata/Y3ZuPTEuNC4w/verification-results/201 (failure)

The verification for the pact between the version of checkout-web currently in production (1.4.0) and version 2.1.0 of catalog-api failed
exit code: 1

وهذا هو المهم: خطوة can-i-deploy في خط النشر سوف تتوقف برمز 1، فلا تصل النسخة 2.1.0 إلى الإنتاج، والرسالة تخبر فريق الكتالوج باسم المستهلك ونسخته التي سوف تنكسر. والصورة التالية تبين ما حدث، والطريقة الصحيحة التي سوف نطبقها الآن:

checkout-web 1.4.0 في الإنتاج يقرأ name وprice، وأربع بطاقات: catalog-api 2.0.0 فيها name ونجح ونشر، و2.1.0 فيها title بدل name وفشل لغياب name ومنعتها بوابة can-i-deploy برمز خروج 1، و2.2.0 فيها title وname معاً ونجح وقال can-i-deploy نعم، وبطاقة أخيرة منقطة: لاحقاً ينشر web 1.5 الذي يقرأ title ثم يحذف name، ولم ننفذها في التجربة
كل نسخة من المزود تقارن بنسخة المستهلك الموجودة في الإنتاج، وليس بأحدث نسخة فقط

الطريقة الصحيحة: أضف أولاً، ثم احذف لاحقاً

إعادة تسمية حقل يقرؤه غيرك لا تتم في خطوة واحدة، وإنما في خطوتين يسميهما كثيرون التوسيع ثم التقليص Expand and Contract: النسخة 2.2.0 ترجع title الجديد وname القديم معاً، ثم يحدث المستهلك كوده ليقرأ title وينشر عقداً جديداً، وبعد أن تصل نسخته الجديدة إلى الإنتاج يحذف المزود name، وcan-i-deploy نفسه هو من يخبره متى أصبح الحذف آمناً.

--- app.py
+++ app-v2.2.py
@@ -1,17 +1,26 @@
 # catalog-api: the provider. One endpoint that checkout-web uses.
 from fastapi import FastAPI, HTTPException
-from pydantic import BaseModel
+from pydantic import BaseModel, Field, model_validator

-app = FastAPI(title="catalog-api", version="1.0.0")
+app = FastAPI(title="catalog-api", version="2.2.0")


 class Product(BaseModel):
     id: int
-    name: str
+    title: str
+    # Kept until every consumer reads "title" (expand, then contract later).
+    name: str = Field(deprecated=True)
     price: float
     currency: str
     in_stock: int

+    @model_validator(mode="before")
+    @classmethod
+    def fill_old_name(cls, data):
+        if isinstance(data, dict) and "name" not in data:
+            data = {**data, "name": data.get("title")}
+        return data
+
docker build -q -f Dockerfile.v2.2 -t lab-qa3-pact-provider:2.2 .
docker run --rm --name lab-qa3-pact-provider --network lab-qa3-pact \
  -e PACT_BROKER_BASE_URL=http://lab-qa3-pact-broker:9292 \
  -e PACT_BROKER_USERNAME=ci -e PACT_BROKER_PASSWORD=ci-lab-pass \
  -e PROVIDER_VERSION=2.2.0 lab-qa3-pact-provider:2.2 python verify.py; echo "exit code: $?"
cd .. && ./broker.sh can-i-deploy --pacticipant catalog-api --version 2.2.0 --to-environment production; echo "exit code: $?"
  a request for product 10 (371ms loading, 160ms verification)
     Given product 10 exists
    returns a response which
      has status code 200 (OK)
      includes headers
        "Content-Type" with value "application/json" (OK)
      has a matching body (OK)

  a request for product 99 (371ms loading, 45ms verification)
     Given product 99 does not exist
    returns a response which
      has status code 404 (OK)
      has a matching body (OK)

exit code: 0
Computer says yes \o/

CONSUMER     | C.VERSION | PROVIDER    | P.VERSION | SUCCESS? | RESULT#
-------------|-----------|-------------|-----------|----------|--------
checkout-web | 1.4.0     | catalog-api | 2.2.0     | true     | 1

VERIFICATION RESULTS
--------------------
1. http://lab-qa3-pact-broker:9292/pacts/provider/catalog-api/consumer/checkout-web/pact-version/28fcccfcdced2bd1ee999795f348f64f77ec29dc/metadata/Y3ZuPTEuNC4w/verification-results/202 (success)

All required verification results are published and successful
exit code: 0

ولم ننفذ الخطوة الأخيرة، أي نسخة المستهلك التي تقرأ title ثم حذف name، لأنها تكرار للدورة نفسها، لكن لاحظ ما سوف يحدث: ما دامت النسخة 1.4.0 مسجلة في الإنتاج فأي نسخة من المزود بلا name سوف تأخذ «لا»، وبعد أن يسجل نشر نسخة المستهلك الجديدة وتختفي 1.4.0 من الإنتاج تصبح الإجابة «نعم». والصفحة التالية من ال Broker تبين المصفوفة بعد كل ما سبق، ومعها نسخة رابعة 2.0.1 سوف نشرحها في القسم التالي:

صفحة The Matrix في Pact Broker: أربعة صفوف للمستهلك checkout-web 1.4.0 مع وسمي branch main وenv production، ونسخ المزود 2.0.1 و2.2.0 و2.1.0 و2.0.0، والتحقق أخضر لكل النسخ ما عدا 2.1.0 باللون الأحمر، و2.0.0 عليها وسم env production
صفحة المصفوفة في ال Broker الذي شغلناه، والأحمر هو النسخة التي أعادت تسمية الحقل

الفحص المبني على OpenAPI على نفس التغيير

لنرى ما قلناه في المقارنة بالأرقام، شغلنا النسختين 2.0.0 و2.1.0 من المزود في Containers، مع ملف schema-check/serve.py الذي يجعل كل رقم موجب منتجاً موجوداً حتى ترجع الطلبات المولدة ردوداً حقيقية وليس 404 فقط، ثم شغلنا Schemathesis 4.31.0 على النسخة التي أعادت التسمية:

docker run -d --name lab-qa3-pact-api21 --network lab-qa3-pact \
  -v "$(pwd)/catalog-api:/api" -v "$(pwd)/schema-check:/s" lab-qa3-pact-provider:1 python /s/serve.py /api/app-v2.1.py
docker run --rm --name lab-qa3-pact-st --network lab-qa3-pact schemathesis/schemathesis:4.31.0 \
  run http://lab-qa3-pact-api21:8000/openapi.json --checks response_schema_conformance --max-examples 50; echo "exit code: $?"
 ✅  Coverage (in 1.03s)
     ✅ 1 passed
 ✅  Fuzzing (in 7.32s)
     ✅ 1 passed
=================================== WARNINGS ===================================
Missing test data: 1 operation repeatedly returned 404 Not Found, preventing tests from reaching your API's core logic
  - GET /products/{product_id}
💡 Schemathesis found no operation that creates this data - create it outside the test run and supply the identifiers in your config file
=================================== SUMMARY ====================================
API Operations:
  Selected: 1/1
  Tested: 1
Test Phases:
  ⏭  Examples
  ✅ Coverage
  ✅ Fuzzing
  ⏭  Stateful (not applicable)
Warnings:
  ⚠️ Missing test data: 1 operation repeatedly returned 404 responses
...
Test cases:
  64 generated, 64 passed, 8 skipped
============================== 1 warning in 8.69s ==============================
exit code: 0

نجحت 64 حالة، والمزود الذي كسر المستهلك يطابق وثيقته تماماً. وحددنا الفحص response_schema_conformance وحده، والسبب أن الفحوص الافتراضية في أول تشغيل فشلت بخطأ «Undocumented HTTP status code» لأن FastAPI لا يذكر الرد 404 في الوثيقة المولدة، وهذه ملاحظة صحيحة على المزود، لكنها موجودة في النسختين ولا علاقة لها بإعادة التسمية. والتحذير عن 404 سببه أن الأداة تولد أرقاماً سالبة وصفراً أيضاً.

بعد ذلك أخذنا ملف OpenAPI من النسختين، وصنعنا نسخة ثالثة من وثيقة 2.0.0 حذفنا منها in_stock، وقارناها بأداة oasdiff v1.33.0:

docker run --rm -v "$(pwd)/schema-check:/s" tufin/oasdiff:v1.33.0 breaking /s/openapi-2.0.json /s/openapi-2.1.json
docker run --rm -v "$(pwd)/schema-check:/s" tufin/oasdiff:v1.33.0 breaking /s/openapi-2.0.json /s/openapi-2.0.1-no-stock.json
docker run --rm -v "$(pwd)/schema-check:/s" tufin/oasdiff:v1.33.0 breaking /s/openapi-2.0.json /s/openapi-2.0.1-no-stock.json --fail-on ERR >/dev/null; echo "exit code: $?"
1 changes: 1 error, 0 warning, 0 info
error	[response-required-property-removed] at /s/openapi-2.1.json
	in API GET /products/{product_id}
		removed the required property `name` from the response with the `200` status

1 changes: 1 error, 0 warning, 0 info
error	[response-required-property-removed] at /s/openapi-2.0.1-no-stock.json
	in API GET /products/{product_id}
		removed the required property `in_stock` from the response with the `200` status

exit code: 1

ولاحظ أن oasdiff يرجع رمز خروج صفراً افتراضياً حتى مع وجود خطأ، فإذا وضعته في خط CI فأضف --fail-on ERR. ثم بنينا النسخة 2.0.1 من المزود بلا in_stock وشغلنا عليها تحقق Pact:

This pact has previously been successfully verified by a version of catalog-api from branch 'main'. If this verification fails, it will fail the build. Read more at https://docs.pact.io/go/pending
      has a matching body (OK)
      has a matching body (OK)
exit code: 0

أي أن oasdiff اعتبر الحذفين كاسرين بنفس الدرجة، وPact فرق بينهما لأن checkout-web لا يقرأ in_stock.

أين تضع كل خطوة في خطوط CI؟

لم نشغل نظام CI في التجربة، لكن الأوامر نفسها هي ما يوضع في خطوط البناء والنشر، وبالترتيب التالي:

  1. بناء المستهلك: اختبارات الوحدة، ثم اختبار Pact، ثم pact-broker publish برقم ال Commit والفرع.
  2. نشر المستهلك: can-i-deploy --to-environment للبيئة المطلوبة، ثم النشر، ثم record-deployment.
  3. بناء المزود: اختبارات الوحدة، ثم verify.py بمحددي الفرع الرئيسي والنسخ المنشورة مع العقود المعلقة، وينشر النتيجة فقط من خط CI وليس من جهاز المطور، والسبب أن نتيجة من كود لم يدمج تضلل ال Broker.
  4. نشر المزود: مثل المستهلك تماماً، can-i-deploy ثم النشر ثم record-deployment.
  5. Webhook من ال Broker: عند حدث contract_requiring_verification_published يطلق بناء تحقق عند المزود لنسخة العقد الجديدة فقط.

وإذا كانت البيئات عندك أكثر من الإنتاج فطبق نفس الأوامر على كل بيئة، وقد ناقشنا تصميم البيئات في البيئات ومعاينة الميزات وGitOps.

إزالة التجربة

docker rm -f lab-qa3-pact-api20 lab-qa3-pact-api21
docker compose down -v
docker rmi lab-qa3-pact-provider:1 lab-qa3-pact-provider:2.1 lab-qa3-pact-provider:2.2 lab-qa3-pact-provider:2.0.1

أخطاء شائعة

  • اسم خاطئ لمعاملات دالة الحالة في pact-python: في أول تشغيل سمينا المعامل الثاني params بدلاً من parameters، فلم تمرر المكتبة القيمة، وفشلت كل دالة حالة بالخطأ TypeError: product_exists() missing 1 required positional argument: 'params'، والأسوأ أن ال Verifier أعاد طلب الحالة مرات كثيرة بفواصل تزداد في كل مرة، فبدا التحقق معلقاً أكثر من ثلاث دقائق حتى أوقفناه. فإذا بدا لك التحقق معلقاً فابحث في السجل عن POST /_pact/state برمز 500.
  • عقد فيه كل حقول الرد: المستهلك الذي ينسخ رداً كاملاً من المزود إلى اختباره يمنع المزود من حذف أي حقل، حتى الذي لا يستخدمه أحد، فاكتب فقط ما يقرؤه كودك.
  • قيم حرفية بدلاً من قواعد المطابقة: اسم المنتج الحرفي في العقد يجعل التحقق يفشل عندما تتغير بيانات الاختبار عند المزود، فاستخدم like وdecimal وغيرها إلا عندما تكون القيمة نفسها جزءاً من الاتفاق، مثل رمز حالة أو قيمة Enum.
  • اختبار قواعد العمل في العقد: العقد يتحقق من الشكل والمعنى المشترك، أما «الخصم 10% فوق 500 ريال» فمكانه اختبارات المزود.
  • نشر بدون record-deployment: إذا لم تسجل ما نشرته فلا يعرف ال Broker ما في الإنتاج، فيكون جواب can-i-deploy مبنياً على معلومات قديمة أو «لا» دائماً كما رأيت في أول سؤال.
  • التحقق من أحدث نسخة فقط: مزود يتحقق من عقد الفرع الرئيسي وحده قد يكسر النسخة الموجودة في الإنتاج إذا كانت أقدم، لذلك أضف deployed_or_released.
  • تجاهل رمز الخروج: can-i-deploy لا يمنع شيئاً إذا شغلته ولم يوقف خط النشر رمز 1، وكذلك oasdiff الذي يرجع صفراً افتراضياً.
  • نشر نتائج التحقق من جهاز المطور: اجعل النشر من خط CI فقط، وشغل التحقق محلياً بدون نشر.

قائمة تحقق

  • كل مستهلك داخلي لخدمة ينشر عقده من خط CI برقم ال Commit والفرع.
  • العقد فيه الحقول التي يقرؤها الكود فقط، مع قواعد مطابقة بدلاً من القيم الحرفية.
  • جمل حالات المزود متفق عليها بين الفريقين، ولكل جملة دالة عند المزود.
  • المزود يتحقق من الفرع الرئيسي ومن النسخ المنشورة، مع العقود المعلقة مفعلة.
  • can-i-deploy قبل كل نشر، وrecord-deployment بعده، لكل بيئة.
  • ال Broker خلف TLS ومصادقة، وقاعدة بياناته ضمن النسخ الاحتياطي.
  • إعادة تسمية أي حقل تتم بالتوسيع ثم التقليص، والحذف ينتظر «نعم» من can-i-deploy.
  • للواجهات العامة: وثيقة OpenAPI وفحص الفرق بين نسخها مع --fail-on في خط CI.

الخلاصة

وصلنا لنهاية الموضوع، وأهم ما فيه:

  • اختبار العقود يحل مشكلة الخدمات التي تنشرها فرق مختلفة بشكل مستقل، وفي التطبيق الواحد يكفيك اختبار التكامل.
  • في Pact يكتب المستهلك ما يرسله وما يقرؤه، ويشغل كوده الحقيقي على سيرفر وهمي، والمزود يعيد الطلبات نفسها على كوده الحقيقي بعد تجهيز حالات المزود.
  • Pact Broker مفتوح المصدر برخصة MIT ويعمل على سيرفرك مع PostgreSQL، ومع record-deployment وcan-i-deploy يتحول من مخزن للعقود إلى بوابة تمنع النشر.
  • في التجربة رفض can-i-deploy النسخة التي أعادت تسمية name برمز خروج 1، ووافق على النسخة التي أرجعت الحقلين معاً.
  • Schemathesis لم ير الكسر لأن الوثيقة تغيرت مع الكود، وoasdiff رآه لكنه اعتبر حذف حقل لا يستخدمه أحد كاسراً بنفس الدرجة، وPact فرق بين الحالتين.
  • للواجهات العامة استخدم وثيقة OpenAPI وفحص الفرق بين نسخها، وللخدمات الداخلية Pact، ولا يوجد ما يمنع الجمع بينهما.

وفي هذه المرحلة من خارطة الطريق مقالات أخرى عن قياس جودة الاختبارات نفسها وعن اختبار الواجهات، يمكنك أن تبدأ منها:

دليلأنواع الاختبارات للمبتدئين: من Unit إلى E2E مع Playwrightما الذي يكشفه كل نوع من الاختبارات وما الذي يفوته، وكم اختباراً تكتب من كل نوع، وأين يعمل كل منها في خط CI.دليلكيف تصمم API لا تضطر إلى كسره بعد سنةمبادئ تصميم API يتطور دون أن يكسر التطبيقات التي تعتمد عليه، وكيف تختار الشكل المناسب لنظامك.دليلاختبار الطفرات Mutation Testing: هل تكشف اختباراتك الأخطاء؟لماذا تمر الأخطاء من اختبارات تغطيتها 100%، وكيف يقيس اختبار الطفرات قوتها فعلاً ويعمل في خط CI دون أن يبطئه.دليلMonolith أم Microservices؟ قرار صادق للفرق الصغيرة والكبيرةمتى يكفيك برنامج واحد مقسم إلى وحدات، ومتى تستحق الخدمات المصغرة ثمنها، مع حالات موثقة وتجربة تقيس الفرق.

سجل التحديثات

  • أكتوبر 2026: كتابة المقال، وتنفيذ التجربة على Docker Engine 29.8.0 مع Pact Broker 2.121.2 (pactfoundation/pact-broker:3.0.0-pactbroker2.121.2) وPostgreSQL 18.6 وpact-cli 1.5.0 (pactfoundation/pact-cli:1.5.0.5)، والمستهلك على Node.js 24.21.0 مع @pact-foundation/pact 17.1.4، والمزود على Python 3.13.16 مع pact-python 3.4.1 وFastAPI 0.143.0 وuvicorn 0.54.0 وPydantic 2.14.1، وSchemathesis 4.31.0 وoasdiff v1.33.0.
نشرة عرب رووت | ArabRoot

معرفة تستحق مكاناً في بريدك.

مقالات مختارة وأدوات مفيدة وأفكار لمشروعك القادم، في رسالة واحدة كل أسبوع.

يمكنك إلغاء الاشتراك متى شئت. الخصوصية

تم استلام طلبك. افتح بريدك واضغط رابط التأكيد لإتمام الاشتراك.