تشغيل محاكاة واختبار API ذاتياً على VPS
شغّل WireMock لمحاكاة التبعيات وHurl لاختبار نقاط النهاية على VPS. خزّن stubs في Git وتقارير CI، واحمِ بيانات العملاء حتى بعد إعادة البناء.
وظيفتان تشتركان في مستودع واحد
محاكاة API واختبارها ذاتياً هما وظيفتان مختلفتان، والتعامل معهما كأنهما وظيفة واحدة يهدر أسبوعاً. يحل خادم المحاكاة محل تبعية لا يمكنك استدعاؤها من CI: مزود دفع، أو API لشريك، أو خدمة upstream تفرض حدوداً للمعدل، أو خدمة لم يطلقها فريق آخر بعد. يستدعي مشغّل اختبارات API نقاط النهاية الخاصة بك بترتيب ثابت، ويتحقق من الاستجابات، وينقل القيم من استجابة إلى الطلب التالي.
لا يوجد تداخل بين الوظيفتين. لا يحدد خادم المحاكاة أبداً ما إذا كانت النتيجة نجاحاً أو فشلاً. ولا يبدي مشغّل الاختبارات رأياً في الاستجابة التي يعيدها مزود الدفع عند رفض البطاقة. ينتهي الأمر بمعظم الفرق التي تستأجر خادماً بالفعل إلى تشغيل نسخة واحدة من كل منهما، مع تشغيلهما بواسطة ملف Docker Compose نفسه ومراجعتهما في طلب دمج واحد.
لماذا تُجرى محاكاة واجهة API واختبارها ذاتياً؟
بيانات الاختبار التي تستخدمها في fixtures تشبه بيانات الإنتاج. قد يكون جسم الطلب في اختبار API سجلاً حقيقياً لعميل، مع تغيير الاسم، أو من دون تغيير الاسم لأن أحداً لم يتحقق منه. أما stubs المسجّلة فأسوأ: إذ يخزّن تسجيل proxy كل ما أعاده upstream فعلياً، لذلك يحتوي مجلد stubs الناتج عن التسجيل على رموز وصول مباشرة وعناوين بريد إلكتروني للعملاء إلى أن يقرأ أحدهم كل ملف. في خدمة مستضافة، تتحول هذه البيانات إلى حادثة أمنية تخص طرفاً آخر وإلى إفصاح تتحمل مسؤوليته.
السبب الثاني هو إمكانية الوصول. لا يمكن الوصول إلى خدمة مرتبطة بعنوان خاص من hosted runner، ولذلك لا يمكن للاختبار أن يعمل أصلاً. لكل حل بديل تكلفة. نشر API على الإنترنت لاختبارها يلغي سبب إبقائها خاصة. أما tunnel أو نسخة staging عامة فهي نظام آخر يجب صيانته، كما أن نسخة staging تنحرف عن الإنتاج بين الإصدارات. يستدعي runner الموجود على الشبكة الخاصة نفسها الخدمة مباشرة ولا يحتاج إلى أي من ذلك. وهذه هي الحجة العملية وراء runner مستضاف ذاتياً لـ GitHub Actions.
أي خادم محاكاة مستضاف ذاتياً ينبغي أن تشغّل؟
يعمل كل خيار من هذه الخيارات كحاوية على خادم تملكه. السؤال المهم هو ما الذي يعتبره كل خيار مصدر الحقيقة، لأن ذلك يحدد ما إذا كانت إعادة إنشاء الحاوية لن تكلفك شيئاً أو ستستغرق بعد الظهر بأكمله.
- WireMock يحتفظ بكل stub كملف JSON في دليل
mappings/، ويضع أجسام الاستجابات الكبيرة في__files/. صورة الحاوية هيwiremock/wiremock، ودليلها الجذري داخل الحاوية هو/home/wiremock، كما تعمل أيضاً كـproxy للتسجيل. وجود الملفات على القرص يعني أن mock يُدار في git مثل أي شفرة أخرى. - Mockoon CLI يحتفظ بواجهة mock API كاملة في ملف بيانات JSON واحد. ثبّته باستخدام
npm install -g @mockoon/cliوشغّله باستخدامmockoon-cli start --data ./data-file.json، أو شغّل صورةmockoon/cliمع ربط ذلك الملف عبر bind mount. يحرّر تطبيق سطح المكتب الملف نفسه، لذلك يظل التصميم عبر واجهة رسومية وإيداع النتيجة في المستودع متوافقين. - MockServer يعمل من صورة
mockserver/mockserverويستمع على المنفذ 1080. تصل التوقعات عبر REST API الخاصة به، وهذا مفيد من شفرة الاختبار لكنه ينطوي على مخاطر عند النشر: يختفي أي توقع أُنشئ عبر طلب HTTP عند إعادة تشغيل الحاوية. استخدم ملف تهيئة JSON الخاص به للـstubs التي يُفترض أن تكون دائمة. - Prism ينشئ mock من مستند OpenAPI بدلاً من ملفات stub منفصلة. ثبّته باستخدام
npm install -g @stoplight/prism-cli، ثم شغّله باستخدامprism mock openapi.yaml. أضف-h 0.0.0.0داخل الحاوية، لأن Prism يرتبط افتراضياً بـlocalhost، ومن دون ذلك يتعذر الوصول إليه من خارج الحاوية. - Microcks هو الخيار الأكبر: يوفّر واجهة ويب تستورد مستندات OpenAPI ومجموعات Postman، ثم تعرضها كـmocks وتشغّل اختبارات العقود. يتطلب التثبيت الكامل MongoDB وKeycloak، إضافة إلى Kafka لميزاته غير المتزامنة. تجمع صورة
microcks-uberالشاملة MongoDB تعمل في الذاكرة، ويوثق المشروع أنها مناسبة للاستخدام المؤقت، لذلك تعامل مع كل ما تنشئه في تلك الواجهة على أنه مؤقت، واحتفظ بملفات المصدر في git.
أي مشغّل لاختبارات API ذاتية الاستضافة ينبغي أن تستخدم؟
المهمة هنا عبارة عن تسلسل: المصادقة، ثم إنشاء طلب، ثم قراءته مجدداً، ثم التحقق من تغيّر حالته. يتطلب ذلك التقاط قيمة من استجابة واحدة واستخدامها في الطلب التالي. الأداة التي لا تستطيع الاحتفاظ بالحالة بين الاستدعاءات هي أداة لفحص الصحة، وليست أداة لاختبار API.
- Hurl يشغّل ملفات نصية عادية تحتوي على طلبات HTTP من ملف ثنائي واحد. يستخرج قسم
[Captures]القيم من الاستجابة، ويتحقق قسم[Asserts]منها، بينما يحوّلها قسم--testإلى مشغّل اختبارات يعرض ملخصاً ويعيد رمز خروج. الإصدار 8.0.1 هو الإصدار الحالي في أغسطس 2026. - يشغّل Bruno CLI مجلداً يحتوي على ملفات
.bru. ثبّته باستخدامnpm install -g @usebruno/cli، ثم شغّله باستخدامbru run folder --env Local --reporter-junit results.xml. صُمّم تنسيق المجموعة ليكون عبارة عن ملفات نصية في دليل، لذلك تكون الفروقات سهلة القراءة أثناء المراجعة. - يشغّل Newman مجموعات Postman خارج Postman:
npm install -g newman، ثمnewman run collection.json -r cli,junit --reporter-junit-export results.xml. لكن المشكلة في التنسيق. المجموعة عبارة عن كائن JSON مُصدَّر واحد، لذلك تتم عملية التحرير في Postman، ويصبح الملف الموجود في git نسخة قديمة مع مرور الوقت. - Schemathesis نوع مختلف من الفحوصات. يقرأ مخطط OpenAPI وينشئ حالات اختبار تحاول إنتاج استجابات يقول مخططك إنها مستحيلة:
uvx schemathesis run https://your.api/openapi.json. يكتشف حالات التعطل ومخالفات العقد، لكنه لا يعرف شيئاً عن قواعد العمل لديك، لذلك يُستخدم بجانب مجموعة اختبارات مكتوبة بدلاً من أن يحل محلها. - خيار Hoppscotch المستضاف ذاتياً هو واجهة الويب، ويتطلب مثيل Postgres. افهم هذه المقايضة قبل تثبيته: تُخزَّن المجموعات في قاعدة بيانات، لا في مستودعك.
هناك أداة ينبغي تجنبها. لا يزال Step CI يظهر في قوائم الأدوات، كما أن تنسيق سير العمل الخاص به بصيغة YAML سهل القراءة، لكن المستودع تلقى آخر commit في أغسطس 2024. البرنامج الذي يقع بين CI وAPI لديك ليس مكاناً مناسباً لتشغيل شيفرة غير صيانتها مستمرة.
ضع خادم المحاكاة خلف الجدار الناري
يشغّل الإعداد أدناه WireMock بديلاً عن مزود دفع. إذا كان تنسيق ملف Compose جديداً عليك، يشرح Docker Compose على VPS أوامر دورة الحياة التي يفترضها هذا القسم.
services:
mock-payments:
image: wiremock/wiremock:3.13.2
command: ["--verbose"]
volumes:
- ./mocks/payments:/home/wiremock
ports:
- "127.0.0.1:8080:8080"
restart: unless-stoppedالبادئة 127.0.0.1: في تعريف المنفذ هي الجزء المهم. يؤدي استخدام 8080:8080 وحده إلى نشر خادم المحاكاة على كل الواجهات، بما فيها عنوان IP العام، ويظل قابلاً للوصول حتى عندما يمنع ufw ذلك المنفذ، لأن Docker يكتب قواعده الخاصة في سلسلة DOCKER ضمن iptables، ويجري تقييمها قبل قواعد INPUT الخاصة بـufw. اربط الخدمة بعنوان loopback بدلاً من ذلك، أو بعنوان واجهة خاصة، وعندها لا تقبل النواة الاتصال من خارج الخادم.
بعد ذلك، يوجّه خدمتك قيد الاختبار طلباتها إلى خادم المحاكاة. عندما تعمل الخدمة ضمن مشروع Compose نفسه، يكون عنوان URL الأساسي لخادم المحاكاة هو http://mock-payments:8080، لأن Compose يحل أسماء الخدمات على شبكته الخاصة. وعندما تعمل الخدمة على المضيف، يكون العنوان هو http://127.0.0.1:8080. اضبط ذلك عبر متغير بيئة، وليس داخل الشيفرة، وإلا قد يصل عنوان URL الاختبار إلى الإنتاج.
ضع القوالب الوهمية في ./mocks/payments/mappings/، بمعدل ملف JSON واحد لكل قالب.
{
"request": {
"method": "POST",
"urlPath": "/v1/charges",
"bodyPatterns": [{ "matchesJsonPath": "$.amount" }]
},
"response": {
"status": 201,
"headers": { "Content-Type": "application/json" },
"jsonBody": { "id": "ch_test_001", "status": "succeeded", "amount": 4200 }
}
}شغّل الخادم، ثم تحقق مما جرى تحميله فعلياً.
docker compose up -d --wait mock-payments
curl -fsS http://127.0.0.1:8080/__admin/mappingsينتظر --wait حتى يبلغ الحاوي حالة صحية، وهذا ممكن لأن صورة WireMock تتضمن HEALTHCHECK لنقطة النهاية /__admin/health. يعرض استدعاء mappings كل قالب وهمي قرأه الخادم. إذا كان قالب كتبته مفقوداً من هذه القائمة، فهذا يعني أنه لم يُحمّل قط. تحقق من وجود الملف ضمن mappings/ وليس في جذر المسار المركّب، وتحقق من صحة صيغة JSON.
عندما يصل طلب ولا يطابقه أي قالب وهمي، يجيب WireMock بالحالة 404، مع نص يبدأ بـRequest was not matched، ثم يعرض الفرق مقارنة بأقرب قالب موجود لديه. اقرأ هذا الفرق قبل تغيير أي شيء، لأنه يحدد الحقل المختلف بدقة. يكون هذا الحقل عادةً مساراً يحتوي على /v1/charge، بينما يحدد القالب القيمة /v1/charges.
اكتب الاختبار كسلسلة من الطلبات مع الاحتفاظ بالحالة بين الاستدعاءات
ملفات Hurl ملفات نصية عادية. ثبّت حزمة deb من إصدارات المشروع.
VERSION=8.0.1
curl --location --remote-name https://github.com/Orange-OpenSource/hurl/releases/download/$VERSION/hurl_${VERSION}_amd64.deb
sudo apt update && sudo apt install ./hurl_${VERSION}_amd64.debتوجد مجموعة اختبارات تختبر API الخاص بك مقابل الخادم الوهمي في tests/checkout.hurl.
POST {{base_url}}/orders
Content-Type: application/json
{
"sku": "ssd-1tb",
"amount": 4200
}
HTTP 201
[Captures]
order_id: jsonpath "$['id']"
GET {{base_url}}/orders/{{order_id}}
HTTP 200
[Asserts]
jsonpath "$.status" == "paid"
jsonpath "$.charge_id" == "ch_test_001"كتلة [Captures] هي ما يجعل هذا اختباراً لـAPI، وليس طلبين غير مرتبطين. تُقرأ order_id من الاستجابة الأولى، وتُدرج في عنوان URL للطلب الثاني. يمثل التأكيد على charge_id الهدف الأساسي من الاختبار: فهو يثبت أن خدمتك استدعت موفر الدفع وخزّنت الاستجابة التي أعادها، والقيمة التي يقارن بها هي القيمة التي كتبتها في إعداد WireMock الوهمي. يغطي ملف واحد الآن نصفي التدفق.
hurl --test --variable base_url=http://127.0.0.1:3000 \
--report-junit reports/junit.xml \
--report-json reports/json \
tests/يعرض التشغيل الناجح سطراً واحداً لكل ملف، ثم يعرض ملخصاً.
tests/checkout.hurl: Success (2 request(s) in 61 ms)
Executed files: 1
Executed requests: 2 (30.1/s)
Succeeded files: 1 (100.0%)
Failed files: 0 (0.0%)
Duration: 64 msيعرض الفشل error: Assert failure مع اسم الملف ورقم السطر، ثم يعرض القيمة التي حصل عليها مقابل القيمة المتوقعة، ويخرج hurl بحالة غير صفرية، لذلك تتوقف CI. إذا قرأت status القيمة pending بينما كنت تتوقع paid، فهذا يعني أن خدمتك لم تعالج استجابة الخادم الوهمي. اقرأ بعد ذلك سجل طلبات WireMock في /__admin/requests، فهو يوضح ما إذا كان الاستدعاء قد وصل إلى الخادم الوهمي أصلاً.
تشغيل المجموعة من CI runner الخاص بك
عند تسجيل runner على الخادم نفسه، يكون سير العمل قصيراً. الـrunner عبارة عن عملية عادية على المضيف، لذلك يجب تثبيت docker وhurl على ذلك المضيف. لا يرث runner أي شيء من صورة مستضافة.
name: api-tests
on: [push]
jobs:
hurl:
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
- name: Start the mock
run: docker compose up -d --wait mock-payments
- name: Run the suite
run: hurl --test --variable base_url=http://127.0.0.1:3000 --report-junit reports/junit.xml tests/
- name: Archive the reports
if: always()
run: install -d /srv/api-tests/reports/$GITHUB_SHA && cp -r reports/. /srv/api-tests/reports/$GITHUB_SHA/
- name: Stop the mock
if: always()
run: docker compose downوجود if: always() في خطوة الأرشفة مهم. من دونه، تتخطى العملية نسخ الملفات عند فشل تشغيل الاختبارات، فتفقد التقرير الذي أردت قراءته تحديداً. يجب أيضاً أن تكون النسخة خارج مساحة العمل، لأن runner ينظف مساحة العمل قبل المهمة التالية، وتُحذف التقارير معها.
احتفظ بالنتائج، وليس بنتيجة آخر تشغيل فقط
يجيب ملف JUnit XML واحد لكل commit عن سؤال واحد: هل نجح؟ لكنه لا يجيب عن وقت بدء تباطؤ نقطة نهاية، لأن لا أحد يقرأ هذه الملفات بعد التوقف عن فتحها. لمتابعة الاتجاه، أضف صفاً واحداً لكل تشغيل إلى قاعدة بيانات صغيرة على الخادم نفسه. يكفي جدول واحد يحتوي على SHA الخاص بـcommit، واسم الملف، وعدد حالات النجاح، وعدد حالات الفشل، والمدة. ويُعد SQLite في بيئة الإنتاج على VPS مكاناً مناسباً لتخزينها: ملف واحد، ومن دون عملية خادم، مع الاحتفاظ بكامل السجل ضمن النسخة الاحتياطية التي تنشئها بالفعل. حلّل مخرجات Hurl بتنسيق --report-json بدلاً من JUnit XML، لأنها التنسيق القابل للقراءة آلياً من بين التنسيقين.
ما الذي يجب أن يبقى بعد إعادة بناء الحاوية
تعريفات المحاكاة ومجموعات الاختبارات هي شيفرة مصدرية. يجب وضعها في مستودع بجانب الخدمة التي تصفها، وتغييرها في طلب السحب نفسه الذي يغيّر نقطة نهاية. أما الـstub الذي يُعدَّل عبر واجهة ويب، أو التوقع الذي يُرسل إلى MockServer عبر REST API أثناء التشغيل، فلا يوجد إلا في ذاكرة تلك الحاوية أو في قاعدة بيانات تلك الأداة. شغّل docker compose down وسيختفي، ولن يلاحظ أحد ذلك حتى يبدأ اختبار بالنجاح لسبب غير صحيح. إذا كانت مستودعاتك تعمل أيضاً على أجهزتك الخاصة، فإن خادم git مستضاف ذاتياً يُبقي ملفات الاختبار والخدمة داخل نطاق ثقة واحد.
إليك القواعد العملية. ثبّت وسوم الصور، لأن latest قد يغيّر طريقة مطابقة المحاكي للطلبات من دون أي تغيير في المستودع، ومن الصعب جداً ربط هذا الفشل بسببه. اربط أدلة الـstub بوضع القراءة فقط عندما لا تحتاج الأداة إلى الكتابة فيها. لا تضع أبداً ملفات stub الخاصة بالمحاكي في Docker volume مُسمّى، لأن وحدة التخزين تصبح عندها مصدر الحقيقة، وتصبح النسخة الموجودة في git خاطئة من دون تنبيه.
هناك قاعدة أخرى، وغالباً ما تفاجئ المستخدمين. إذا أنشأت ملفات stub بتسجيل حركة الشبكة الفعلية عبر proxy، فاقرأ كل ملف مُنشأ قبل إيداعه. يحتوي التسجيل على كل ما أعاده المصدر الأصلي، بما في ذلك bearer tokens وعناوين البريد الإلكتروني للعملاء. يؤدي إيداعه إلى إبقاء هذه البيانات في مستودعك بشكل دائم، لأن git يحتفظ بالمحتوى المحذوف في السجل.
FAQ
ما الفرق بين خادم محاكاة API ومشغّل اختبارات API؟
يستجيب خادم المحاكاة للطلبات. وهو يحل محل اعتماد لا يمكنك استدعاؤه من CI، ولا يبلّغ أبداً عن نجاح الاختبار أو فشله. يرسل مشغّل اختبارات API الطلبات إلى خدمتك، ويتحقق من الاستجابات، وينقل القيم من استدعاء إلى الاستدعاء التالي، وينتهي بحالة غير صفرية عند فشل أحد التحققات. يعالج كل منهما مشكلة مختلفة. لذلك يشغّل الإعداد المعتاد كليهما في الوقت نفسه: يستدعي المشغّل خدمتك، بينما تستدعي خدمتك خادم المحاكاة.
هل يمكنني اختبار API داخلي من مشغّل CI مستضاف؟
ليس من دون تعريضه للعامة. يوجد المشغّل المستضاف خارج شبكتك، لذلك لا يمكنه الوصول إلى خدمة مرتبطة بعنوان خاص. خياراتك هي نشر API للعامة، أو تشغيل نفق، أو الحفاظ على نسخة staging عامة. يضيف كل خيار نظاماً يمكن أن يفشل أو يسرّب البيانات. يستدعي المشغّل الموجود على الشبكة الخاصة نفسها الخدمة مباشرة. وهذا هو السبب العملي الرئيسي الذي يدفع الفرق إلى استضافة هذا العمل ذاتياً.
أين يجب أن توجد stubs المحاكاة ومجموعات اختبارات API؟
في git، بجانب الخدمة التي تصفها. تمنحك الأدوات التي تخزن التعريفات كملفات، مثل دليل mappings/ في WireMock، وملف البيانات في Mockoon، وملفات Hurl، ومجلد .bru في Bruno، مراجعة الشيفرة وإعادة بناء الحاوية من دون تكلفة إضافية. أما الأدوات التي تخزن التعريفات في قاعدة بيانات أو في واجهة ويب فتحتاج إلى خطة نسخ احتياطي وخطوة تصدير. والتصدير هو الجزء الذي ينساه الناس حتى تختفي الحاوية بالفعل.
لماذا يعيد mock الاستجابة 404 رغم أن stub تبدو صحيحة؟
يخدم WireMock stub عند التطابق التام فقط. تحصل الطلبات غير المطابقة على 404 مع نص يبدأ بـRequest was not matched، يتبعه فرق مقارنة بأقرب stub. ويسمي هذا الفرق الحقل المختلف. من الأسباب الشائعة وجود شرطة مائلة في نهاية المسار، أو عدم إرسال العميل ترويسة Content-Type التي تتطلبها stub، أو استخدام urlPath حيث تحتاج stub إلى urlPathPattern لمقطع متغير، أو عدم ملاءمة مطابقة النص للحمولة. افحص /__admin/requests أولاً للتأكد من أن الطلب وصل إلى mock أصلاً.
هل ما زلت أحتاج إلى mocks إذا كانت لديّ بيئة staging؟
نعم، لسببين. قد تتوقف نسخة staging من خدمة upstream لا تتحكم فيها، وقد تفرض أيضاً حدوداً على معدل الطلبات. لذلك قد تفشل مجموعة الاختبارات لأسباب لا علاقة لها بشيفرتك. كما أنها لا تستطيع إنتاج الاستجابات التي تحتاج إلى اختبارها أكثر من غيرها، مثل رفض بطاقة أو انتهاء مهلة بوابة. يعيد mock هذه الاستجابات عند الطلب وبسرعة الشبكة المحلية. وهكذا تتحول مجموعة اختبارات تستغرق دقائق عند تشغيلها على sandbox إلى مجموعة تستغرق ثواني. احتفظ بـstaging للتحقق النهائي قبل الإصدار، واستخدم mocks في CI.