أذكرها وكأنها البارحة. كانت الساعة تقارب الثانية صباحاً، وفنجان القهوة الثالث بجانبي لم يعد له أي تأثير. كنا على وشك إطلاق ميزة جديدة انتظرها المستخدمون بفارغ الصبر. الأدرينالين في أوجه، وفريق الواجهة الأمامية (Frontend) يضغط زر النشر على بيئة الاختبار النهائية (Staging)، وفجأة… صمت مطبق في قناة التواصل، يليه رسالة من “سليم”، قائد فريق الواجهة الأمامية: “يا جماعة الخير، الواجهة الخلفية (Backend) بترجع بيانات غلط! كل الشاشات عنا ضربت!”.
في فريق الواجهة الخلفية، نظرنا إلى بعضنا البعض. “مستحيل!”، قلت لـ “خالد”، زميلي في الفريق. “الكود شغال 100% عنا، والاختبارات كلها ناجحة. أكيد المشكلة من عندكم، يمكن بتعملوا parsing للـ JSON بشكل خاطئ”.
وهنا بدأت المعركة التي يعرفها كل مطور: جحيم اللوم المتبادل. هم يقولون “أنتم غيرتم في الـ API بدون ما تخبرونا”، ونحن نقول “الـ API كما هي في التوثيق، المشكلة عندكم”. ساعات طويلة ضاعت في نقاشات بيزنطية، ومحاولات يائسة لتحديد مصدر الخلل، وفي النهاية، بعد تدقيق ممل، اكتشفنا أننا قمنا بتغيير اسم حقل من userId إلى user_id. تغيير بسيط، لكنه كان كفيلاً بنسف عمل فريق كامل.
في تلك الليلة، ونحن نصلح المشكلة على عجل، قلت في نفسي: “لازم يكون في حل أفضل من هيك. لازم يكون في طريقة تمنع هاي المشاكل قبل ما تصير أصلاً”. وكان الحل هو ما يُعرف بـ “الاختبار القائم على العقود” أو Contract Testing.
ما هو “الاختبار القائم على العقود” (Contract Testing)؟
ببساطة، تخيل أن فريق الواجهة الخلفية (المزوّد – Provider) وفريق الواجهة الأمامية (المستهلك – Consumer) يوقعان على “عقد” رقمي. هذا العقد لا يصف كل تفاصيل منطق العمل، بل يصف فقط شكل التوقعات المتبادلة بينهما.
المستهلك (الواجهة الأمامية) يقول في العقد: “أنا سأرسل لك طلباً إلى المسار /api/users/123، وأتوقع منك رداً بحالة 200 OK، وأن يحتوي الرد على جسم JSON فيه حقل اسمه user_id من نوع رقم، وحقل name من نوع نص”.
هذا العقد يصبح هو الحكم بين الطرفين. بدلاً من الاعتماد على توثيق قديم أو رسائل Slack، يصبح لدينا ملف مُولَّد من الكود، يضمن أن أي تغيير يكسر هذا الاتفاق سيتم اكتشافه تلقائياً وفوراً.
باختصار، الاختبار القائم على العقود لا يختبر “هل الخدمة تعمل بشكل صحيح؟”، بل يختبر “هل ما زالت الخدمة تفي بوعودها للخدمات الأخرى التي تعتمد عليها؟”.
آلية العمل: كيف يتم الاختبار خطوة بخطوة؟
الحكاية وما فيها تتم على مرحلتين: مرحلة المستهلك الذي يكتب العقد، ومرحلة المزوّد الذي يتحقق من التزامه بالعقد.
المرحلة الأولى: جانب المستهلك (Consumer) يحدد توقعاته
يبدأ كل شيء من عند المستهلك (تطبيق الويب، تطبيق الموبايل، أو حتى خدمة أخرى). يقوم فريق المستهلك بكتابة اختبار يحدد تماماً ما يحتاجه من الواجهة الخلفية.
في هذا الاختبار، يقوم المستهلك بمحاكاة (Mock) الواجهة الخلفية، ويُعرّف الطلب الذي سيرسله، والاستجابة التي يتوقعها.
عند تشغيل هذا الاختبار بنجاح، تقوم أداة الاختبار (مثل Pact.js) بتوليد ملف “عقد” (بصيغة JSON)، يحتوي على كل هذه التوقعات.
مثال عملي باستخدام Pact.js في الواجهة الأمامية:
لنفترض أن الواجهة الأمامية تريد جلب بيانات مستخدم. الكود التالي يوضح كيف يمكن تعريف العقد:
// consumer-test.js
import { Pact } from '@pact-foundation/pact';
import { Matchers } from '@pact-foundation/pact';
import { fetchUser } from './api'; // دالة تقوم بطلب البيانات
const provider = new Pact({
consumer: 'WebApp', // اسم المستهلك
provider: 'UserService', // اسم المزوّد
port: 1234,
});
describe('API Pact test', () => {
beforeAll(() => provider.setup()); // إعداد الخادم الوهمي
afterEach(() => provider.verify()); // التحقق من التفاعلات
afterAll(() => provider.finalize()); // إنشاء ملف العقد
it('should return user data for a given ID', async () => {
// 1. تعريف التوقعات (العقد)
await provider.addInteraction({
state: 'a user with ID 1 exists',
uponReceiving: 'a request for user 1',
withRequest: {
method: 'GET',
path: '/users/1',
},
willRespondWith: {
status: 200,
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: {
// هنا نستخدم Matchers لزيادة مرونة الاختبار
user_id: Matchers.integer(1),
name: Matchers.string('Abu Omar'),
email: Matchers.email('abu.omar@example.com'),
},
},
});
// 2. تشغيل الكود الفعلي الذي يقوم بالطلب
const user = await fetchUser(1); // دالة fetchUser ستتصل بالخادم الوهمي على port 1234
// 3. التأكد من أن الكود يتعامل مع الاستجابة بشكل صحيح
expect(user.name).toBe('Abu Omar');
});
});
بعد تشغيل هذا الاختبار، سيتم إنشاء ملف WebApp-UserService.json. هذا هو العقد الذي سنشاركه مع فريق الواجهة الخلفية.
المرحلة الثانية: جانب المزوّد (Provider) يتحقق من العقد
الآن، يأخذ فريق الواجهة الخلفية (المزوّد) ملف العقد الذي تم إنشاؤه. مهمتهم هي تشغيل اختبار يتحقق من أن خدمتهم الفعلية تفي بجميع التوقعات المذكورة في العقد.
تقوم أداة الاختبار (مثل Pact-JVM, Pact-Go) بالآتي:
- تقرأ ملف العقد.
- تشغّل خدمة الواجهة الخلفية الفعلية.
- تقوم بإرسال الطلبات المذكورة في العقد (
uponReceiving) إلى الخدمة الفعلية. - تقارن الاستجابة الفعلية من الخدمة مع الاستجابة المتوقعة في العقد (
willRespondWith).
إذا كان هناك أي اختلاف (تغيير اسم حقل، تغيير نوع بيانات، تغيير رمز الحالة)، سيفشل الاختبار فوراً في بيئة الواجهة الخلفية!
مثال توضيحي (مفهومي) لجانب المزوّد:
لا داعي لكود معقد هنا، لكن الفكرة كالتالي في اختبار الواجهة الخلفية:
// provider-test.java (conceptual)
@RunWith(PactRunner.class) // استخدم مشغّل Pact
@Provider("UserService") // اسم هذا المزوّد
@PactBroker(host = "pact-broker.mycompany.com") // مكان العقود (اختياري لكن موصى به)
public class UserServiceContractTest {
@TestTarget
public final Target target = new HttpTarget(8080); // استهدف خدمتك الفعلية
@State("a user with ID 1 exists") // تهيئة الحالة قبل الاختبار
public void user1Exists() {
// هنا يمكنك إضافة مستخدم وهمي ID=1 في قاعدة البيانات المؤقتة للاختبار
userRepository.save(new User(1, "Abu Omar", "abu.omar@example.com"));
}
}
عند تشغيل هذا الاختبار، ستقوم مكتبة Pact بإرسال طلب GET /users/1 إلى خدمتك التي تعمل على منفذ 8080، ثم ستتحقق من أن الاستجابة تطابق تماماً ما هو موجود في ملف العقد الذي ولّده فريق الواجهة الأمامية. لو قمت بتغيير user_id إلى userId في كود الواجهة الخلفية، سيفشل هذا الاختبار فوراً ويخبرك: “لقد كسرت العقد مع WebApp!”.
نصائح من خبرة أبو عمر: كيف تطبقها صح؟
تبني هذه التقنية يتطلب تغييراً في العقلية. إليك بعض النصائح العملية من تجربتي:
1. ابدأ بالبسيط والضروري
لا تحاول تغطية 100% من الـ API الخاص بك من اليوم الأول. ابدأ بنقاط النهاية (Endpoints) الأكثر أهمية أو تلك التي تتغير باستمرار وتسبب مشاكل. ركز على “Happy Path” في البداية، ثم أضف حالات الأخطاء تدريجياً.
2. العقد هو الحَكَم، وليس التوثيق
وثائق الـ API (مثل Swagger/OpenAPI) ممتازة، لكنها قد تصبح قديمة. العقد، لأنه يُولَّد ويُختبَر مع كل تغيير في الكود، هو “مصدر الحقيقة” الحي والدائم. اعتمد عليه لحل النزاعات. إذا كان التغيير يكسر العقد، فهو تغيير غير مقبول ويجب مناقشته أولاً.
3. لا تختبر منطق العمل (Business Logic)
تذكر، الاختبار القائم على العقود يختبر “شكل” الاستجابة وليس “محتواها” الدقيق. استخدم Matchers (كما في المثال أعلاه) للتحقق من أنواع البيانات وهيكلها، وليس للتحقق من قيم محددة. اختبار منطق العمل هو مهمة الاختبارات الوحدوية (Unit Tests) داخل كل خدمة.
4. اجعلها جزءاً من عملية الدمج والنشر المستمر (CI/CD)
القوة الحقيقية تظهر عندما تدمج هذه الاختبارات في الـ Pipeline الخاص بك.
- عندما يقوم مطور الواجهة الأمامية بعمل
push، يتم تشغيل اختبار المستهلك ونشر العقد الجديد. - عندما يقوم مطور الواجهة الخلفية بعمل
push، يتم سحب أحدث العقود وتشغيل اختبار المزوّد ضدها.
هذا يمنحك تغذية راجعة فورية ويمنع دمج أي كود يكسر التكامل بين الخدمات.
الخلاصة: وداعاً لحرب الاستنزاف 👋
بعد أن تبنينا الاختبار القائم على العقود، تغيرت ديناميكية العمل بالكامل. في المرة التالية التي قام فيها مطور واجهة خلفية بتغيير اسم حقل، لم تصلنا رسالة غاضبة في الثانية صباحاً. بدلاً من ذلك، فشل الـ Build الخاص به تلقائياً مع رسالة واضحة: “فشل التحقق من العقد مع المستهلك WebApp”.
لم يعد هناك لوم متبادل، بل أصبحت المحادثة: “يا شباب، أنا بحاجة لتغيير هذا الحقل، هل يؤثر عليكم؟ لنجدد العقد معاً”. تحولنا من فرق متناحرة إلى شركاء حقيقيين في بناء المنتج.
نصيحتي الأخيرة لك: توقفوا عن إضاعة الوقت في الجدال حول “من كسر ماذا؟”. دعوا الكود والعقود تتحدث عن نفسها. استثمروا وقتكم وطاقتكم في ما يهم حقاً: بناء برمجيات رائعة ومستقرة تسعد المستخدمين. والله يعطيكم العافية ويلا، شدّوا حيلكم يا شباب! 🚀