> ## Content Index
> Fetch the complete content index at: https://arabroot.io/llms.txt
> Use this file to discover other available public pages before exploring further.

# اختبار العقود Contract Testing بين الخدمات مع Pact
- URL: https://arabroot.io/articles/اختبار-العقود-contract-testing-مع-pact/
- Published: 2026-10-13T00:15:00.000Z
- Updated: 2026-10-12T20:32:33.000Z
- Description: كيف تمنع خدمة من كسر خدمة أخرى تعتمد عليها قبل النشر، بعقد يكتبه المستهلك ويتحقق منه المزود ويحفظه Pact Broker.
- Author: فريق عرب رووت
- Tags: الأدلة التقنية, DevOps والتشغيل, DevOps وCI/CD, الاستضافة الذاتية

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

وقد عرفنا اختبار العقد Contract Test في [مقال أنواع الاختبارات](https://arabroot.io/articles/%D8%A7%D9%86%D9%88%D8%A7%D8%B9-%D8%A7%D9%84%D8%A7%D8%AE%D8%AA%D8%A8%D8%A7%D8%B1%D8%A7%D8%AA-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86-unit-%D9%88-integration-%D9%88-e2e/#contract) في فقرة واحدة، وذكرنا في [دليل تصميم ال API](https://arabroot.io/articles/%D8%AA%D8%B5%D9%85%D9%8A%D9%85-%D9%88%D8%A7%D8%AC%D9%87%D8%A7%D8%AA-api-rest-%D9%88-grpc-%D9%88-graphql/#versioning) أن إعادة تسمية حقل في الرد تغيير كاسر Breaking Change، وأن ملف [OpenAPI](https://arabroot.io/articles/%D8%AA%D8%B5%D9%85%D9%8A%D9%85-%D9%88%D8%A7%D8%AC%D9%87%D8%A7%D8%AA-api-rest-%D9%88-grpc-%D9%88-graphql/#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 في [مقال أنواع الاختبارات](https://arabroot.io/articles/%D8%A7%D9%86%D9%88%D8%A7%D8%B9-%D8%A7%D9%84%D8%A7%D8%AE%D8%AA%D8%A8%D8%A7%D8%B1%D8%A7%D8%AA-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86-unit-%D9%88-integration-%D9%88-e2e/#test-doubles).
- **العقد:** المستهلك يسجل في اختباره الطلبات التي يرسلها والحقول التي يقرؤها، والمزود يشغل هذا التسجيل على كوده الحقيقي في خط CI الخاص به، وبهذا يصبح البديل الوهمي عند المستهلك مضموناً لأن المزود يثبت أنه يطابقه.

وقد يتساءل البعض: هل أحتاجه إذا كان عندي تطبيق واحد؟ والإجابة لا، فإذا كان الكود كله في مستودع واحد ويبنى وينشر مرة واحدة فاختبار التكامل يكفيك، والسبب أن المترجم والاختبارات يريانك الكسر قبل النشر. وكذلك إذا كان فريق واحد يملك الخدمتين وينشرهما معاً دائماً. أما عندما تنفصل الخدمات وتنشر كل واحدة في وقتها، وهو ما ناقشناه في [Monolith أم Microservices؟](https://arabroot.io/articles/monolith-%D8%A7%D9%85-microservices-%D9%82%D8%B1%D8%A7%D8%B1-%D8%B5%D8%A7%D8%AF%D9%82-%D9%84%D9%84%D9%81%D8%B1%D9%82-%D8%A7%D9%84%D8%B5%D8%BA%D9%8A%D8%B1%D8%A9-%D9%88%D8%A7%D9%84%D9%83%D8%A8%D9%8A%D8%B1%D8%A9/)، فاختبار العقود هو ما يجعل النشر المستقل آمناً فعلاً.

## كيف يعمل Pact؟

[Pact](https://docs.pact.io/?ref=arabroot.io) أداة مفتوحة المصدر لاختبار العقود بأسلوب يسمى العقود التي يقودها المستهلك 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 قبل النشر](https://arabroot.io/content/images/2026/10/contract-testing-01-flow.webp)

سبع خطوات، وكل فريق يشغل خطواته في خط 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](https://docs.pact.io/consumer/contract%5Ftests%5Fnot%5Ffunctional%5Ftests?ref=arabroot.io) تنصح بأن يختبر العقد شكل الطلب والرد ومعناهما المشترك، وأن تبقى قواعد العمل في المزود، مثل حساب الخصم أو رفض طلب بلا رصيد، في اختبارات المزود نفسه، والسبب أن العقد الذي يحاول اختبار كل قواعد المزود يتحول إلى نسخة ثانية من اختباراته، ويكسر عند كل تغيير داخلي لا يهم المستهلك.

ولنفس السبب يستخدم العقد قواعد مطابقة Matchers بدلاً من القيم الحرفية، فالمستهلك في التجربة يكتب `like('Arabic keyboard')`، أي أنه يحتاج نصاً في هذا الحقل وليس هذا النص بالذات، و`decimal(49.5)` أي رقماً عشرياً، وتفاصيل قواعد المطابقة في صفحة [Matching](https://docs.pact.io/getting%5Fstarted/matching?ref=arabroot.io). ولو كتب القيم حرفياً لأصبح العقد مرتبطاً ببيانات الاختبار عند المزود، وفشل كلما تغير اسم منتج في بيانات الاختبار.

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

الطلب `GET /products/10` لا يكون له معنى إلا إذا كان المنتج 10 موجوداً، والطلب `GET /products/99` الذي يتوقع 404 لا ينجح إلا إذا لم يكن موجوداً. لذلك يكتب المستهلك قبل كل تفاعل جملة تصف الحالة المطلوبة، مثل `given('product 10 exists')`، ويكتب المزود لكل جملة دالة تجهز هذه الحالة في بياناته قبل إعادة الطلب، وهذا ما تسميه الوثائق [Provider States](https://docs.pact.io/getting%5Fstarted/provider%5Fstates?ref=arabroot.io). والجملة نص يتفق عليه الفريقان، وتطابق حرفياً، لذلك عاملها كجزء من العقد: لا تقم بتغيير صياغتها من جهة واحدة، والسبب أن المزود الذي لا يجد دالة لجملة جديدة لن يجهز البيانات، فيفشل التحقق لسبب لا علاقة له بالكود.

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

## Pact Broker على سيرفرك

يمكن أن ينقل ملف العقد بين الفريقين بأي طريقة، مثل نسخه إلى مستودع المزود، لكن هذا يعمل مع مستهلك واحد ونسخة واحدة فقط. أما عندما يكون للمزود ثلاثة مستهلكين، ولكل مستهلك فرع رئيسي ونسخة في الإنتاج ونسخة في بيئة الاختبار، فأنت تحتاج مكاناً يعرف كل العقود وكل نتائج التحقق وأي نسخة موجودة في أي بيئة، وهذا هو [Pact Broker](https://github.com/pact-foundation/pact%5Fbroker?ref=arabroot.io). وهو تطبيق Ruby مفتوح المصدر برخصة MIT كما في مستودعه على GitHub، ويحفظ بياناته في PostgreSQL، وله Docker Image رسمي من مستودع [pact-broker-docker](https://github.com/pact-foundation/pact-broker-docker?ref=arabroot.io) برخصة 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](https://docs.pact.io/pact%5Fbroker/recording%5Fdeployments%5Fand%5Freleases?ref=arabroot.io). وال Broker في التجربة أنشأ بيئتين جاهزتين هما `production` و`test`.
- **المصفوفة Matrix:** جدول كل نسخ المستهلك مع كل نسخ المزود ونتيجة التحقق بينها، ومنه يجيب ال Broker على سؤال `can-i-deploy`.

وسؤال [can-i-deploy](https://docs.pact.io/pact%5Fbroker/can%5Fi%5Fdeploy?ref=arabroot.io) هو ما يجعل كل ما سبق يمنع شيئاً فعلاً، فهو يأخذ النسخة التي تريد نشرها والبيئة، ويبحث في المصفوفة عن نتيجة تحقق ناجحة بينها وبين كل نسخة من الخدمات المرتبطة بها الموجودة في تلك البيئة الآن، ويرجع رمز خروج Exit Code صفراً إذا وجدها و1 إذا لم يجدها، لذلك يوضع خطوة في خط النشر قبل أمر النشر نفسه.

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

هناك مشكلة تظهر بسرعة مع العقود التي يقودها المستهلك: المستهلك ينشر عقداً فيه تفاعل جديد لم يبنه المزود بعد، فيفشل خط CI عند المزود في أول تشغيل، ويتوقف نشر المزود بسبب ميزة لم يطلبها أحد منه بعد. وحل Pact لذلك هو العقود المعلقة [Pending Pacts](https://docs.pact.io/pact%5Fbroker/advanced%5Ftopics/pending%5Fpacts?ref=arabroot.io): العقد الذي لم ينجح التحقق منه أبداً من الفرع الرئيسي للمزود يعتبر معلقاً، وفشله تنشر نتيجته إلى ال Broker لكنه لا يفشل بناء المزود، وبعد أول نجاح يصبح العقد ملزماً، وأي فشل بعدها يفشل البناء. وسوف ترى هذه الرسالة بنفسها في أول تحقق في التجربة. ولاحظ أن نتيجة الفشل تبقى منشورة، لذلك يبقى المستهلك هو من لا يستطيع النشر، لأن `can-i-deploy` عنده سوف يقول إن المزود لا يدعم ما يحتاجه بعد.

أما العقود قيد العمل [WIP Pacts](https://docs.pact.io/pact%5Fbroker/advanced%5Ftopics/wip%5Fpacts?ref=arabroot.io) فهي عقود من فروع المستهلك الأخرى، مثل فرع ميزة جديدة، يتحقق منها المزود إضافة إلى ما اختاره صراحة، حتى يعرف المستهلك مبكراً هل يدعم المزود ميزته، وتحتاج تحديد تاريخ تبدأ منه عبر `includeWipPactsSince` حتى لا يتحقق المزود فجأة من كل العقود القديمة، وتحتاج نسخة 2.60.0 أو أحدث من ال Broker بحسب الوثائق. وفي pact-python الإصدار 3 اسم الخيار `include_wip_since`.

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

إذا نشر المستهلك عقداً جديداً يوم الخميس ولم يتغير كود المزود حتى الأحد، فلن يتحقق أحد من العقد حتى الأحد، ويبقى المستهلك ينتظر. لذلك يرسل ال Broker طلب HTTP إلى نظام CI عند أحداث معينة، وأهمها الحدث `contract_requiring_verification_published` الذي يطلق بناء تحقق عند المزود لنسخة العقد الجديدة فقط، وقد حل بحسب صفحة [Webhooks](https://docs.pact.io/pact%5Fbroker/webhooks?ref=arabroot.io) محل الحدث الأقدم `contract_content_changed` منذ النسخة 2.82.0، وفي الوثائق [قوالب جاهزة](https://docs.pact.io/pact%5Fbroker/webhooks/template%5Flibrary?ref=arabroot.io) لأشهر أنظمة CI. ولم نشغل Webhook في التجربة، لأنها تحتاج نظام CI يستقبله، فإذا كنت تستخدم [Gitea Actions](https://arabroot.io/articles/gitea-actions-%D8%AA%D8%B4%D8%BA%D9%8A%D9%84-ci-cd-%D8%B9%D9%84%D9%89-%D8%AE%D8%A7%D8%AF%D9%85%D9%83/) فالفكرة نفسها: Webhook يطلق Workflow في مستودع المزود.

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

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

- فعل المصادقة، ففي التجربة استخدمنا المصادقة الأساسية Basic Auth عبر متغيرات البيئة، والطلب بدونها يرجع 401، ومع وجود أكثر من فريق استخدم حساباً للقراءة وحساباً للكتابة لخطوط CI.
- ضعه خلف Reverse Proxy مع TLS، ولا تقم بنشر منفذه مباشرة على الإنترنت.
- كل بياناته في قاعدة PostgreSQL، لذلك نسخها الاحتياطي هو نسخ قاعدة البيانات، وقد شرحنا ذلك في [النسخ الاحتياطي بقاعدة 3-2-1](https://arabroot.io/articles/%D8%A7%D9%84%D9%86%D8%B3%D8%AE-%D8%A7%D9%84%D8%A7%D8%AD%D8%AA%D9%8A%D8%A7%D8%B7%D9%8A-%D8%A8%D9%82%D8%A7%D8%B9%D8%AF%D8%A9-3-2-1-%D9%88%D8%A7%D8%AE%D8%AA%D8%A8%D8%A7%D8%B1-%D8%A7%D9%84%D8%A7%D8%B3%D8%AA%D8%B9%D8%A7%D8%AF%D8%A9/).
- متغير `PACT_BROKER_BASE_URLS` يحدد العناوين التي يبني منها الروابط في صفحاته، وفي التجربة كتبنا فيه عنوانين، الاسم الداخلي في شبكة Docker والعنوان المحلي، والسبب أن الصفحة التي فتحناها من المتصفح بالعنوان المحلي ظهرت أولاً بلا أي تنسيق، لأن روابط ملفات CSS كانت مبنية على الاسم الداخلي الذي لا يعرفه المتصفح.

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

إذا كان عندك ملف OpenAPI فهناك نوعان من الأدوات قد تظن أنها تغنيك عن Pact. النوع الأول يختبر المزود مقابل وثيقته، مثل [Schemathesis](https://github.com/schemathesis/schemathesis?ref=arabroot.io) (رخصة MIT) الذي يولد طلبات من الوثيقة ويتأكد أن الردود تطابق المخطط Schema. والنوع الثاني يقارن نسختين من الوثيقة ويبحث عن التغييرات الكاسرة، مثل [oasdiff](https://github.com/oasdiff/oasdiff?ref=arabroot.io) (رخصة Apache-2.0). وقد شغلنا الأداتين على نفس التغيير في التجربة، والصورة التالية تلخص ما وجدته كل أداة:

![جدول من ثلاثة أعمدة schemathesis وoasdiff breaking وPact verification وصفين: إعادة تسمية name إلى title نجح فيها schemathesis ب 64 حالة دون إنذار وأعطى oasdiff خطأ وفشل Pact بسبب غياب name، وحذف in_stock الذي لا يقرؤه أي مستهلك لم نشغل عليه schemathesis وأعطى oasdiff خطأ ونجح Pact](https://arabroot.io/content/images/2026/10/contract-testing-03-schema-vs-pact.webp)

كل أداة تجيب على سؤال مختلف، والنتيجة من التجربة نفسها

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

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

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

ويجدر الإشارة إلى أن الخدمة التجارية PactFlow تقدم ما تسميه [Bi-Directional Contract Testing](https://pactflow.io/bi-directional-contract-testing/?ref=arabroot.io)، الذي يقارن عقد المستهلك بوثيقة OpenAPI التي يرفعها المزود بدلاً من تشغيل التحقق على كوده، وهذه الميزة ليست في ال Broker مفتوح المصدر. وبحسب [صفحة الأسعار](https://pactflow.io/pricing/?ref=arabroot.io) في 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.

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

```text
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 فقط:

```yaml
# 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` داخل شبكة التجربة، حتى لا تكرر العنوان وبيانات الدخول في كل أمر:

```bash
#!/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 يرفض الطلب بدون مصادقة، ونعرض البيئات الموجودة:

```bash
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
```

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

```text
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` الذي يتكلم مع خدمة الكتالوج، ولاحظ أنه يقرأ حقلين فقط:

```javascript
// 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:

```javascript
// 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:

```bash
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"
```

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

```text
> checkout-web@1.0.0 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 كما يظهر في آخره. وهذا جزء من الملف، ولاحظ قواعد المطابقة وحالة المزود:

```json
{
  "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 الخاص بالمستهلك ينشر العقد مع رقم النسخة والفرع:

```bash
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
```

```text
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
```

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

```bash
./broker.sh can-i-deploy --pacticipant checkout-web --version 1.4.0 --to-environment production; echo "exit code: $?"
```

```text
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 مسار واحد يرجع المنتج:

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

```python
# 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](https://docs.pact.io/pact%5Fbroker/advanced%5Ftopics/consumer%5Fversion%5Fselectors?ref=arabroot.io)، أي أن المزود يتحقق من عقد الفرع الرئيسي لكل مستهلك ومن عقد كل نسخة موجودة في أي بيئة، وهذا هو الإعداد الذي توصي به الوثائق، والسبب أن النسخة الموجودة في الإنتاج هي التي سوف تتعامل مع نسختك الجديدة فعلاً.
- `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:

```bash
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`:

```text
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 لأن المزود لم يتحقق منه أبداً، فلو فشل لما فشل بناء المزود، وبعد هذا النجاح أصبح ملزماً، وأي فشل بعد الآن سوف يوقف البناء، وهذا ما سوف نراه بعد قليل.

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

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

```bash
./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
```

```text
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، ولا يغير شيئاً آخر:

```text
--- 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
```

```bash
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: $?"
```

```text
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؟

```bash
./broker.sh can-i-deploy --pacticipant catalog-api --version 2.1.0 --to-environment production; echo "exit code: $?"
```

```text
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، ولم ننفذها في التجربة](https://arabroot.io/content/images/2026/10/contract-testing-02-rename.webp)

كل نسخة من المزود تقارن بنسخة المستهلك الموجودة في الإنتاج، وليس بأحدث نسخة فقط

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

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

```text
--- 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
+
```

```bash
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: $?"
```

```text
  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](https://arabroot.io/content/images/2026/10/contract-testing-broker-matrix.webp)

صفحة المصفوفة في ال Broker الذي شغلناه، والأحمر هو النسخة التي أعادت تسمية الحقل

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

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

```bash
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: $?"
```

```text
 ✅  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:

```bash
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: $?"
```

```text
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:

```text
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](https://arabroot.io/articles/%D8%A7%D9%84%D8%A8%D9%8A%D8%A6%D8%A7%D8%AA-%D9%88%D9%85%D8%B9%D8%A7%D9%8A%D9%86%D8%A9-%D8%A7%D9%84%D9%85%D9%8A%D8%B2%D8%A7%D8%AA-%D9%88-gitops-%D9%84%D9%84%D9%81%D8%B1%D9%82/).

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

```bash
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، ولا يوجد ما يمنع الجمع بينهما.

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

[![](https://arabroot.io/content/images/2026/10/testing-types-cover-cover.webp)دليلأنواع الاختبارات للمبتدئين: من Unit إلى E2E مع Playwrightما الذي يكشفه كل نوع من الاختبارات وما الذي يفوته، وكم اختباراً تكتب من كل نوع، وأين يعمل كل منها في خط CI.](https://arabroot.io/articles/%D8%A7%D9%86%D9%88%D8%A7%D8%B9-%D8%A7%D9%84%D8%A7%D8%AE%D8%AA%D8%A8%D8%A7%D8%B1%D8%A7%D8%AA-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86-unit-%D9%88-integration-%D9%88-e2e/)[![](https://arabroot.io/content/images/2026/10/api-design-cover-cover.webp)دليلكيف تصمم API لا تضطر إلى كسره بعد سنةمبادئ تصميم API يتطور دون أن يكسر التطبيقات التي تعتمد عليه، وكيف تختار الشكل المناسب لنظامك.](https://arabroot.io/articles/%D8%AA%D8%B5%D9%85%D9%8A%D9%85-%D9%88%D8%A7%D8%AC%D9%87%D8%A7%D8%AA-api-rest-%D9%88-grpc-%D9%88-graphql/)[![](https://arabroot.io/content/images/2026/10/mutation-testing-cover-cover.webp)دليلاختبار الطفرات Mutation Testing: هل تكشف اختباراتك الأخطاء؟لماذا تمر الأخطاء من اختبارات تغطيتها 100%، وكيف يقيس اختبار الطفرات قوتها فعلاً ويعمل في خط CI دون أن يبطئه.](https://arabroot.io/articles/%D8%A7%D8%AE%D8%AA%D8%A8%D8%A7%D8%B1-%D8%A7%D9%84%D8%B7%D9%81%D8%B1%D8%A7%D8%AA-mutation-testing/)[![](https://arabroot.io/content/images/2026/10/monolith-microservices-cover-cover.webp)دليلMonolith أم Microservices؟ قرار صادق للفرق الصغيرة والكبيرةمتى يكفيك برنامج واحد مقسم إلى وحدات، ومتى تستحق الخدمات المصغرة ثمنها، مع حالات موثقة وتجربة تقيس الفرق.](https://arabroot.io/articles/monolith-%D8%A7%D9%85-microservices-%D9%82%D8%B1%D8%A7%D8%B1-%D8%B5%D8%A7%D8%AF%D9%82-%D9%84%D9%84%D9%81%D8%B1%D9%82-%D8%A7%D9%84%D8%B5%D8%BA%D9%8A%D8%B1%D8%A9-%D9%88%D8%A7%D9%84%D9%83%D8%A8%D9%8A%D8%B1%D8%A9/)

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

- أكتوبر 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.