يمكن لـ TimeProvider في .NET 8 وتعديل بسيط في SynchronizationContext توفير ثوانٍ من اختبارات الوحدة (unit tests) الخاصة بمنطق إعادة المحاولة (retry-logic) التي تظل عالقة عادةً في فترات انتظار حقيقية (real sleeps). الاختبار الذي كان يستغرق سبع ثوانٍ أصبح ينتهي الآن في بضع عشرات من الملي ثانية، ولا يكلف هذا التغيير أي شيء إضافي عند التشغيل في بيئة الإنتاج (production).

لماذا يهم هذا التأخير

تستخدم العديد من أدوات المساعدة في إعادة المحاولة (retry helpers) أسلوب التراجع الأسي (exponential backoff): الانتظار لمدة ثانية واحدة، ثم ثانيتين، ثم 4 ثوانٍ، وهكذا. في بيئة الإنتاج، يحمي هذا الخدمات من إغراق نقطة نهاية (endpoint) متعثرة بالطلبات. أما في مجموعة الاختبارات، فإن الكود نفسه يستدعي Task.Delay باستخدام ساعة حقيقية، لذا فإن خيط الاختبار (test thread) يدخل في حالة نوم فعلي. اضرب ذلك في عشرات الاختبارات، وستجد أن خط أنابيب التكامل المستمر (CI pipeline) يتضخم لعدة دقائق دون أي فائدة وظيفية. "ضريبة النوم" هذه هي مجرد وقت ضائع.

كيف يعمل TimeProvider

قدم .NET 8 ميزة TimeProvider كطبقة تجريد (abstraction) فوق ساعة النظام. يمكن للفئة (class) التي تحتاج إلى الوقت الحالي أو تحتاج إلى تأخير أن تقبل مثيلًا (instance) من TimeProvider بدلاً من استدعاء DateTime.UtcNow أو Task.Delay مباشرة. في بيئة الإنتاج، تمرر TimeProvider.System الذي يحول الطلبات إلى الساعة الحقيقية. أما في الاختبار، فتقوم بتزويد FakeTimeProvider. تتيح لك الساعة الوهمية (fake clock) تقديم الوقت يدويًا باستخدام Advance(TimeSpan). داخليًا، يقرأ Task.Delay المزود (provider)، لذا فإن تقديم الساعة الوهمية للأمام يلبي فورًا أي تأخير معلق.

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

فخ SynchronizationContext في xUnit

تعمل هذه الفكرة في تطبيقات الكونسول (console app)، ولكنها تسببت في حدوث حالة جمود (deadlock) في xUnit. استدعى الاختبار Advance() بينما كان مساعد إعادة المحاولة ينتظر تأخيرًا. يقوم xUnit بتثبيت SynchronizationContext الخاص به الذي يلتقط الاستمراريات (continuations) ويشغلها على خيط الاختبار. عندما قامت Advance() بتقديم الساعة للأمام، تم وضع الاستمرارية في قائمة انتظار مجمع الخيوط (thread pool) بدلاً من خيط الاختبار. استمر خيط الاختبار في تقديم الوقت، مما دفع الساعة المحاكية لتجاوز اللحظة التي يجب أن يتم فيها تنفيذ إعادة المحاولة التالية. تم ضبط المؤقت الجديد لنقطة مستقبلية لن يتم الوصول إليها أبدًا لأنه لم يتبق أي خيط لتحريك الساعة مرة أخرى. ظل الاختبار معلقًا إلى أجل غير مسمى.

الإصلاح في سطر واحد

إضافة سطر واحد في بداية الاختبار يعيد السلوك المتوقع:

SynchronizationContext.SetSynchronizationContext(null);

يؤدي مسح السياق المخصص (custom context) إلى إجبار استمراريات await على العمل في مجمع الخيوط (thread pool)، حيث يمكن تنفيذ استدعاءات (callbacks) المؤقت الوهمي. مع هذا التغيير، ينخفض نفس الاختبار من سبع ثوانٍ إلى حوالي 36 مللي ثانية تقريبًا.

فوائد أوسع

بعيدًا عن القضاء على فترات النوم غير المستغلة، تسهل الساعة الوهمية اختبار المنطق الحساس للمناطق الزمنية. يمكنك التحقق من أن الحصة اليومية (daily quota) يتم إعادة ضبطها عند منتصف الليل المحلي الصحيح دون انتظار الساعة الحقيقية لتصل إلى الثانية عشرة. يعمل النهج نفسه مع أي كود يتفرع بناءً على الوقت الحالي: قم بحقن المزود (inject the provider)، وتجنب الاستدعاءات المخفية لـ Thread.Sleep أو Task.Delay ، وستحصل على اختبارات حتمية (deterministic) وسريعة.

أمور يجب مراعاتها

  • يجب توجيه كل تأخير عبر TimeProvider المحقون. أي Thread.Sleep عابر أو Task.Delay مباشر سيظل يستدعي الساعة الحقيقية ويعيد إدخال زمن التأخير (latency).
  • إذا كانت المكتبة التي تعتمد عليها تستدعي Task.Delay داخليًا دون إظهار مزود (provider)، فلا يمكنك التحكم في توقيتها دون استخدام وسيط (shim) أكثر تغلغلًا. في مثل هذه الحالات، قد تكون الفائدة محدودة.
  • ابحث (Grep) في مجلد الاختبارات عن Task.Delay و Thread.Sleep لرصد فترات الانتظار المخفية. إعادة هيكلة (Refactoring) تلك الاستدعاءات لاستخدام المزود هي الطريقة الوحيدة للحفاظ على فعالية الساعة الوهمية.

الخلاصة

إن استبدال ساعة النظام بـ TimeProvider وتعطيل SynchronizationContext الخاص بـ xUnit يحول اختبارات إعادة المحاولة البطيئة إلى فحوصات فورية تقريبًا، مما يوفر موارد التكامل المستمر (CI) ويجعل الكود المعتمد على الوقت أسهل في التحقق. الجهد المبذول هو بضعة أسطر من الكود؛ أما العائد فيُقاس بالثواني التي يتم توفيرها في كل عملية تشغيل للاختبار.