اختبار معالجات REST في Go: httptest واختبارات مدفوعة بجدول الحالات
اختبار معالجات REST في Go باستخدام httptest وحالات مدفوعة بجدول يمنحك طريقة قابلة للتكرار للتحقق من المصادقة، التحقق من المدخلات، رموز الحالة، وحالات الحافة قبل الإصدار.

ما الذي يجب أن تكون واثقاً منه قبل الإصدار
يمكن أن يترجم الكمبايل لمعالج REST إلى نجاح في فحص يدوي سريع، ومع ذلك يفشل في الإنتاج. معظم الأخطاء ليست مشاكل تركيبية. هي مشاكل بالعقد: المعالج يقبل ما يجب رفضه، يعيد رمز حالة خاطئ، أو يكشف تفاصيل في رسالة الخطأ.
الاختبار اليدوي يساعد، لكن من السهل أن تفوت حالات الحافة والانكسارات. تجرب مسار السعادة، ربما خطأ واضح واحد، ثم تمضي. بعدها تغيير صغير في التحقق أو الميدلويير يكسر سلوكاً افترضت أنه ثابت.
هدف اختبارات المعالجات بسيط: اجعل وعود المعالج قابلة للتكرار. ذلك يشمل قواعد المصادقة، التحقق من المدخلات، رموز الحالة المتوقعة، وأجسام الخطأ التي يمكن للعملاء الاعتماد عليها بأمان.
حزمة Go httptest مناسبة لأنها تُمكنك من تشغيل المعالج مباشرة دون بدء خادم حقيقي. تبني طلب HTTP، تمرره إلى المعالج، وتتفحص جسم الاستجابة، الرؤوس، ورمز الحالة. تظل الاختبارات سريعة ومعزولة وسهلة التشغيل على كل كوميت.
قبل الإصدار، يجب أن تعرف (لا تأمل) أن:
- سلوك المصادقة متسق عند غياب التوكن، توكن غير صالح، وأدوار خاطئة.
- المدخلات مُحققة: الحقول المطلوبة، الأنواع، النطاقات، وإذا كنت تفرض ذلك، الحقول المجهولة.
- رموز الحالة تطابق العقد (على سبيل المثال 401 مقابل 403، 400 مقابل 422).
- استجابات الخطأ آمنة ومتسقة (لا آثار تتبع، نفس الشكل في كل مرة).
- المسارات غير السعيدة مُعالجة: انتهاء مهلة، فشل تبعية، ونتائج فارغة.
نقطة نهاية "إنشاء تذكرة" قد تعمل عندما ترسل JSON مثالي كمسؤول. الاختبارات تلتقط ما تنسى تجربته: توكن منتهي، حقل إضافي أرسله العميل عن طريق الخطأ، أولوية سالبة، أو الفرق بين "غير موجود" و"خطأ داخلي" عندما تفشل تبعية.
حدد العقد لكل نقطة نهاية
اكتب ما يعد به المعالج قبل أن تكتب الاختبارات. عقد واضح يحافظ على تركيز الاختبارات ويمنعها من أن تتحول إلى تخمينات حول ما "كان يقصده" الكود. كما يجعل عمليات إعادة البناء أكثر أماناً لأنك تستطيع تغيير البنية الداخلية دون تغيير السلوك العام.
ابدأ بالمدخلات. كن محدداً حول مصدر كل قيمة وما المطلوب. قد تقبل نقطة النهاية id من المسار، limit من استعلام، رأس Authorization، وجسم JSON. دوّن القواعد المهمة: الصيغ المسموح بها، القيم الدنيا/العليا، الحقول المطلوبة، وماذا يحدث عندما يكون شيء مفقود.
ثم حدد المخرجات. لا تكتفِ بـ "يُرجع JSON". قرر ما يشكل نجاحاً، أي رؤوس تهم، وكيف تبدو الأخطاء. إذا اعتمد العملاء على ثبات رموز الأخطاء وشكل JSON متوقع، اعتبر ذلك جزءاً من العقد.
قائمة فحص عملية تكون:
- المدخلات: قيم المسار/الاستعلام، الرؤوس المطلوبة، حقول JSON، وقواعد التحقق
- المخرجات: رمز الحالة، رؤوس الاستجابة، شكل JSON للنجاح والخطأ
- التأثيرات الجانبية: أي بيانات تتغير وما الذي ينشأ
- التبعيات: استدعاءات قاعدة البيانات، خدمات خارجية، الوقت الحالي، المعرفات المولدة
كما قرر أين تتوقف اختبارات المعالجات. اختبارات المعالج أقوى عند حد HTTP: المصادقة، التحليل، التحقق، رموز الحالة، وأجسام الخطأ. ادفع القضايا الأعمق إلى اختبارات التكامل: استعلامات قاعدة بيانات حقيقية، استدعاءات شبكة، والتوجيه الكامل.
إذا كان backend مولداً (مثلاً AppMaster يولد معالجات Go ومنطق أعمال)، فإن نهج ثابت-العقد يصبح أكثر فائدة. يمكنك تجديد الشيفرة والتحقق من أن كل نقطة نهاية تحافظ على نفس السلوك العام.
إعداد حزام اختبار httptest بسيط
يجب أن يشعر اختبار المعالج الجيد كأنه إرسال طلب حقيقي، دون بدء خادم. في Go، هذا عادة يعني: بناء طلب بـ httptest.NewRequest، التقاط الاستجابة بـ httptest.NewRecorder، واستدعاء معالجك.
استدعاء المعالج مباشرة يعطي اختبارات سريعة ومركزة. هذا مثالي عندما تريد التحقق من السلوك داخل المعالج: فحوصات المصادقة، قواعد التحقق، رموز الحالة، وأجسام الخطأ. استخدام راوتر في الاختبارات مفيد عندما يعتمد العقد على معلمات المسار، مطابقة المسارات، أو ترتيب الميدلويير. ابدأ بالاستدعاءات المباشرة وأضف الراوتر فقط عند الحاجة.
الرؤوس أهم مما يظن كثير من الناس. غياب Content-Type يمكن أن يغير كيفية قراءة المعالج للجسم. ضع الرؤوس التي تتوقعها في كل حالة حتى تشير الأخطاء إلى اللوجيك، لا إلى إعداد الاختبار.
إليك نمطًا مبدئياً يمكنك إعادة استخدامه:
req := httptest.NewRequest(http.MethodPost, "/v1/widgets", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
res := rec.Result()
defer res.Body.Close()
للحفاظ على اتساق التصريحات، يساعد وجود مساعد صغير واحد لقراءة وفك ترميز جسم الاستجابة. في معظم الاختبارات، تحقق من رمز الحالة أولاً (حتى تكون الأخطاء سهلة المسح)، ثم الرؤوس الرئيسية التي تعد بها (غالباً Content-Type)، ثم الجسم.
إذا كان backend مولداً (بما في ذلك backend Go من AppMaster)، فإن هذا الحزام لا يزال صالحاً. أنت تختبر عقد HTTP الذي يعتمد عليه المستخدمون، وليس نمط الشيفرة خلفه.
تصميم حالات مدفوعة بجدول تبقى مقروءة
تعمل الاختبارات المدفوعة بجدول بشكل أفضل عندما تقرأ كل حالة كقصة صغيرة: الطلب الذي ترسله وما تتوقعه رده. يجب أن تستطيع مسح الجدول وفهم التغطية دون التنقل في الملف.
حالة قوية عادة تحتوي على: اسم واضح، الطلب (الطريقة، المسار، الرؤوس، الجسم)، رمز الحالة المتوقع، وفحص للاستجابة. بالنسبة لأجسام JSON، فضّل التحقق من بعض الحقول الثابتة (مثل رمز الخطأ) بدلاً من مطابقة سلسلة JSON كاملة، ما لم يكن العقد يطالب بدقة صارمة.
شكل حالة بسيط يمكنك إعادة استخدامه
حافظ على بنية الحالة مركزة. ضع الإعدادات الخاصة في مساعدين حتى يظل الجدول صغيراً.
type tc struct {
name string
method string
path string
headers map[string]string
body string
wantStatus int
wantBody string // substring or compact JSON
}
للمدخلات المختلفة، استخدم سلاسل جسم صغيرة تُظهر الفرق بوضوح: حمولة صحيحة، واحدة بحقل مفقود، واحدة بنوع خاطئ، وواحدة بسلسلة فارغة. تجنب بناء JSON بتنسيق كثير داخل الجدول — يصبح صاخباً بسرعة.
عندما ترى إعدادات متكررة (إنشاء توكن، رؤوس شائعة، جسم افتراضي)، ادفعها إلى مساعدين مثل newRequest(tc) أو baseHeaders().
إذا بدأ جدول واحد يخلط أفكاراً كثيرة جداً، قسمه. جدول للمسارات الناجحة وآخر للمسارات الخطأ غالباً أسهل للقراءة والتصحيح.
فحوصات المصادقة: الحالات التي عادة ما تُهمل
تبدو اختبارات المصادقة سليمة في مسار السعادة، ثم تفشل في الإنتاج لأن حالة "صغيرة" لم تُجرّب. اعتبر المصادقة عقداً: ما يرسله العميل، ما يعيده الخادم، وما يجب ألا يُكشف.
ابدأ بحالات وجود التوكن وصلاحيته. يجب أن تتصرف نقطة النهاية المحمية بشكل مختلف عند غياب الرأس مقابل وجوده ولكن خاطئ. إذا كنت تستخدم توكنات قصيرة العمر، اختبر انتهاء الصلاحية أيضاً، حتى لو حاكتها بإدخال مُحقق يُرجع "منتهي".
معظم الفجوات تُغطى بهذه الحالات:
- لا رأس
Authorization-> 401 مع استجابة خطأ ثابتة - رأس مُشوّه (بادئة خاطئة) -> 401
- توكن غير صالح (توقيع سيء) -> 401
- توكن منتهي الصلاحية -> 401 (أو الرمز المختار لديك) مع رسالة متوقعة
- توكن صالح لكن دور/أذونات خاطئة -> 403
فاصل 401 مقابل 403 مهم. استخدم 401 عندما لا يكون النداء مصدقاً. استخدم 403 عندما يكون مصدقاً لكن غير مخول. إذا خلطت بينهما، سيعيد العميل المحاولة بلا داع أو يعرض واجهة خاطئة.
فحوصات الدور ليست كافية على نقاط النهاية الخاصة بملكية المستخدم (مثل GET /orders/{id}). اختبر الملكية: يجب على المستخدم A ألا يرى طلب المستخدم B حتى مع توكن صالح. يجب أن يكون ذلك 403 نقي (أو 404 إذا اخترت إخفاء الوجود)، ويجب ألا يكشف الجسم أي شيء. اجعل الخطأ عاماً. لا تلمح إلى أن "الطلب يخص المستخدم 42".
قواعد المدخلات: تحقق، ارفض، وفسّر بوضوح
الكثير من أخطاء ما قبل الإصدار هي أخطاء مدخلات: حقول مفقودة، أنواع خاطئة، صيغ غير متوقعة، أو حمولة كبيرة جداً.
سَمِّ كل مدخل يقبله معالجك: حقول جسم JSON، معلمات الاستعلام، ومعلمات المسار. لكل منها، قرر ماذا يحدث عندما تكون مفقودة، فارغة، تالفة، أو خارج النطاق. ثم اكتب حالات تثبت أن المعالج يرفض المدخل السيئ مبكراً ويعيد نفس نوع الخطأ في كل مرة.
مجموعة صغيرة من حالات التحقق تغطي معظم المخاطر:
- الحقول المطلوبة: مفقودة مقابل سلسلة فارغة مقابل null (إذا سمحت به)
- الأنواع والصيغ: رقم مقابل سلسلة، صيغ البريد/التاريخ/UUID، تحليل البوليان
- حدود الحجم: الحد الأقصى للطول، الحد الأقصى للعناصر، حمولة كبيرة جداً
- الحقول المجهولة: تجاهل مقابل رفض (إذا كنت تفرض فك ترميز صارم)
- معلمات الاستعلام والمسار: مفقودة، غير قابلة للتحويل، والسلوك الافتراضي
مثال: معالج POST /users يقبل { "email": "...", "age": 0 }. اختبر email مفقود، email كـ 123، email كـ "not-an-email"، age كـ -1، و age كـ "20". إذا كنت تتطلب JSON صارم، اختبر أيضاً { "email":"[email protected]", "extra":"x" }` وتأكد من فشله.
اجعل حالات فشل التحقق متوقعة. اختر رمز حالة لحالات التحقق (بعض الفرق تستخدم 400، وأخرى 422) واحتفظ بشكل جسم الخطأ ثابتاً. يجب أن تختبر الحالات كل من الرمز والرسالة (أو حقل التفاصيل) الذي يشير إلى المدخل المحدد الذي فشل.
رموز الحالة وأجسام الخطأ: اجعلها متوقعة
تصبح اختبارات المعالج أسهل عندما تكون أخطاء API مملة ومتسقة. تريد أن يرتبط كل خطأ برمز حالة واضح ويعيد نفس شكل JSON، بغض النظر عمن كتب المعالج.
ابدأ بخريطة صغيرة ومتفق عليها من أنواع الأخطاء إلى رموز HTTP:
- 400 Bad Request: JSON تالفة، معلمات استعلام مطلوبة مفقودة
- 404 Not Found: معرف المورد غير موجود
- 409 Conflict: قيد فريد أو تعارض حالة
- 422 Unprocessable Entity: JSON صالح لكنه يفشل قواعد الأعمال
- 500 Internal Server Error: فشل غير متوقع (قاعدة بيانات متوقفة، مؤشر فارغ، انقطاع طرف ثالث)
ثم حافظ على جسم الخطأ ثابتاً. حتى لو تغير نص الرسالة لاحقاً، يجب أن تمتلك العملاء حقولاً متوقعة للاعتماد عليها:
{ "code": "user_not_found", "message": "User was not found", "details": { "id": "123" } }
في الاختبارات، تحقق من الشكل، لا فقط من السطر الحالة. فشل شائع هو إرجاع HTML أو نص عادي أو جسم فارغ عند الأخطاء، وهذا يكسر العملاء ويخفي الأخطاء.
اختبر أيضاً الرؤوس والترميز لاستجابات الخطأ:
Content-Typeهوapplication/json(ومع charset متسق إذا ضبطته)- الجسم JSON صالح حتى عند الفشل
- وجود
codeوmessageوdetails(يمكن أن تكون details فارغة، لكن لا يجب أن تكون عشوائية) - الأزمات والاخطاء غير المتوقعة تُرجع 500 آمن بدون كشف آثار تتبع
إذا أضفت ميدلويير استرجاع من panic، ضمن اختبار واحد يُجبر على panic وتأكد أنك ما زلت تحصل على استجابة JSON نظيفة.
حالات الحافة: الفشل، الوقت، والمسارات غير السعيدة
تثبت اختبارات مسار السعادة أن المعالج يعمل مرة. تثبت اختبارات حالات الحافة أنه يستمر بالتصرف عندما يكون العالم فوضوياً.
أجبِر التبعيات على الفشل بطرق محددة وقابلة للتكرار. إذا كان معالجك يستدعي قاعدة بيانات، كاش، أو API خارجي، تريد أن ترى ماذا يحدث عندما تعيد تلك الطبقات أخطاء لا تتحكم بها.
هذه تستحق المحاكاة مرة على الأقل لكل نقطة نهاية:
- انتهاء مهلة من استدعاء خارجي (
context deadline exceeded) - عدم وجود في التخزين عندما يتوقع العميل وجود بيانات
- انتهاك قيد فريد عند الإنشاء (بريد مكرر، slug مكرر)
- خطأ شبكي أو نقل (connection refused, broken pipe)
- خطأ داخلي غير متوقع ("حدث خطأ ما")
حافظ على ثبات الاختبارات بالتحكم في أي شيء يمكن أن يختلف بين التشغيلات. الاختبار المتقلب أسوأ من لا اختبار لأنه يدرب الناس على تجاهل الفشل.
اجعل الوقت والعشوائية متوقعة
إذا كان المعالج يستخدم time.Now() أو معرفات عشوائية أو قيم عشوائية، حقنها. مرّر دالة ساعة ومنشئ معرفات إلى المعالج أو الخدمة. في الاختبارات، أعد قيم ثابتة لتتمكن من التحقق من حقول JSON والرؤوس بدقة.
استخدم بدائل صغيرة، واثبت "عدم وجود تأثير جانبي"
فَضّل البدائل الصغيرة أو الستَبّات على الموكس الكبيرة. يمكن أن يسجّل البديل الاستدعاءات ويتيح لك التحقق من أنه لم يحدث شيء بعد فشل.
مثال: في معالج "إنشاء مستخدم"، إذا فشل إدخال قاعدة البيانات بسبب قيد فريد، تحقق من رمز الحالة الصحيح، جسم الخطأ الثابت، وأنه لم يُرسل بريد ترحيبي. يمكن لبديل المرسل أن يكشف عداداً (sent=0) حتى يثبت مسار الفشل أنه لم يُفعّل الآثار الجانبية.
أخطاء شائعة تجعل اختبارات المعالج غير موثوقة
غالباً ما تفشل اختبارات المعالج لأسباب خاطئة. الطلب الذي تبنيه في الاختبار ليس نفس شكل طلب العميل الحقيقي. هذا يؤدي إلى أخطاء ضوضائية وثقة خاطئة.
مشكلة شائعة هي إرسال JSON بدون الرؤوس التي يتوقعها المعالج. إذا كان الكود يتحقق من Content-Type: application/json، نسيان ذلك قد يجعل المعالج يتخطى فك ترميز JSON، يعيد رمز حالة مختلف، أو يتخذ فرعاً لا يحدث في الإنتاج. نفس الأمر مع المصادقة: غياب Authorization ليس نفس شيء كتويكن غير صالح. يجب أن تكونا حالتين مختلفتين.
فخ آخر هو المطالبة بسلسلة JSON كاملة كرد خام. تغييرات صغيرة في ترتيب الحقول، الفراغات، أو حقول جديدة تكسر الاختبارات حتى عندما يكون الـ API صحيحاً. فكّ الجسم إلى struct أو map[string]any، ثم تحقق مما يهم: الحالة، رمز الخطأ، الرسالة، وبعض الحقول المفتاحية.
تصبح الاختبارات غير موثوقة أيضاً عندما تشترك الحالات في حالة قابلة للتغيير. إعادة استخدام مخزن في الذاكرة نفسه، متغيرات عالمية، أو راوتر مفرد عبر الصفوف يمكن أن يسرّب بيانات بين الحالات. يجب أن يبدأ كل حالة اختبار نظيفة، أو إعادة تهيئة الحالة في t.Cleanup.
الأنماط التي عادة تسبب اختبارات هشة:
- بناء الطلبات دون نفس الرؤوس وترميز عملاء حقيقيين
- المطالبة بكامل JSON كسلسلة بدلاً من فكّها والتحقق من الحقول
- إعادة استخدام قاعدة بيانات/كاش/حالة عالمية عبر الحالات
- دمج فحوصات المصادقة والتحقق ومنطق الأعمال في اختبار واحد ضخم
حافظ على تركيز كل اختبار. إذا فشلت حالة، يجب أن تعرف خلال ثوانٍ هل السبب مصادقة، قواعد إدخال، أم تنسيق الخطأ.
قائمة فحص سريعة قبل الإصدار يمكنك إعادة استخدامها
قبل الشحن، يجب أن تثبت الاختبارات شيئين: أن نقطة النهاية تتبع عقدها، وأنها تفشل بطرق آمنة ومتوقعة.
شغّل هذه كحالات مدفوعة بجدول، واجعل كل حالة تحقق الاستجابة وأي آثار جانبية:
- المصادقة: لا توكن، توكن سيء، دور خاطئ، دور صحيح (وتأكد أن حالة "دور خاطئ" لا تكشف تفاصيل)
- المدخلات: الحقول المطلوبة مفقودة، أنواع خاطئة، حدود الحجم (دنيا/عليا)، الحقول المجهولة التي تريد رفضها
- المخرجات: رمز الحالة، الرؤوس المفتاحية (مثل
Content-Type)، حقول JSON المطلوبة، شكل الخطأ المتسق - التبعيات: إجبار فشل تبعية واحدة (DB، طابور، دفع، بريد)، تحقق من رسالة آمنة، وتأكد من عدم وجود كتابة جزئية
- عدم التكرار: كرر نفس الطلب (أو أعد المحاولة بعد مهلة) وتأكد من عدم إنشاء نسخ مكررة
بعد ذلك، أضف تحقق سلامة واحد يتخطاه بعض الناس: تأكد أن المعالج لم يلمس ما لا ينبغي. مثلاً، في حالة فشل التحقق، تأكد أنه لم يُنشىء سجل ولا أُرسل بريد. يمكن لبديل المرسل أن يثبت ذلك (sent=0).
إذا بنت الـ APIs بأداة مثل AppMaster، فإن نفس القائمة تنطبق. الفكرة واحدة: إثبات بقاء السلوك العام ثابتاً.
مثال: نقطة نهاية واحدة، جدول صغير، وما يلتقطه
لنفترض نقطة نهاية بسيطة: POST /login. تقبل JSON بـ email و password. تعيد 200 مع توكن عند النجاح، 400 للمدخلات غير الصالحة، 401 لبيانات اعتماد خاطئة، و 500 إذا كان خادم المصادقة متوقفاً.
جدول مضغوط مثل هذا يغطي معظم ما ينكسر في الإنتاج.
func TestLoginHandler(t *testing.T) {
// Fake dependency so we can force 200/401/500 without hitting real systems.
auth := &FakeAuth{ /* configure per test */ }
h := NewLoginHandler(auth)
tests := []struct {
name string
body string
authHeader string
setup func()
wantStatus int
wantBody string
}{
{"success", `{"email":"[email protected]","password":"secret"}`, "", func() { auth.Mode = "ok" }, 200, `"token"`},
{"missing password", `{"email":"[email protected]"}`, "", func() { auth.Mode = "ok" }, 400, "password"},
{"bad email format", `{"email":"not-an-email","password":"secret"}`, "", func() { auth.Mode = "ok" }, 400, "email"},
{"invalid JSON", `{`, "", func() { auth.Mode = "ok" }, 400, "invalid JSON"},
{"unauthorized", `{"email":"[email protected]","password":"wrong"}`, "", func() { auth.Mode = "unauthorized" }, 401, "unauthorized"},
{"server error", `{"email":"[email protected]","password":"secret"}`, "", func() { auth.Mode = "error" }, 500, "internal"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tt.setup()
req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(tt.body))
req.Header.Set("Content-Type", "application/json")
if tt.authHeader != "" {
req.Header.Set("Authorization", tt.authHeader)
}
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != tt.wantStatus {
t.Fatalf("status = %d, want %d, body=%s", rr.Code, tt.wantStatus, rr.Body.String())
}
if tt.wantBody != "" && !strings.Contains(rr.Body.String(), tt.wantBody) {
t.Fatalf("body %q does not contain %q", rr.Body.String(), tt.wantBody)
}
})
}
}
اتبع حالة واحدة من البداية للنهاية: لحالة "كلمة المرور مفقودة" ترسل جسماً به email فقط، تضبط Content-Type، تمرره عبر ServeHTTP، ثم تتحقق من 400 وخطأ يشير بوضوح إلى password. تلك الحالة الواحدة تثبت أن المفكك، المحقق، وصيغة رسالة الخطأ تعمل معاً.
إذا أردت طريقة أسرع لتوحيد العقود، وحدات المصادقة، والتكاملات مع استمرار شحن شيفرة Go حقيقية، فـ AppMaster (appmaster.io) مبنية لذلك. حتى عندها، تبقى هذه الاختبارات قيّمة لأنها تقفل السلوك الذي يعتمد عليه عملاؤك.


