EduFlow
اختبار تطبيقات JavaScript
⁦0%⁩

الوحدة 2 · اختبارات وحدة لها قيمة

بدائل الاختبار، ومتى يضرّ الـ mocking

20 دقراءة 3 دبيئة تجريب الكود

الدرس في لمحة

تستبدل المتعاونين بـ vi.fn وvi.spyOn، وتتحكّم في الوقت بالمؤقّتات المزيّفة، وتميّز الحالات التي يجعل فيها الـ mocking الاختبار ينجح بينما الكود الحقيقي معطّل.

  1. 1المصطلحات، باختصار
  2. 2vi.fn: دالة تتذكّر
  3. 3vi.spyOn: مراقبة دالة حقيقية
  4. 4المؤقّتات المزيّفة: تخطّي الانتظار
  5. 5متى يضرّ الـ mocking

لا فيديو هنا، بل الشيء الحقيقي: درس مكتوب وأمثلة قابلة للتشغيل واختبار وتدريب.

في الدرس الماضي كتبت API بديلًا (stub) باليد، مع عدّاد. نجح الأمر، واستغرق نحو خمسة عشر سطرًا. أدوات vi في Vitest تفعل الشيء نفسه في سطر واحد، وتضيف قوة كبيرة. وهذه القوة هي المشكلة: الـ mocking أسرع طريقة لكتابة اختبار ينجح بينما الكود الحقيقي معطّل. يغطّي هذا الدرس الأدوات، ويغطّي حسن التقدير في استخدامها.

المصطلحات، باختصار

كل هذه test doubles (بدائل الاختبار)، أي أشياء تقوم مقام متعاون حقيقي:

أربعة أنواع من بدائل الاختبار في صف: الـ stub يعيد إجابات جاهزة، والـ spy يسجّل الاستدعاءات، والـ mock هو spy تؤكّد عليه، والـ fake تطبيق خفيف يعمل فعلًا. Stub إجابات جاهزة returns {id} Spy يسجّل الاستدعاءات .mock.calls Mock spy تؤكّد عليه CalledWith Fake يعمل، وأبسط in-memory DB
بدائل الاختبار، من الأبسط إلى الأغنى سلوكًا

في Vitest، يعطيك vi.fn() الـ stub والـ spy (الجاسوس) في كائن واحد، والناس يسمّون كل ذلك “mocks” (بدائل وهمية). الأسماء أقل أهمية من السؤال الذي يجيب عنه كل منها: ماذا يعيد هذا المتعاون؟ (stub)، وكيف استُدعي؟ (spy).

vi.fn: دالة تتذكّر

يرسل Splitwise-lite تذكيرًا إلى كل من عليه مال. دالة الإرسال تُمرَّر كمعامل، فيستطيع الاختبار استبدالها:

src/remind.test.js
import { test, expect, vi } from 'vitest';
import { remindDebtors } from './remind.js';

test('reminds people who owe money, with the amount', () => {
  const sendReminder = vi.fn();

  const sent = remindDebtors({ Ana: 1500, Ben: -500, Cai: 0 }, sendReminder);

  expect(sent).toBe(1);
  expect(sendReminder).toHaveBeenCalledTimes(1);
  expect(sendReminder).toHaveBeenCalledWith('Ben', 'You owe $5.00 to the group');
});

يعيد vi.fn() القيمة undefined افتراضيًا. أعطه سلوكًا حين يحتاج الكود الخاضع للاختبار إلى إجابة:

JavaScript
const post = vi.fn().mockResolvedValue({ id: 'e1' });          // async success
const failing = vi.fn().mockRejectedValue(new Error('Offline')); // async failure
const send = vi.fn().mockImplementation((name) => {
  if (name === 'Ben') throw new Error('Mailbox full');
});

يُسجَّل كل استدعاء في send.mock.calls، وهي مصفوفة من مصفوفات المعاملات، للحالات النادرة التي لا تغطّيها الـ matchers.

vi.spyOn: مراقبة دالة حقيقية

أحيانًا يكون المتعاون دالة على كائن موجود، مثل console.warn. يلفّها vi.spyOn: افتراضيًا تظلّ الدالة الحقيقية تعمل، ويسجّل الـ spy الاستدعاءات. أضف mockImplementation لإسكاتها أو استبدالها:

JavaScript
import { afterEach, test, expect, vi } from 'vitest';

afterEach(() => {
  vi.restoreAllMocks(); // put console.warn back
});

test('warns and carries on when one reminder fails', () => {
  const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
  const send = vi.fn().mockImplementationOnce(() => { throw new Error('Mailbox full'); });

  const sent = remindDebtors({ Ben: -500, Dee: -300 }, send);

  expect(sent).toBe(1);
  expect(send).toHaveBeenCalledTimes(2);
  expect(warn).toHaveBeenCalledWith('Could not remind Ben: Mailbox full');
});

يستبدل الـ spy دالة على كائن مشترك. انسَ استعادتها، وسيعمل كل اختبار لاحق في الملف مع console.warn صامت. وجود vi.restoreAllMocks() داخل afterEach، أو restoreMocks: true في الإعدادات، يجعل التنظيف تلقائيًا.

المؤقّتات المزيّفة: تخطّي الانتظار

الكود الذي ينتظر (الحفظ التلقائي المؤجَّل بـ debounce، والتباطؤ بين المحاولات، ورسائل “تراجع” المؤقّتة) اختباره بالوقت الحقيقي مؤلم. تستبدل المؤقّتات المزيّفة (fake timers) كلًّا من setTimeout وsetInterval وDate بساعة يتحكّم فيها الاختبار:

JavaScript
import { afterEach, test, expect, vi } from 'vitest';

afterEach(() => vi.useRealTimers());

test('autosaves the draft 2 seconds after the last change', () => {
  vi.useFakeTimers();
  const save = vi.fn();
  const draft = createDraft({ save, delayMs: 2000 }); // debounced

  draft.update({ description: 'Din' });
  draft.update({ description: 'Dinner' });
  vi.advanceTimersByTime(1999);
  expect(save).not.toHaveBeenCalled();

  vi.advanceTimersByTime(1);
  expect(save).toHaveBeenCalledWith({ description: 'Dinner' });
});

يشغّل vi.advanceTimersByTime(ms) فورًا كل مؤقّت يحين موعده خلال تلك الفترة. ويثبّت vi.setSystemTime(date) قيمة new Date()، وهكذا تختبر أن “التذكيرات تُرسل في أول كل شهر”. وحين تجدول المؤقّتات promises، استخدم النسخ غير المتزامنة مثل vi.advanceTimersByTimeAsync. (ساحة التجربة لا تشغّل إلا المؤقّتات الحقيقية، لذا يبقى هذا المثال على الصفحة فقط.)

متى يضرّ الـ mocking

كل mock ادّعاء عن طريقة عمل الشيء الحقيقي. وحين يبتعد الواقع عن الادّعاء، يستمرّ الاختبار في النجاح. الأنماط التي تؤذي:

  • استخدام الـ mocks مكان وحداتك أنت. استبدال parseAmount داخل اختبار النموذج يعني أن النموذج والمحلّل لا يُختبران معًا أبدًا، وهذا كان الهدف من الاختبار. كودك الصافي سريع؛ استخدمه.
  • استخدام mock مكان الشيء الخاضع للاختبار نفسه. إن تجسّست على remindDebtors وأنت تختبر remindDebtors، فأنت تختبر الـ spy.
  • تأكيد كل استدعاء. فحص التسلسل الدقيق لأربعة عشر استدعاءً داخليًا يجمّد التفاصيل الداخلية. أكّد الاستدعاءات التي تهمّ المستدعي، مثل “وصل تذكير إلى Ben”.
  • mocks تكذب. الـ API البديل لديك يعيد { id }؛ والحقيقي يعيد { data: { id } }. الـ unit tests خضراء، والتطبيق معطّل. ومحاكاة الشبكة في القسم التالي واختبارات الـ end-to-end في القسم 4 موجودة لالتقاط هذا بالضبط.

فحص سريع لأي mock تكتبه: احذف سطرًا واحدًا من الكود الحقيقي الذي يقوم مقامه. إن لم يفشل أي اختبار في أي مكان من المجموعة، فذلك الـ mock كان يُخفي السلوك بدل أن يختبر ما حوله، وتحتاج في مكان ما إلى اختبار يشغّل الشيء الحقيقي.

قاعدتي: استخدم الـ mocks عند حدود نظامك (الشبكة، والبريد، والدفع، والوقت، والعشوائية)، وشغّل الكود الحقيقي في كل ما بداخله.

في التمرين، استخدم vi.fn وvi.spyOn لالتقاط أربعة أخطاء في remindDebtors. وينتقل القسم 3 من الدوال إلى الـ DOM.

أهم النقاط

  • ينشئ vi.fn() دالة تسجّل كل استدعاء، وتستطيع أن تحدّد ما تعيده باستخدام mockReturnValue أو mockResolvedValue أو mockImplementation.
  • يراقب vi.spyOn(object, 'method') دالة حقيقية ويستطيع استبدالها؛ أعِد الـ spies دائمًا إلى أصلها، مثلًا بـ vi.restoreAllMocks() داخل afterEach.
  • استخدم الـ mocks عند حدود نظامك (الشبكة، والبريد، والوقت، والعشوائية)، واستخدم الكود الحقيقي لوحداتك أنت.
  • تسمح المؤقّتات المزيّفة (vi.useFakeTimers وvi.advanceTimersByTime) للاختبار بأن يقفز فوق فترات الانتظار بدل أن ينتظرها.
  • كل mock ادّعاء عن طريقة عمل الشيء الحقيقي؛ وحين يبتعد هذا الادّعاء عن الواقع، يبقى الاختبار أخضر بينما ينكسر الإنتاج.

قراءات إضافية

تحقّق من فهمك

4 أسئلة

السؤال 1 من 4

تريد التحقّق من أن remindDebtors تستدعي sendReminder('Ben', 'You owe $5.00 to the group'). أيّ assertion هو المناسب؟

السؤال 2 من 4

اختبار لنموذج المصروفات يستخدم mock مكان الوحدة الداخلية parseAmount بحيث تعيد دائمًا 1250. ثم يظهر خطأ في parseAmount الحقيقية. ماذا يحدث لاختبار النموذج؟

السؤال 3 من 4

تُحفظ المسوّدة تلقائيًا بعد ثانيتين من آخر ضغطة مفتاح. مع تفعيل vi.useFakeTimers()، كيف يجعل الاختبار الحفظ يحدث؟

السؤال 4 من 4

لماذا تستدعي vi.restoreAllMocks() (أو تضبط restoreMocks) بعد الاختبارات التي تستخدم vi.spyOn؟

تدريب

بيئة تجريب الكود

Mocks لـ remindDebtors

تستدعي remindDebtors(balances, sendReminder) الدالة sendReminder(name, message) لكل شخص عليه $1.00 على الأقل، وتحذّر عبر console.warn حين يفشل تذكير، وتعيد عدد التذكيرات التي أرسلتها. الأرصدة الموجبة لأشخاص لهم مال؛ والسالبة لأشخاص عليهم مال. القواعد مذكورة في تعليق الـ subject.

يتجسّس الكود المبدئي على console.warn مسبقًا (ويستعيده بعد كل اختبار). استخدم vi.fn() مكان sendReminder وأضف اختبارات حتى تلتقط مجموعتك أربعة أخطاء مخفيّة: نسخة تذكّر من لهم مال، ونسخة تبادل موقعي المعاملين، ونسخة تتخطّى دينًا قيمته $1.00 بالضبط، ونسخة تتوقّف عند أول تذكير فاشل.

function formatMoney(cents) {
  const sign = cents < 0 ? '-' : '';
  const abs = Math.abs(cents);
  return `${sign}${Math.floor(abs / 100)}.${String(abs % 100).padStart(2, '0')}`;
}

// Sends a reminder to everyone who owes money (a negative balance).
// - Calls sendReminder(name, message) once per debtor, with a message
//   such as "You owe $5.00 to the group".
// - Skips debts smaller than minimumCents (default 100, i.e. $1.00);
//   a debt of exactly minimumCents IS reminded.
// - People who are owed money or are settled up get nothing.
// - If sendReminder throws for someone, logs
//   console.warn("Could not remind <name>: <error message>")
//   and carries on with the others.
// Returns the number of reminders that were sent successfully.
function remindDebtors(balances, sendReminder, { minimumCents = 100 } = {}) {
  let sent = 0;
  for (const [name, cents] of Object.entries(balances)) {
    if (cents >= 0 || -cents < minimumCents) continue;
    try {
      sendReminder(name, `You owe ${formatMoney(-cents)} to the group`);
      sent += 1;
    } catch (error) {
      console.warn(`Could not remind ${name}: ${error.message}`);
    }
  }
  return sent;
}
جارٍ تحميل المحرّر…

Ctrl/⌘ + Enter للتشغيل · يعمل في بيئة معزولة داخل متصفّحك. لا شيء يغادر جهازك.

النتائج

(لا مخرجات)

الفحوص

  • كل اختباراتك تنجح على remindDebtors الحقيقية
  • تلتقط النسخة التي تذكّر من لهم مال
  • تلتقط النسخة التي تبادل موقعي الاسم والرسالة
  • تلتقط النسخة التي تتخطّى دينًا قيمته $1.00 بالضبط
  • تلتقط النسخة التي تتوقّف عند أول تذكير فاشل