الاختبار باستخدام JUnit 5 وMockito

التأكيدات بعمق

15 دقيقة الدرس 3 من 13

التأكيدات بعمق

التأكيدات هي قلب كل اختبار. الاختبار الذي لا يحتوي على تأكيد ذي معنى ليس أكثر من ستارة دخانية — يمكنه النجاح حتى لو كان الكود المختبر خاطئًا تمامًا. يأتي JUnit 5 مزوَّدًا بمجموعة غنية من التوابع الساكنة في org.junit.jupiter.api.Assertions تغطّي المساواة، وسلوك الاستثناءات، والفحوصات المجمّعة، وغير ذلك. في هذا الدرس ستتجاوز الأساسيات وتتعلّم كتابة تأكيدات دقيقة وموثِّقة لنفسها ومفيدة فعلًا عند الفشل.

assertEquals — أكثر من مجرد مقارنة

assertEquals(expected, actual) هو أكثر التأكيدات استخدامًا. الترتيب مهم: القيمة المتوقّعة أولًا، والقيمة الفعلية ثانيًا. هذا الترتيب ليس اعتباطيًا — يستخدمه JUnit لإنتاج رسالة الفشل "expected: <X> but was: <Y>". عكس الترتيب يُنتج رسالة مربكة ومعكوسة تضيّع وقت التصحيح.

import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; class PriceCalculatorTest { @Test void applyDiscount_tenPercent_reducesPrice() { PriceCalculator calc = new PriceCalculator(); double result = calc.applyDiscount(100.0, 10); assertEquals(90.0, result, 0.001, "10% discount on 100 should yield 90"); } }

الوسيط الثالث هنا هو delta (هامش التسامح) للمقارنات العشرية — ضروري كلّما تضمّن حسابك double أو float، حيث تتراكم أخطاء التقريب. الوسيط الرابع هو رسالة الفشل. أضِف دائمًا رسالة وصفية: حين ينكسر هذا الاختبار في الثانية صباحًا، ينبغي للرسالة أن تخبر القارئ بما كان متوقَّعًا دون الحاجة إلى استنتاجه من اسم الاختبار.

موردو الرسائل الكسولة: مرِّر Supplier<String> بدلًا من String عادي للرسائل المكلفة الحساب. يُقيِّم JUnit المورِّد فقط عند فشل التأكيد، فلا تكلفة على المسار الناجح:

assertEquals(expected, actual, () -> "Computed from " + expensiveOperation());

لمقارنة الكائنات، يفوِّض assertEquals العملية إلى .equals(). يعني ذلك أن كائنات نطاقك يجب أن تُطبِّق equals بشكل صحيح — وهو مصدر شائع لنتائج إيجابية زائفة عند مقارنة List أو Map أو أنواع القيم المخصَّصة. للتحقّق من المرجعية (نفس الكائن في الذاكرة)، استخدم assertSame.

assertThrows — اختبار المسارات الاستثنائية

الكود الإنتاجي الذي لا يرمي استثناءً حين يجب يُعدّ مكسورًا تمامًا كالكود الذي يُعيد قيمة خاطئة. يتيح لك assertThrows التحقّق من أن استثناءً قد رُمي وأنه من النوع الصحيح — كما يُعيد نسخة الاستثناء لتتمكّن من فحص رسالته أو سببه.

@Test void divide_byZero_throwsArithmeticException() { Calculator calc = new Calculator(); ArithmeticException ex = assertThrows( ArithmeticException.class, () -> calc.divide(10, 0), "Dividing by zero must throw ArithmeticException" ); assertTrue(ex.getMessage().contains("zero"), "Exception message should mention 'zero'"); }

اللامبدا المُمرَّرة إلى assertThrows هي القابل للتنفيذ — فقط الكود بداخلها هو المتوقَّع منه الرمي. هذا الحد مهم: لا تضع كود الإعداد داخل اللامبدا وإلا قد تلتقط استثناءً من المكان الخطأ.

مشكلة شائعة — تغليف كود زائد: إذا وضعت بناء الكائن أو تحضير البيانات داخل لامبدا assertThrows ورمت تلك الشيفرة استثناءً غير متوقَّع، ينجح اختبارك لسبب خاطئ. أبقِ اللامبدا ضيّقة قدر الإمكان — فقط الاستدعاء الواحد الذي يجب أن يرمي.

للسيناريو المعاكس — التحقّق من أن الكود لا يرمي — استخدم assertDoesNotThrow:

@Test void parse_validInput_doesNotThrow() { assertDoesNotThrow(() -> DateParser.parse("2024-01-15"), "A well-formed ISO date string must parse without exception"); }

assertAll — تجميع التأكيدات

عند استخدام سلسلة عادية من استدعاءات assertEquals، يوقف الفشل الأول التنفيذ ويخفي جميع الفشلات اللاحقة. يجعل ذلك تشخيص الكائنات متعددة الحقول أمرًا مؤلمًا — تُصلح حقلًا، تُعيد التشغيل، ترى الفشل التالي، وهكذا. assertAll يشغّل كل قابل للتنفيذ في المجموعة ويُبلّغ عن جميع الفشلات معًا.

@Test void userMapper_mapsAllFields() { UserDto dto = new UserDto("alice@example.com", "Alice", 30); User user = UserMapper.toUser(dto); assertAll("User field mapping", () -> assertEquals("alice@example.com", user.getEmail(), "email"), () -> assertEquals("Alice", user.getName(), "name"), () -> assertEquals(30, user.getAge(), "age"), () -> assertFalse(user.isAdmin(), "new users must not be admin") ); }

الوسيط الأول هو عنوان يُضاف كبادئة لتقرير الفشل، مما يوضّح فورًا أي مجموعة تأكيدات فشلت. هذا لا يقدَّر بثمن حين يحتوي الاختبار على مجموعات assertAll متعددة.

متى تستخدم assertAll مقابل توابع اختبار منفصلة: استخدم assertAll حين تكون التأكيدات خصائص مترابطة منطقيًا لنفس النتيجة — مثل جميع حقول كائن مُحوَّل. إذا كانت الخصائص تمثّل سلوكيات مستقلة، فضِّل توابع @Test منفصلة بحيث يكون لكل منها اسم واضح ومركّز وتفشل باستقلالية.

كتابة تأكيدات واضحة

التأكيد هو توثيق أيضًا. المشرفون المستقبليون — وأنت في المستقبل — سيقرؤونه لفهم ما يُفترض أن يفعله الكود. المبادئ التالية تجعل التأكيدات مفيدة فعلًا:

  1. مفهوم منطقي واحد لكل اختبار. لا تختبر خمسة أشياء غير ذات صلة في تابع واحد. حين يكون اسم الاختبار shouldReturnCorrectResultWhenInputIsValidAndUserIsAuthenticatedAndCacheIsWarm، فهو يحاول أن يفعل الكثير.
  2. التأكيد على النتائج لا التنفيذات. فضِّل التأكيد على القيمة المُعادة أو تغيّر الحالة بدلًا من أي تابع داخلي استُدعي (ذلك ينتمي إلى التحقّق في Mockito، موضوع درس لاحق).
  3. أضِف رسائل فشل للتأكيدات غير الواضحة. assertTrue(result > 0) يفشل برسالة "expected: true but was: false" — عديمة الفائدة. assertTrue(result > 0, "balance must be positive after deposit") يفشل برسالة يستطيع أي شخص التصرّف بناءً عليها.
  4. استخدم أكثر التأكيدات تحديدًا المتاح. assertNotNull(list) ثم assertEquals(3, list.size()) يمكن استبدالهما بـ assertIterableEquals واحد، أو بسلسلة AssertJ السلسة. التأكيدات المحددة تُنتج رسائل فشل أفضل.
// ضعيف — ينجح إذا احتوت القائمة على عناصر خاطئة طالما الحجم متطابق assertEquals(3, result.size()); // أقوى — يفحص المحتوى الدقيق والترتيب assertIterableEquals(List.of("alpha", "beta", "gamma"), result); // أو التحقّق من شرط برسالة واضحة assertTrue(result.contains("beta"), "result list must contain 'beta'");

ما وراء المكتبة القياسية — AssertJ

تغطّي فئة Assertions القياسية الحالات الشائعة. للحصول على رسائل فشل أغنى وسلاسل أكثر قابلية للقراءة، تضيف كثير من الفِرق AssertJ (assertj-core) إلى جانب JUnit 5. تُوفّر واجهة برمجية سلسة:

import static org.assertj.core.api.Assertions.assertThat; @Test void getActiveUsers_returnsOnlyActive() { List<User> users = userService.getActiveUsers(); assertThat(users) .isNotEmpty() .hasSize(3) .extracting(User::getName) .containsExactlyInAnyOrder("Alice", "Bob", "Carol"); }

AssertJ ليست جزءًا من JUnit 5 لكنها تتكامل معه بشكل طبيعي. رسائل أخطائها تشمل الحالة الكاملة للكائن قيد الاختبار، مما يقلّص بشكل كبير الوقت المستغرق في قراءة تتبّعات الاستدعاء.

الخلاصة

تتّبع التأكيدات الفعّالة هيكلًا واضحًا: المتوقَّع قبل الفعلي، delta للأعداد العشرية، رسائل فشل ذات معنى، وأكثر نوع تأكيد تحديدًا متاح. استخدم assertThrows لتثبيت المسارات الاستثنائية وأبقِ القابل للتنفيذ ضيّقًا. استخدم assertAll لإظهار جميع فشلات الفحص متعدد الحقول دفعة واحدة. كل تأكيد تكتبه هو عقد — اجعله دقيقًا بما يكفي لالتقاط الخطأ المقصود بالضبط، ووصفيًا بما يكفي لأن تشرح رسالة فشله نفسها دون مصحِّح أخطاء.