الاختبارات و TDD

اختبار الكود غير المتزامن: Promises والموقتات والأحداث

20 دقيقة الدرس 8 من 35

تحدي اختبار الكود غير المتزامن

الكود غير المتزامن موجود في كل مكان في JavaScript الحديث - استدعاءات API واستعلامات قاعدة البيانات والموقتات وعمليات الملفات ومعالجات الأحداث تعمل جميعها بشكل غير متزامن. اختبار الكود غير المتزامن يقدم تحديات فريدة لأن الاختبارات تحتاج إلى الانتظار حتى تكتمل العمليات، والتعامل مع سيناريوهات النجاح والفشل، والتعامل مع ظروف السباق. بدون التقنيات المناسبة، يمكن أن تكون الاختبارات غير المتزامنة غير مستقرة أو بطيئة أو غير صحيحة ببساطة.

التحدي الرئيسي هو أن الاختبارات متزامنة افتراضياً - تعمل من الأعلى إلى الأسفل وتنتهي على الفور. لكن العمليات غير المتزامنة تكتمل في وقت ما في المستقبل. نحتاج آليات لإخبار إطار الاختبار: "انتظر حتى تكتمل هذه العملية غير المتزامنة قبل التحقق من التأكيدات." أطر الاختبار المختلفة توفر واجهات برمجية مختلفة لهذا، لكن المفاهيم الأساسية تظل كما هي.

الأنماط غير المتزامنة الشائعة:
  • Callbacks: دوال يتم تمريرها كمعاملات ليتم استدعاؤها لاحقاً
  • Promises: كائنات تمثل الإكمال أو الفشل النهائي
  • Async/Await: سكر نحوي يجعل الكود غير المتزامن يبدو متزامناً
  • Timers: setTimeout وsetInterval للتنفيذ المتأخر
  • Events: مُطلقات الأحداث ومستمعوها

اختبار Callbacks

نمط callback Done

Jest وأطر أخرى توفر معامل callback done للإشارة عند اكتمال اختبار غير متزامن:

// دالة تستخدم callback function fetchData(callback) { setTimeout(() => { callback({ data: 'peanut butter' }); }, 100); } // اختبار مع callback done test('يجلب البيانات مع callback', (done) => { function callback(data) { try { expect(data).toEqual({ data: 'peanut butter' }); done(); // إشارة اكتمال الاختبار } catch (error) { done(error); // إشارة فشل الاختبار } } fetchData(callback); });
لا تنسى ()done:

إذا لم تستدعي done()، سينتهي الاختبار على الفور وسينجح حتى لو لم تعمل تأكيداتك أبداً! استدعي done() دائماً - إما للإشارة إلى النجاح أو تمرير خطأ للفشل.

اختبار Error Callbacks

function fetchDataWithError(callback) { setTimeout(() => { callback(new Error('Network error')); }, 100); } test('يتعامل مع الأخطاء في callback', (done) => { function callback(error) { try { expect(error).toBeInstanceOf(Error); expect(error.message).toBe('Network error'); done(); } catch (err) { done(err); } } fetchDataWithError(callback); });

اختبار Promises

إرجاع Promises

أبسط طريقة لاختبار promises هي إرجاع promise من اختبارك. Jest سينتظر حتى يتم حله:

function fetchUser(id) { return Promise.resolve({ id, name: 'John' }); } test('يجلب المستخدم بواسطة ID', () => { // أرجع promise - Jest ينتظره return fetchUser(1).then(user => { expect(user).toEqual({ id: 1, name: 'John' }); }); });
يجب إرجاع Promise:

إذا نسيت return الـ promise، ينتهي الاختبار فوراً دون انتظار. أرجع promises دائماً في الاختبارات ما لم تستخدم async/await.

اختبار Promise Rejections

function fetchUserWithError(id) { return Promise.reject(new Error('User not found')); } // الطريقة 1: باستخدام ()catch. test('يتعامل مع خطأ المستخدم غير موجود', () => { return fetchUserWithError(1).catch(error => { expect(error.message).toBe('User not found'); }); }); // الطريقة 2: باستخدام ().expect rejects test('يتعامل مع خطأ المستخدم غير موجود', () => { return expect(fetchUserWithError(1)) .rejects .toThrow('User not found'); }); // الطريقة 3: باستخدام عدد التأكيدات test('يتعامل مع خطأ المستخدم غير موجود', () => { expect.assertions(1); // تأكد من تشغيل التأكيد return fetchUserWithError(1).catch(error => { expect(error.message).toBe('User not found'); }); });

اختبار Promise Resolution

// اختبار القيمة المحلولة test('يحل مع بيانات المستخدم', () => { return expect(fetchUser(1)) .resolves .toEqual({ id: 1, name: 'John' }); }); // ربط matchers test('المستخدم له خصائص صحيحة', () => { return expect(fetchUser(1)) .resolves .toMatchObject({ id: 1 }); });

الاختبار مع Async/Await

اختبارات Async/Await الأساسية

Async/await يجعل الاختبارات غير المتزامنة تبدو كالكود المتزامن، مما يحسن القابلية للقراءة بشكل كبير:

async function fetchUser(id) { const response = await fetch(`/api/users/${id}`); return response.json(); } test('يجلب المستخدم مع async/await', async () => { const user = await fetchUser(1); expect(user).toEqual({ id: 1, name: 'John' }); }); // عمليات غير متزامنة متعددة test('يجلب مستخدمين متعددين', async () => { const user1 = await fetchUser(1); const user2 = await fetchUser(2); expect(user1.id).toBe(1); expect(user2.id).toBe(2); });

اختبار Async Errors

async function fetchUserWithError(id) { throw new Error('User not found'); } // الطريقة 1: باستخدام try/catch test('يتعامل مع أخطاء async', async () => { try { await fetchUserWithError(1); // إذا وصلنا هنا، يجب أن يفشل الاختبار expect(true).toBe(false); } catch (error) { expect(error.message).toBe('User not found'); } }); // الطريقة 2: باستخدام ().expect rejects (أنظف) test('يتعامل مع أخطاء async', async () => { await expect(fetchUserWithError(1)) .rejects .toThrow('User not found'); }); // اختبار أنواع أخطاء محددة test('يرمي TypeError', async () => { await expect(invalidOperation()) .rejects .toThrow(TypeError); });

عمليات Async المتوازية

test('يجلب المستخدمين بالتوازي', async () => { // تشغيل جميع promises بالتوازي const [user1, user2, user3] = await Promise.all([ fetchUser(1), fetchUser(2), fetchUser(3) ]); expect(user1.id).toBe(1); expect(user2.id).toBe(2); expect(user3.id).toBe(3); }); // اختبار ظروف السباق test('أول مستخدم يستجيب يفوز', async () => { const fastestUser = await Promise.race([ fetchUser(1), fetchUser(2), fetchUser(3) ]); expect(fastestUser).toBeDefined(); });

اختبار الموقتات

استخدام الموقتات المزيفة

الموقتات الحقيقية تجعل الاختبارات بطيئة. Jest يوفر موقتات مزيفة تتيح لك التحكم في الوقت:

function delayedGreeting(name, callback) { setTimeout(() => { callback(`Hello, ${name}!`); }, 1000); } test('يستدعي التحية بعد ثانية واحدة', () => { jest.useFakeTimers(); const callback = jest.fn(); delayedGreeting('John', callback); // تقديم الوقت بمقدار ثانية واحدة jest.advanceTimersByTime(1000); expect(callback).toHaveBeenCalledWith('Hello, John!'); jest.useRealTimers(); });

تشغيل جميع الموقتات

function scheduledTasks() { setTimeout(() => console.log('Task 1'), 100); setTimeout(() => console.log('Task 2'), 200); setTimeout(() => console.log('Task 3'), 300); } test('يشغل جميع المهام المجدولة', () => { jest.useFakeTimers(); const log = jest.spyOn(console, 'log'); scheduledTasks(); // تشغيل جميع الموقتات مرة واحدة jest.runAllTimers(); expect(log).toHaveBeenCalledTimes(3); expect(log).toHaveBeenCalledWith('Task 1'); expect(log).toHaveBeenCalledWith('Task 2'); expect(log).toHaveBeenCalledWith('Task 3'); log.mockRestore(); jest.useRealTimers(); });

اختبار setInterval

class Timer { constructor() { this.count = 0; } start() { this.interval = setInterval(() => { this.count++; }, 1000); } stop() { clearInterval(this.interval); } } test('يزيد العداد كل ثانية', () => { jest.useFakeTimers(); const timer = new Timer(); timer.start(); expect(timer.count).toBe(0); // تقدم 3 ثوانٍ jest.advanceTimersByTime(3000); expect(timer.count).toBe(3); timer.stop(); jest.useRealTimers(); });

دوال Async مع موقتات

async function fetchWithRetry(url, maxRetries = 3) { for (let i = 0; i < maxRetries; i++) { try { return await fetch(url); } catch (error) { if (i === maxRetries - 1) throw error; await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1))); } } } test('يعيد محاولة الطلبات الفاشلة مع backoff', async () => { jest.useFakeTimers(); const mockFetch = jest.fn() .mockRejectedValueOnce(new Error('Fail 1')) .mockRejectedValueOnce(new Error('Fail 2')) .mockResolvedValueOnce({ data: 'success' }); global.fetch = mockFetch; const promise = fetchWithRetry('/api/data'); // التقدم السريع خلال تأخيرات إعادة المحاولة await jest.advanceTimersByTimeAsync(1000); // المحاولة الأولى بعد 1s await jest.advanceTimersByTimeAsync(2000); // المحاولة الثانية بعد 2s const result = await promise; expect(mockFetch).toHaveBeenCalledTimes(3); expect(result).toEqual({ data: 'success' }); jest.useRealTimers(); });
طرق الموقتات الحديثة:
  • (jest.advanceTimersByTime(ms: يقدم الموقتات بشكل متزامن
  • (jest.advanceTimersByTimeAsync(ms: نسخة async للموقتات مع promises
  • ():jest.runAllTimers يشغل جميع الموقتات المعلقة
  • ():jest.runOnlyPendingTimers يشغل فقط الموقتات المعلقة حالياً
  • ():jest.clearAllTimers يزيل جميع الموقتات المعلقة

اختبار الكود المدار بالأحداث

اختبار Event Emitters

const EventEmitter = require('events'); class UserService extends EventEmitter { createUser(data) { const user = { id: 1, ...data }; this.emit('user:created', user); return user; } } test('يطلق حدث user:created', (done) => { const service = new UserService(); service.on('user:created', (user) => { expect(user).toMatchObject({ name: 'John' }); done(); }); service.createUser({ name: 'John' }); }); // استخدام promises بدلاً من done test('يطلق حدث user:created', () => { const service = new UserService(); return new Promise((resolve) => { service.on('user:created', (user) => { expect(user).toMatchObject({ name: 'John' }); resolve(); }); service.createUser({ name: 'John' }); }); });

اختبار أحداث متعددة

class OrderProcessor extends EventEmitter { async processOrder(order) { this.emit('order:received', order); await this.validateOrder(order); this.emit('order:validated', order); await this.chargePayment(order); this.emit('order:paid', order); return order; } async validateOrder(order) { // منطق التحقق } async chargePayment(order) { // منطق الدفع } } test('يطلق الأحداث بالترتيب الصحيح', async () => { const processor = new OrderProcessor(); const events = []; processor.on('order:received', () => events.push('received')); processor.on('order:validated', () => events.push('validated')); processor.on('order:paid', () => events.push('paid')); await processor.processOrder({ id: 1, total: 100 }); expect(events).toEqual(['received', 'validated', 'paid']); });

اختبار أحداث DOM

// معالج نقر الزر function handleButtonClick(button) { button.addEventListener('click', () => { button.textContent = 'Clicked!'; }); } test('يحدث النص عند نقر الزر', () => { // إنشاء عنصر زر const button = document.createElement('button'); button.textContent = 'Click me'; // إرفاق المعالج handleButtonClick(button); // محاكاة النقر button.click(); expect(button.textContent).toBe('Clicked!'); }); // اختبار معالجات أحداث async test('يحمل البيانات عند نقر الزر', async () => { const button = document.createElement('button'); button.addEventListener('click', async () => { const data = await fetchData(); button.textContent = data; }); // محاكاة fetchData global.fetchData = jest.fn(() => Promise.resolve('Loaded')); // نقر وانتظر button.click(); await new Promise(resolve => setTimeout(resolve, 0)); expect(button.textContent).toBe('Loaded'); });

أفضل الممارسات

1. فضل Async/Await

// تجنب - callback hell في الاختبارات test('عمليات متعددة', (done) => { fetchUser(1, (user) => { fetchOrders(user.id, (orders) => { expect(orders.length).toBe(3); done(); }); }); }); // أفضل - async/await test('عمليات متعددة', async () => { const user = await fetchUser(1); const orders = await fetchOrders(user.id); expect(orders.length).toBe(3); });

2. تعيين مهلات مناسبة

// زيادة المهلة للعمليات البطيئة test('استدعاء API بطيء', async () => { const data = await slowApiCall(); expect(data).toBeDefined(); }, 10000); // مهلة 10 ثوانٍ // أو تعيين مهلة للمجموعة بأكملها describe('اختبارات API', () => { jest.setTimeout(10000); test('يجلب البيانات', async () => { // ... }); });

3. تنظيف الموقتات والمستمعين

let interval; beforeEach(() => { jest.useFakeTimers(); }); afterEach(() => { if (interval) { clearInterval(interval); } jest.clearAllTimers(); jest.useRealTimers(); }); test('يبدأ interval', () => { interval = setInterval(() => {}, 1000); // منطق الاختبار });

4. استخدم ()expect.assertions

// تأكد من تشغيل التأكيدات في promises فعلياً test('يمسك الخطأ', async () => { expect.assertions(1); // يجب تشغيل تأكيد واحد try { await failingOperation(); } catch (error) { expect(error.message).toBe('Failed'); } });
تمرين تطبيقي:
  1. أنشئ CacheService يخزن البيانات مع TTL (وقت البقاء)
  2. اكتب اختبارات باستخدام موقتات مزيفة للتحقق من انتهاء صلاحية العناصر بشكل صحيح
  3. أنشئ BatchProcessor يعالج العناصر في دفعات مع تأخيرات
  4. اكتب اختبارات async للتحقق من ترتيب وتوقيت معالجة الدفعات
  5. أنشئ فئة EventBus تطلق أحداث للاشتراك/إلغاء الاشتراك
  6. اكتب اختبارات تتحقق من استقبال مستمعي الأحداث المتعددين للأحداث بشكل صحيح

الخلاصة

اختبار الكود غير المتزامن يتطلب فهم promises وasync/await والموقتات والأحداث. أطر الاختبار الحديثة مثل Jest توفر أدوات ممتازة لاختبار العمليات غير المتزامنة: إرجاع promises واستخدام صيغة async/await والتحكم في الوقت بالموقتات المزيفة ومعالجة الأحداث بشكل صحيح. باتباع أفضل الممارسات - تفضيل async/await وتنظيف الموقتات والتأكد من تشغيل التأكيدات - يمكنك كتابة اختبارات غير متزامنة موثوقة وسريعة تكتشف الأخطاء قبل وصولها إلى الإنتاج.