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

الاختبارات المُعامَلة والديناميكية

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

الاختبارات المُعامَلة والديناميكية

كتابة منطق الاختبار ذاته مرات عديدة بمدخلات مختلفة فخٌّ صيانة حقيقي. يحلّ JUnit 5 هذه المشكلة بأناقة عبر @ParameterizedTest، الذي يُشغّل دالة اختبار واحدة مرةً لكل صف من المدخلات. وللحالات التي يجب فيها حساب مجموعة الاختبارات في وقت التشغيل، يُنشئ @TestFactory كائنات DynamicTest ديناميكيًا. يُلغي هذان الأسلوبان معًا الاختبارات المنسوخة ويجعلان تغطية الحالات الحدّية صريحةً ومنهجية.

لماذا تهمّ المُعاملة؟

تأمّل مُتحقِّقًا يجب أن يقبل مجموعة متنوعة من المدخلات الصحيحة ويرفض مجموعة مماثلة من المدخلات الخاطئة. بدون المُعاملة ستكتب اختبارًا لكل حالة — ستة مدخلات تصبح ست دوال شبه متطابقة. بـ @ParameterizedTest تُعبّر عن تلك الحالات الست في جدول بيانات وتحتفظ بجسم تأكيد واحد. الفوائد ملموسة:

  • إصلاح خطأ واحد في منطق التأكيد يُصلح كل حالة دفعةً واحدة.
  • إضافة حالة حدّية جديدة تعني سطرًا واحدًا في مصدر البيانات، لا دالة جديدة.
  • تعرض تقارير الاختبار كل مجموعة وسيطات على حدة، فيتحدّد الفشل فورًا.
التبعية: يقع @ParameterizedTest في junit-jupiter-params. إن استخدمت مُجمِّع junit-jupiter البومي فهو مُضمَّن تلقائيًا؛ وإلا أضفه صراحةً في ملف البناء.

@ValueSource — وسيطات عددية/نصية مُضمَّنة

@ValueSource هو أبسط مصدر. تُدرج فيه قيمًا حرفية من نوع واحد ويُغذّيها JUnit واحدةً تلو الأخرى إلى دالة الاختبار. الأنواع المدعومة تشمل int وlong وdouble وString وClass وغيرها.

import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; import static org.junit.jupiter.api.Assertions.assertTrue; class PalindromeCheckerTest { @ParameterizedTest(name = "ispalindrome({0})") @ValueSource(strings = {"racecar", "level", "madam", "deed"}) void validPalindromes(String word) { assertTrue(PalindromeChecker.check(word)); } @ParameterizedTest @ValueSource(ints = {-5, 0, 1, 100, Integer.MAX_VALUE}) void absoluteValueIsNonNegative(int n) { assertTrue(Math.abs(n) >= 0); } }

تُخصّص سمة name على @ParameterizedTest اسم العرض. يُستبدل {0} بالوسيط الأول و{displayName} باسم الدالة. الاختبارات ذات الأسماء الجيدة تجعل مخرجات CI مقروءةً دون فتح الكود المصدري.

@MethodSource — وسيطات من دالة مصنع

يستدعي @MethodSource دالةً static تُعيد Stream (أو Iterable أو Iterator أو مصفوفة) من الوسيطات. هذا هو الخيار الصحيح حين تكون الوسيطات كائنات معقدة أو تتطلب بناءً غير تافه.

import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; import java.util.stream.Stream; import static org.junit.jupiter.params.provider.Arguments.arguments; import static org.junit.jupiter.api.Assertions.assertEquals; class DiscountCalculatorTest { // الوسيطات: السعر، مستوى العميل، السعر النهائي المتوقع static Stream<Arguments> discountScenarios() { return Stream.of( arguments(100.0, "GOLD", 80.0), arguments(100.0, "SILVER", 90.0), arguments(100.0, "REGULAR", 100.0), arguments(200.0, "GOLD", 160.0) ); } @ParameterizedTest(name = "price={0}, tier={1} -> {2}") @MethodSource("discountScenarios") void appliesCorrectDiscount(double price, String tier, double expected) { DiscountCalculator calc = new DiscountCalculator(); assertEquals(expected, calc.apply(price, tier), 0.001); } }

حين يحمل مصدر الدالة الاسم ذاته لدالة الاختبار يمكنك حذف وسيط السلسلة النصية: يبحث @MethodSource بلا قيمة عن دالة ستاتيكية بالاسم ذاته تلقائيًا. لإعادة الاستخدام عبر الفصول، اذكر المسار الكامل: "com.example.Providers#discountScenarios".

أبقِ دوال المصنع نقيّة. تعمل دالة @MethodSource قبل إنشاء نسخة فصل الاختبار. يجب ألا تعتمد على حالة النسخة أو سياق Spring أو دخل/خرج خارجي. إن احتجت وسيطات من قاعدة بيانات، استخدم @MethodSource مع مساعد يقرأ من بنية بيانات في الذاكرة جُهِّزت في @BeforeAll.

@CsvSource و@CsvFileSource — البيانات الجدولية

يُعبّر @CsvSource عن صفوف متعددة الأعمدة كسلاسل نصية محاطة بعلامات اقتباس، إذ يبقى البيانات قريبةً من الاختبار دون حاجة لدالة مصنع منفصلة. يُحوّل JUnit كل عمود إلى نوع المعامل تلقائيًا.

import org.junit.jupiter.params.provider.CsvSource; import static org.junit.jupiter.api.Assertions.assertEquals; class StringUtilsTest { @ParameterizedTest(name = "truncate({0}, {1}) = {2}") @CsvSource({ "Hello World, 5, Hello", "Hi, 10, Hi", "'', 5, ''", "Testing, 7, Testing" }) void truncatesCorrectly(String input, int maxLen, String expected) { assertEquals(expected, StringUtils.truncate(input, maxLen)); } }

علامات الاقتباس المفردة داخل قيمة CSV تُحدّد سلاسل مُضمَّنة تحتوي على فواصل أو مسافات. القيمة الفارغة تُكتب '' وتُحوَّل إلى String فارغ (ليس null). لمجموعات البيانات الأكبر، انقل البيانات إلى ملف واستخدم @CsvFileSource(resources = "/test-data/truncate.csv") — يقرأه JUnit من classpath الاختبار.

قيود التحويل التلقائي للأنواع. يُحوّل JUnit 5 أعمدة CSV إلى أنواع بدائية وString وEnum والأنواع التي لها مُنشئ بـ String واحد أو دالة ستاتيكية valueOf. للكائنات الخاصة بالنطاق (مثل Money أو UserId) استخدم @MethodSource وأنشئها صراحةً في المصنع. محاولة تحويل كائنات تعسفية عبر CSV يؤدي إلى أخطاء غامضة.

مصادر أخرى: NullAndEmpty وEnumSource

يوفّر JUnit 5 عدة مصادر مدمجة أخرى تستحق المعرفة:

  • @NullSource / @EmptySource — يُحقنان null أو قيمة فارغة (سلسلة فارغة، قائمة، مصفوفة). ادمجهما بـ @NullAndEmptySource للتحقق الدفاعي.
  • @EnumSource — يمرّ على ثوابت Enum، مع خيار names وmode (INCLUDE / EXCLUDE / MATCH_ALL / MATCH_ANY) للاختيار الدقيق.
import org.junit.jupiter.params.provider.EnumSource; import static org.junit.jupiter.api.Assertions.assertNotNull; enum Status { PENDING, ACTIVE, SUSPENDED, CLOSED } @ParameterizedTest @EnumSource(value = Status.class, names = {"ACTIVE", "SUSPENDED"}) void activeAndSuspendedHaveDescription(Status s) { assertNotNull(StatusDescriptionRegistry.get(s)); }

الاختبارات الديناميكية بـ @TestFactory

أحيانًا لا يمكن التعبير عن مجموعة الاختبارات بصورة ستاتيكية — فهي تعتمد على بيانات تُكتشف في وقت التشغيل (مثل ملفات في دليل، أو صفوف من مستودع في الذاكرة، أو مدخلات مُحلَّلة من ملف إعداد). يُعيد @TestFactory Stream<DynamicTest> أو أي Iterable من DynamicNode. كل DynamicTest له اسم عرض وExecutable (لامبدا).

import org.junit.jupiter.api.DynamicTest; import org.junit.jupiter.api.TestFactory; import java.util.stream.Stream; import static org.junit.jupiter.api.DynamicTest.dynamicTest; import static org.junit.jupiter.api.Assertions.assertTrue; class PrimeCheckerDynamicTest { private static final int[] KNOWN_PRIMES = {2, 3, 5, 7, 11, 13, 17, 19}; @TestFactory Stream<DynamicTest> knownPrimesAreDetected() { return Stream.of(KNOWN_PRIMES) .map(n -> dynamicTest( "isPrime(" + n + ")", () -> assertTrue(PrimeChecker.isPrime(n)) )); } }

خلافًا لـ @ParameterizedTest، دوال @TestFactory لا تُزيَّن بـ @Test ويمكنها أن تُنتج صفرًا من الاختبارات (قد يكون الـ Stream فارغًا). هذا يجعلها مناسبةً للسيناريوهات القائمة على الاكتشاف حيث "لم يُعثر على عناصر" نتيجةٌ صالحة لا فشل.

فضّل @ParameterizedTest للبيانات الثابتة، و@TestFactory للبيانات المُكتشَفة في وقت التشغيل. @ParameterizedTest أبسط، ودعم IDE له أفضل، ومصادر وسيطاته مرئية في التحكم بالإصدار. احتفظ بـ @TestFactory للحالات الديناميكية الحقيقية — استطلاع نظام الملفات، التحقق من عقود API عبر قائمة نقاط نهاية محمّلة من إعداد، أو الفحوصات الشاملة على فضاء مدخلات محسوب.

أفضل الممارسات المهنية

  • سمِّ اختباراتك المُعامَلة. اضبط دائمًا سمة name وصفية. name = "[{index}] input={0}" حدٌّ أدنى؛ الأسماء ذات المعنى مثل "price={0}, tier={1}" أفضل.
  • غطِّ الحدود والمدخلات الخاطئة صراحةً. المُعاملة تُخفّض تكلفة إضافة الحالات — استغل هذا الرخص لاختبار الحدود والمدخلات الفارغة والقيم القصوى والأخطاء التاريخية المعروفة.
  • أبقِ كل صف مستقلًا. يجب ألا يعتمد الاختبار المُعامَل على ترتيب التنفيذ. كل استدعاء اختبار منفصل في نموذج JUnit.
  • تجنّب الانفجار التوافقي. إن كانت لديك خمسة معاملات بعشر قيم لكل منها، فإن 10^5 تركيبة تُنتج ضوضاءً لا إشارة. اختبر التركيبات ذات المعنى، لا حاصل الضرب الديكارتي.

الخلاصة

يُحوّل @ParameterizedTest مع @ValueSource و@MethodSource و@CsvSource دوال الاختبار المتكررة إلى جداول بيانات أنيقة. يتعامل @TestFactory مع الحالات المتبقية حيث المدخلات مجهولة حتى وقت التشغيل. يتيح لك كلاهما رفع التغطية دون رفع تكلفة الصيانة — وهو الوعد الجوهري لمجموعة الاختبارات الجيدة.