الوحدة 2 · اختبارات وحدة لها قيمة
اختبار الأخطاء والكود غير المتزامن
18 دقراءة 4 دبيئة تجريب الكود
الدرس في لمحة
تختبر كودًا يرمي أخطاءً، أو يعيد promises، أو يعتمد على API غير مستقر، باستخدام assertions من نوع resolves وrejects مع await، وبدائل صغيرة مكتوبة باليد.
- 1الدالة الخاضعة للاختبار
- 2الـ assertions على الـ promises
- 3الـ stubs: متعاونون تتحكّم فيهم
- 4اختبر كل فرع من فروع معالجة الأخطاء
- 5المهل الزمنية (timeouts)
لا فيديو هنا، بل الشيء الحقيقي: درس مكتوب وأمثلة قابلة للتشغيل واختبار وتدريب.
يحفظ Splitwise-lite المصروفات عبر اتصال هاتف في قطار. الطلبات تفشل. بعض حالات الفشل تستحق إعادة محاولة واحدة (انتهاء المهلة)؛ وبعضها لا (حين يقول الخادم إن المبلغ غير صالح). الكود الذي يقرّر ذلك نحو اثني عشر سطرًا لا أكثر، وهو بالضبط الكود الذي لا يختبره أحد، لأنه “غير متزامن” و"يحتاج إلى الـ API". ولا واحد من السببين عائق حقيقي.
الدالة الخاضعة للاختبار
export async function saveExpense(expense, api) {
if (!expense.description?.trim()) throw new Error('Description is required');
if (!Number.isInteger(expense.amountCents) || expense.amountCents <= 0) {
throw new Error('Amount must be positive');
}
try {
const { id } = await api.post('/expenses', expense);
return id;
} catch (error) {
if (!error.retryable) throw error;
const { id } = await api.post('/expenses', expense); // one retry
return id;
}
}لأنها دالة async، يتحوّل كل throw داخلها إلى promise مرفوض. المستدعون (والاختبارات) لا يرون أبدًا استثناءً متزامنًا؛ بل يرون promise يُرفض.
الـ assertions على الـ promises
تنتظر Vitest الاختبار الذي يكون async أو يعيد promise. وفي داخله لديك خياران جيدان:
test('resolves with the new id', async () => {
const api = { post: async () => ({ id: 'e1' }) };
await expect(saveExpense({ description: 'Dinner', amountCents: 4500 }, api))
.resolves.toBe('e1');
});
test('rejects a blank description', async () => {
const api = { post: async () => ({ id: 'e1' }) };
await expect(saveExpense({ description: ' ', amountCents: 4500 }, api))
.rejects.toThrow('Description is required');
});يمكنك أيضًا أن تستخدم await مع الاستدعاء ثم تؤكّد القيمة: const id = await saveExpense(...); expect(id).toBe('e1');. أما مع حالات الرفض، ففضّل rejects على try/catch: إن حُلّ الـ promise بنجاح على غير المتوقّع، يُفشل rejects الاختبار، بينما تتخطّى try/catch كتلة catch بصمت وينجح الاختبار. (وإن اضطررت إلى try/catch، فضع expect.assertions(1) في أعلى الاختبار كي تُفشله Vitest حين لا يُنفَّذ أي assertion.)
الـ stubs: متعاونون تتحكّم فيهم
تتحدّث saveExpense إلى api. في الاختبار لا تريد خادمًا حقيقيًا؛ بل تريد كائنًا تفعل دالة post فيه ما يحتاجه هذا الاختبار بالذات. هذا الكائن هو الـ stub (بديل ثابت الاستجابة)، ولا تحتاج إلى مكتبة لكتابته:
// Fails once with a retryable error, then succeeds; counts its calls.
function flakyApi() {
let calls = 0;
return {
get calls() { return calls; },
async post() {
calls += 1;
if (calls === 1) throw Object.assign(new Error('Timed out'), { retryable: true });
return { id: 'e2' };
},
};
}
test('retries once after a retryable error', async () => {
const api = flakyApi();
await expect(saveExpense({ description: 'Taxi', amountCents: 1800 }, api)).resolves.toBe('e2');
expect(api.calls).toBe(2);
});تمرير api كمعامل هو ما يجعل هذا سهلًا. الكود الذي يستورد عميلًا عامًّا في أعماقه أصعب اختبارًا بكثير؛ وستتعرّف إلى أدوات ذلك (وثمنها) في الدرس القادم.
اختبر كل فرع من فروع معالجة الأخطاء
لمعالجة الأخطاء فروع أكثر من المسار السعيد، وكل فرع منها قرار اتّخذه شخص ما. بالنسبة إلى saveExpense:
| الموقف | المتوقّع |
|---|---|
| ينجح الـ API | يُحلّ بالـ id، باستدعاء واحد |
| خطأ قابل لإعادة المحاولة، ثم نجاح | يُحلّ بالـ id، باستدعاءين |
| خطأ غير قابل لإعادة المحاولة | يُرفض بـ ذلك الخطأ، باستدعاء واحد |
| مدخل غير صالح | يُرفض برسالة تحقّق، بلا أي استدعاء |
الصف الثالث هو الذي يتخطّاه الناس، وهو يخبّئ خطأين شائعين: catch تبتلع الخطأ وتعيد null (فيظنّ المستدعون أن الحفظ نجح)، وcatch تعيد المحاولة مع كل شيء (فتُحصَّل دفعة مرفوضة مرتين). الرفض وحده لا يستطيع كشف الخطأ الثاني، لأن إعادة المحاولة تفشل هي الأخرى. أما عدّ الاستدعاءات فيستطيع.
لاحظ الصف الأخير أيضًا: المدخل غير الصالح يجب ألا يُحدث أي استدعاء. هذا وعد يتعلّق بالآثار الجانبية، وهو مهم. النسخة التي تتحقّق بعد الإرسال سترفض بالرسالة الصحيحة رغم ذلك، فلا يثبت أن المصروف السيئ لم يصل إلى الخادم سوى عدد الاستدعاءات.
المهل الزمنية (timeouts)
إن لم يستقرّ promise أبدًا (stub نسي أن يُحلّ، أو await مفقود داخل حلقة)، يعلق الاختبار حتى تنتهي مهلة Vitest، وهي خمس ثوانٍ افتراضيًا، ثم يفشل بخطأ انتهاء المهلة. مرّر معاملًا ثالثًا إلى test لحالة أبطأ، أو اضبط testTimeout في الإعدادات، لكن تعامل مع الـ unit test البطيء كعلامة على مشكلة: على الأرجح يتسرّب شيء حقيقي إلى الاختبار.
في التمرين تختبر الصفوف الأربعة في ذلك الجدول وتلتقط أربعة أخطاء واقعية، منها خطأ التحصيل المزدوج. ويستبدل الدرس القادم الـ stubs المكتوبة باليد بـ vi.fn.
أهم النقاط
- استخدم
awaitدائمًا مع الـ promise أو مع الـ assertion عليه؛ فالاختبار الذي ينتهي قبل أن يستقرّ الـ promise قد ينجح أيًّا كان ما يفعله الكود. await expect(promise).rejects.toThrow('message')هي أوضح طريقة لاختبار فشل غير متزامن.- الـ stub (بديل ثابت الاستجابة) متعاون مزيّف صغير تتحكّم فيه، مثل كائن api تنجح دالة post فيه، أو تفشل، أو تعدّ استدعاءاتها.
- اختبر كل فرع من فروع معالجة الأخطاء، بما فيها الفرع الذي يجب أن يستسلم، وعُدّ الاستدعاءات حين يكون الفرق في عدد المحاولات.