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

الوحدة 1 · لماذا نختبر، وماذا نختبر

تشريح الاختبار وقراءة رسائل الفشل

18 دقراءة 4 دبيئة تجريب الكود

الدرس في لمحة

تبني كل اختبار بنمط Arrange وAct وAssert، وتختار بين toBe وtoEqual، وتقرأ رسالة فشل Vitest لتصل إلى السطر المعطوب في ثوانٍ.

  1. 1Arrange وAct وAssert
  2. 2الأسماء هي السطر الأول في تقرير الخطأ
  3. 3toBe أم toEqual؟
  4. 4قراءة رسالة الفشل
  5. 5اختبار أن شيئًا ما يرمي خطأً

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

يُقرأ الاختبار أكثر بكثير مما يُكتب. يُقرأ حين يفشل في لحظة سيئة، على يد شخص لم يكتبه، يريد إجابة واحدة: ما الذي انكسر؟ هذا الدرس عن كتابة اختبارات تجيب عن هذا السؤال في ثوانٍ، وعن قراءة الإجابة التي تعطيك إياها Vitest.

Arrange وAct وAssert

لكل اختبار جيد تقريبًا الأجزاء الثلاثة نفسها:

src/tip.test.js
import { test, expect } from 'vitest';
import { withTip } from './tip.js';

test('rounds a half-cent tip up to the next cent', () => {
  // Arrange: the inputs that matter for this behaviour
  const subtotalCents = 1980;
  const percent = 12.5;

  // Act: one call to the code under test
  const result = withTip(subtotalCents, percent);

  // Assert: the claims about the outcome
  expect(result).toEqual({ tipCents: 248, totalCents: 2228 });
});

Arrange (التهيئة) يبني العالم الذي يحتاجه الاختبار. Act (الفعل) يفعل شيئًا واحدًا فقط: السلوك الذي تختبره. Assert (التأكيد) يذكر ما يجب أن يكون صحيحًا الآن. لن تحتاج إلى التعليقات بعد أن يصبح هذا الشكل عادة؛ الأسطر الفارغة تكفي.

يحميك هذا الشكل من أشهر رائحة سيئة في الاختبارات: اختبار يفعل، ثم يؤكّد، ثم يفعل مرة أخرى، ثم يؤكّد مرة أخرى. حين يفشل، لا تستطيع أن تعرف أيّ خطوة انكسرت. قسّمه إلى اختبارين، لكل منهما اسم يقول ما الذي يثبته.

ثلاثة صناديق متجاورة: Arrange يبني المدخلات، وAct يستدعي withTip مرة واحدة، وAssert يقارن النتيجة بـ toEqual. Arrange 1980, 12.5 Act withTip(...) Assert toEqual({...})
Arrange وAct وAssert: تهيئة واحدة، وفعل واحد، ثم الادّعاءات

كم assertion في الاختبار الواحد؟

ستسمع قاعدة “assertion واحد لكل اختبار”، وهي قاعدة صارمة أكثر من اللازم. القاعدة الحقيقية هي سلوك واحد لكل اختبار. فحص tipCents وtotalCents معًا بعد استدعاء واحد هو سلوك واحد له حقيقتان يمكن ملاحظتهما، فلا بأس بتأكيد الاثنتين، وtoEqual على الكائن كله يفعل ذلك في سطر واحد. ما تريد تجنّبه هو اختبار يفحص التقريب، ثم يستدعي الدالة مجددًا ليفحص حالة الخطأ، ثم مرة ثالثة ليفحص الصفر. حين يفشل الادّعاء الأول، توقف Vitest ذلك الاختبار، فلا تعرف أبدًا إن كان السلوكان الآخران ما زالا يعملان. ثلاثة اختبارات صغيرة تعطيك ثلاث إجابات منفصلة.

الأسماء هي السطر الأول في تقرير الخطأ

حين يفشل اختبار، يطبع التقرير مساره الكامل: withTip > rounds a half-cent tip up to the next cent. اكتب ذلك الاسم جملةً عن السلوك. “withTip works” لا تخبرك بشيء؛ أما “rejects a negative tip percent” فتخبرك بما تراجع قبل أن تفتح أي ملف. اجمع بـ describe حسب الوحدة، وسمِّ بـ test حسب القاعدة.

toBe أم toEqual؟

يقوم اثنان من الـ matchers (أدوات المطابقة) بمعظم العمل، ويقارن كلٌّ منهما بطريقة مختلفة:

  • toBe يستخدم Object.is: القيمة البدائية نفسها، أو مرجع الكائن نفسه. استخدمه مع الأرقام والنصوص والقيم المنطقية.
  • toEqual يقارن البنية بشكل متكرّر: المفاتيح نفسها، والقيم نفسها. استخدمه مع الكائنات والمصفوفات. (ويفحص toStrictEqual أيضًا تطابق الخصائص التي قيمتها undefined وأنواع الأصناف.)

expect(withTip(2000, 15)).toBe({ tipCents: 300, totalCents: 2300 }) يفشل رغم تطابق القيم، لأن الدالة تعيد كائنًا جديدًا. هذا الفخ يقع فيه الجميع مرة واحدة على الأقل.

قراءة رسالة الفشل

إليك نوعًا حقيقيًا من الأخطاء. النسخة الأولى من toCents، التي تحوّل مبلغًا بالدولار إلى سنتات، استخدمت Math.floor:

JavaScript
const toCents = (dollars) => Math.floor(dollars * 100);
console.log(0.29 * 100);    // what the computer actually computes
console.log(toCents(0.29)); // one cent short
console.log(Math.round(0.29 * 100));

يفشل الاختبار expect(toCents(0.29)).toBe(29)، وتعرض Vitest شيئًا كهذا:

Text
 FAIL  src/money.test.js > toCents > converts 0.29 dollars to 29 cents
AssertionError: expected 28 to be 29 // Object.is equality

- Expected
+ Received

- 29
+ 28

 ❯ src/money.test.js:7:28

اقرأها من الأعلى إلى الأسفل، على ثلاث مراحل:

  1. المسار يخبرك أيّ سلوك انكسر: تحويل 0.29 دولار.
  2. المتوقّع مقابل المستلم يخبرك كيف انكسر: ينقص سنتًا واحدًا، إذن شيء ما اقتطع القيمة بدل أن يقرّبها.
  3. الموقع (money.test.js:7:28) يشير إلى سطر الـ assertion في الاختبار. والخطأ موجود في الكود الذي يستدعيه ذلك السطر.

مع الكائنات، يعلّم الفرق (diff) الأسطر المختلفة فقط، فيبرز totalCents الخاطئ داخل كائن من عشرة مفاتيح فورًا.

اختبار أن شيئًا ما يرمي خطأً

أحيانًا يكون السلوك المطلوب هو “رفض المدخلات السيئة”. لفّ الاستدعاء داخل دالة كي يستطيع expect أن يستدعيها ويلتقط الخطأ:

JavaScript
test('rejects a negative tip percent', () => {
  expect(() => withTip(1000, -5)).toThrow(RangeError);
});

بدون الـ arrow function، ترمي withTip الخطأ أثناء حساب المعاملات، قبل أن يعمل expect، فينهار الاختبار بدل أن ينجح. ويقبل toThrow أيضًا نصًّا أو تعبيرًا نمطيًا (regex) لمطابقة رسالة الخطأ.

في التمرين تكتب اختبارات بنمط AAA لـ withTip، بما فيها الحدّ الذي يسهل نسيانه: بقشيش قيمته صفر. وينتقل القسم التالي من “كيف يبدو الاختبار” إلى “أيّ الاختبارات تستحق أن تُكتب”.

أهم النقاط

  • يعطي نمط Arrange وAct وAssert كل اختبار الشكل الثلاثي نفسه، فيجد القارئ التهيئة والفعل والادّعاء دون بحث.
  • يفحص الاختبار الواحد سلوكًا واحدًا، واسمه جملة تخبرك أيّ سلوك انكسر حين يفشل.
  • يقارن toBe باستخدام Object.is (القيمة نفسها أو المرجع نفسه)؛ أما toEqual فيقارن البنية، وهذا ما تريده مع الكائنات والمصفوفات.
  • يعطيك تقرير الفشل مسار الاختبار، والقيمتين المتوقّعة والمستلمة، والملف ورقم السطر؛ اقرأ الثلاثة قبل أن تلمس الكود.

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

تحقّق من فهمك

4 أسئلة

السؤال 1 من 4

ماذا يُظهر هذا الاختبار؟

JavaScript
test('builds the tip summary', () => {
  expect(withTip(2000, 15)).toBe({ tipCents: 300, totalCents: 2300 });
});
السؤال 2 من 4

يطبع اختبار فاشل expected 28 to be 29 للاستدعاء toCents(0.29). أين تنظر أولًا؟

السؤال 3 من 4

أيّ اسم اختبار هو الأكثر فائدة حين يفشل في CI في الثانية فجرًا؟

السؤال 4 من 4

لماذا تظهر مراحل Arrange وAct وAssert عادةً بهذا الترتيب، يفصل بينها سطر فارغ؟

تدريب

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

اختبارات AAA لـ withTip

تعيد withTip(subtotalCents, percent) الكائن { tipCents, totalCents }. يُقرَّب البقشيش إلى أقرب سنت (ونصف السنت يُقرَّب إلى الأعلى)، والبقشيش بنسبة 0% صالح، والنسبة السالبة ترمي RangeError.

يفحص الاختبار المبدئي بقشيشًا بنسبة 15% على $20.00. أضف اختبارات بشكل Arrange وAct وAssert حتى تنجح مجموعتك على الكود الحقيقي وتلتقط ثلاثة أخطاء مخفيّة: نسخة تأخذ الجزء الصحيح من البقشيش بدل تقريبه، ونسخة تنسى رفض النسب السالبة، ونسخة ترفض البقشيش الصفري خطأً.

// Adds a tip to a subtotal. Money is in integer cents; percent is a
// number such as 15 or 12.5. The tip is rounded to the nearest cent
// (a half cent rounds up). A zero tip is allowed; a negative one is not.
function withTip(subtotalCents, percent) {
  if (percent < 0) throw new RangeError('Tip percent cannot be negative');
  const tipCents = Math.round((subtotalCents * percent) / 100);
  return { tipCents, totalCents: subtotalCents + tipCents };
}
جارٍ تحميل المحرّر…

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

النتائج

(لا مخرجات)

الفحوص

  • كل اختباراتك تنجح على withTip الحقيقية
  • تلتقط النسخة التي تأخذ الجزء الصحيح من البقشيش
  • تلتقط النسخة التي تقبل نسبة سالبة
  • تلتقط النسخة التي ترفض البقشيش الصفري