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

أفضل ممارسات الاختبار

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

أفضل ممارسات الاختبار

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

١. سمِّ الاختبارات كمواصفات

اسم الاختبار هو أوّل ما يظهر عند فشل البناء. يجب أن يُجيب على ثلاثة أسئلة دون قراءة الجسم: ما الذي يُختبَر، وفي أيّ حالة، وما المتوقَّع.

النمط الشائع هو Given / When / Then مضغوطًا في اسم الدالة:

// سيّئ — لا يُخبرك بشيء مفيد عند الفشل @Test void test1() { ... } // سيّئ — يخبرك بالفعل لكن لا يذكر التوقّع @Test void withdraw() { ... } // جيّد — اسم الدالة هو المواصفة بحدّ ذاتها @Test void withdraw_reducesBalance_whenFundsAreSufficient() { ... } @Test void withdraw_throwsInsufficientFundsException_whenBalanceIsLow() { ... }

الشرطة السفلية داخل الاسم مقبولة (تفضّلها فرق كثيرة) لأنّها تجعل الهيكل الثلاثي قابلًا للمسح البصري في نتائج IDE وتقارير CI. يمكنك بديلًا استخدام @DisplayName لإضافة تسمية بجملة عادية دون تغيير المعرّف:

@Test @DisplayName("withdraw() reduces balance when funds are sufficient") void withdrawReducesBalanceWhenFundsSufficient() { ... }
عامل اسم الاختبار الفاشل كرسالة خطأ. إذا قرأ زميل في سجلّ CI عبارة "withdraw_reducesBalance_whenFundsAreSufficient FAILED"، يعرف فورًا مكان الخلل. أمّا "test1 FAILED" فلا تُقدّم أيّ دلالة.

٢. تحقُّق منطقي واحد في كل اختبار

يجب أن يتحقّق كل اختبار من سلوك واحد بالضبط. هذا لا يعني دائمًا استدعاء assertX() مرّة واحدة فحسب — أحيانًا يستلزم التحقّق من سلوك واحد فحص قيمتين مرتبطتين. لكنّ الاختبار الذي يتحقّق من عشرة أشياء غير مترابطة يُخفي سبب الفشل الجذري.

// سيّئ — تحقّقات متعدّدة غير مترابطة في اختبار واحد @Test void processOrder() { Order order = service.process(cart); assertEquals(OrderStatus.CONFIRMED, order.getStatus()); assertEquals(2, emailSender.getSentCount()); // شاغل مختلف assertTrue(inventory.isReduced()); // شاغل ثالث } // جيّد — كل شاغل في اختبار مستقل @Test void processOrder_setsStatusToConfirmed() { Order order = service.process(cart); assertEquals(OrderStatus.CONFIRMED, order.getStatus()); } @Test void processOrder_sendsConfirmationEmail() { service.process(cart); assertEquals(1, emailSender.getSentCount()); } @Test void processOrder_reducesInventory() { service.process(cart); assertTrue(inventory.isReduced()); }

٣. عزل النظام قيد الاختبار

يجب أن يُنشئ كل اختبار حالته الابتدائية الخاصة النظيفة. الحالة المتغيّرة المشتركة بين الاختبارات تُسبّب فشلًا تبعيًا — اختبارات تنجح منفردةً وتفشل عند تغيير الترتيب. JUnit 5 يُنشئ نسخة جديدة من الصنف لكل دالة اختبار بالافتراض، وهذا مُفيد، لكنّ عليك أيضًا:

  • تهيئة البيانات الابتدائية في @BeforeEach لا في حقول ثابتة.
  • استبدال المتعاونين الحقيقيين بمحاكيات (mocks) أو بدائل (fakes) كي يختبر الاختبار الصنف المقصود فقط.
  • عدم الاعتماد على نظام الملفات أو قاعدة بيانات حيّة أو نقطة HTTP خارجية في اختبار الوحدة.
// سيّئ — حالة ثابتة مشتركة تُسبّب تداخل الاختبارات class OrderServiceTest { static OrderService service = new OrderService(new RealDatabase()); // مشترك! @Test void test1() { service.place(order1); } @Test void test2() { service.place(order2); } // قد يفشل إذا غيّر test1 الحالة } // جيّد — نسخة جديدة لكل اختبار، المتعاونون محاكَون class OrderServiceTest { private OrderService service; private OrderRepository repoMock; @BeforeEach void setUp() { repoMock = mock(OrderRepository.class); service = new OrderService(repoMock); } @Test void place_savesOrder_whenCartIsValid() { service.place(validCart); verify(repoMock).save(any(Order.class)); } }
الحالة الثابتة المتغيّرة تقتل عزل الاختبارات. حتى إن أعدت تهيئتها في @AfterEach، فإنّ اختبارًا يرمي استثناءً قبل التنظيف يترك الحالة ملوّثة للاختبار التالي. استخدم حقول نسخ تُهيَّأ في @BeforeEach.

٤. اتّبع هيكل Arrange–Act–Assert (AAA)

يجب أن يحتوي كل اختبار على ثلاث مراحل منفصلة: تهيئة البيانات والمتعاونين (Arrange)، ثمّ استدعاء الكود المُختبَر (Act)، ثمّ التحقّق من النتيجة (Assert). سطر فارغ بين كل مرحلة يجعل البنية مقروءة فورًا.

@Test void transfer_movesMoneyBetweenAccounts() { // Arrange Account source = new Account(1000); Account target = new Account(200); // Act source.transfer(300, target); // Assert assertEquals(700, source.getBalance()); assertEquals(500, target.getBalance()); }

٥. ما الذي يجب اختباره

ركّز الاختبارات على السلوك القابل للملاحظة من منظور المُستدعي، لا على تفاصيل التنفيذ الداخلية.

  • عقود الواجهة العامة — القيم المُعادة، والاستثناءات المُرمَاة، وتغيّرات الحالة المرئية عبر الـ getters.
  • جميع مسارات المنطق الشرطي — المسار السعيد، وكل مسار خطأ ذي معنى، والحالات الحدّية (null، فارغ، صفر، أقصى قيمة).
  • قواعد العمل — التحقّقات، والحسابات، والثوابت التي يجب أن يلتزم بها نموذج المجال.
  • نقاط التكامل عبر اختبارات التكامل — أنّ استعلام JpaRepository يُعيد الصفوف الصحيحة، وأنّ نقطة HTTP تستجيب بالرمز الصحيح.
// اختبر العقد لا التفصيل الداخلي للتنفيذ @Test void discount_appliesPercentage_forPremiumCustomer() { Customer premium = new Customer(CustomerTier.PREMIUM); PricingService pricing = new PricingService(); BigDecimal discounted = pricing.calculate(new BigDecimal("100.00"), premium); // تحقّق من الناتج المرئي — لا تتحقّق من الدالة الخاصة التي استُدعيت assertEquals(new BigDecimal("80.00"), discounted); }

٦. ما الذي يجب عدم اختباره

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

  • الدوال الخاصة (private) — اختبرها عبر الواجهة العامة التي تستدعيها. إن كانت الدالة الخاصة معقّدة لدرجة تستلزم اختبارها مباشرةً، استخرجها إلى صنف متعاون.
  • أُطر العمل ذاتها — لا تختبر أنّ Spring يُسلّك Bean أو أنّ JPA يُولّد SQL صحيحًا. ثق بإطار العمل؛ اختبر منطقك أنت.
  • الـ getters/setters التافهة — getter يُعيد حقلًا لا يحتوي منطقًا. اختباره يُضيف ضجيجًا دون أمان.
  • بيانات الإعداد — ثابت أو قيمة enum أو ملف خصائص لا يحتاج اختبار وحدة.
مبدأ هرم الاختبار: اختبارات وحدة كثيرة وسريعة في القاعدة؛ اختبارات تكامل أقل في المنتصف؛ واختبارات شاملة (E2E) أقل عددًا في القمّة. عكس الهرم بكتابة معظم الاختبارات E2E يُنتج مجموعة بطيئة وهشّة يتوقّف المطوّرون عن تشغيلها محليًا.

٧. احرص على سرعة الاختبارات وحتميّتها

مجموعة اختبارات تستغرق عشر دقائق لن تُشغَّل قبل كل commit. احرص على السرعة بلا هوادة:

  • استبدل التبعيات البطيئة (قواعد البيانات، قوائم الانتظار، HTTP) بتطبيقات بديلة في الذاكرة أو بمحاكيات Mockito في اختبارات الوحدة.
  • استخدم @Tag("integration") لفصل اختبارات التكامل كي تعمل في CI دون أن تُبطّئ كل بناء محلّي.
  • لا تستخدم Thread.sleep() في اختبار لانتظار نتيجة غير متزامنة — استخدم Awaitility أو CompletableFuture.get(timeout).
  • تجنّب new Date() أو Instant.now() مباشرةً؛ أدخِل Clock حقنًا كي تتحكّم الاختبارات في الوقت.
// أدخِل Clock حقنًا كي يتحكّم الاختبار في "الآن" public class SubscriptionService { private final Clock clock; public SubscriptionService(Clock clock) { this.clock = clock; } public boolean isExpired(Subscription sub) { return sub.getExpiryDate().isBefore(LocalDate.now(clock)); } } // في الاختبار @Test void isExpired_returnsTrue_whenExpiryIsInThePast() { Clock fixed = Clock.fixed( Instant.parse("2025-06-01T00:00:00Z"), ZoneOffset.UTC); SubscriptionService service = new SubscriptionService(fixed); Subscription sub = new Subscription(LocalDate.of(2025, 5, 31)); assertTrue(service.isExpired(sub)); }

الخلاصة

سمات مجموعة الاختبارات عالية الجودة: أسماء تُقرأ كمواصفات، تحقّق منطقي واحد لكل اختبار، عزل كامل لكل حالة، هيكل AAA، اختبارات تستهدف السلوك العام القابل للملاحظة لا تفاصيل التنفيذ الخاصة، وزمن تشغيل سريع وحتمي. استوعب هذه الممارسات وستصبح مجموعة اختباراتك شبكة أمان تثق بها — شبكة تُسرّع التطوير بدلًا من أن تُعيقه.